Files
zzet--gortex/internal/hooks/pretooluse_mode_test.go
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

477 lines
20 KiB
Go

package hooks
import (
"encoding/json"
"strings"
"testing"
)
// TestRunPreToolUse_EnrichModeDowngradesDeny verifies that in ModeEnrich
// the same input that produces a deny in ModeDeny is downgraded to an
// additionalContext payload — the agent's call is never blocked, but
// the deny rationale still surfaces so the agent learns the graph
// alternative.
func TestRunPreToolUse_EnrichModeDowngradesDeny(t *testing.T) {
withEditBlocking(t, true)
port := fakeIndexedBridge(t, map[string]bool{"/repo/handler.go": true})
payload := []byte(`{"hook_event_name":"PreToolUse","tool_name":"Edit","tool_input":{"file_path":"/repo/handler.go"}}`)
t.Run("deny mode → permission decision = deny", func(t *testing.T) {
out := captureStdout(t, func() { runPreToolUse(payload, port, ModeDeny) })
if out == "" {
t.Fatal("expected JSON output")
}
var dec HookOutput
if err := json.Unmarshal([]byte(out), &dec); err != nil {
t.Fatalf("invalid JSON: %v\n%s", err, out)
}
if dec.HookSpecificOutput == nil {
t.Fatal("missing hookSpecificOutput")
}
if dec.HookSpecificOutput.PermissionDecision != "deny" {
t.Errorf("expected deny, got: %q", dec.HookSpecificOutput.PermissionDecision)
}
if dec.HookSpecificOutput.AdditionalContext != "" {
t.Errorf("deny mode must NOT also emit additionalContext, got: %q", dec.HookSpecificOutput.AdditionalContext)
}
})
t.Run("enrich mode → additionalContext, no deny", func(t *testing.T) {
out := captureStdout(t, func() { runPreToolUse(payload, port, ModeEnrich) })
if out == "" {
t.Fatal("expected JSON output")
}
var dec HookOutput
if err := json.Unmarshal([]byte(out), &dec); err != nil {
t.Fatalf("invalid JSON: %v\n%s", err, out)
}
if dec.HookSpecificOutput == nil {
t.Fatal("missing hookSpecificOutput")
}
if dec.HookSpecificOutput.PermissionDecision == "deny" {
t.Errorf("enrich mode must NEVER deny; got: %+v", dec.HookSpecificOutput)
}
if dec.HookSpecificOutput.AdditionalContext == "" {
t.Error("enrich mode should surface the deny rationale as additionalContext")
}
// The downgraded message should still reference the graph
// alternative (edit_symbol/edit_file/etc) so the agent learns
// without being blocked.
if !strings.Contains(dec.HookSpecificOutput.AdditionalContext, "edit_symbol") {
t.Errorf("downgraded context should retain graph alternative hints, got:\n%s",
dec.HookSpecificOutput.AdditionalContext)
}
})
}
// TestRunPreToolUse_EnrichModePreservesSoftContext makes sure non-deny
// soft guidance (the "PREFER graph tools" tip on unindexed source) is
// unchanged in ModeEnrich — only deny responses are downgraded.
func TestRunPreToolUse_EnrichModePreservesSoftContext(t *testing.T) {
port := fakeIndexedBridge(t, map[string]bool{}) // nothing indexed → soft path
payload := []byte(`{"hook_event_name":"PreToolUse","tool_name":"Read","tool_input":{"file_path":"/repo/unindexed.go"}}`)
out := captureStdout(t, func() { runPreToolUse(payload, port, ModeEnrich) })
if out == "" {
t.Fatal("expected JSON output for soft context")
}
var dec HookOutput
if err := json.Unmarshal([]byte(out), &dec); err != nil {
t.Fatalf("invalid JSON: %v\n%s", err, out)
}
if dec.HookSpecificOutput == nil || dec.HookSpecificOutput.AdditionalContext == "" {
t.Fatal("expected soft additionalContext to survive ModeEnrich")
}
if dec.HookSpecificOutput.PermissionDecision == "deny" {
t.Errorf("soft path must not become a deny — got: %q",
dec.HookSpecificOutput.PermissionDecision)
}
}
// TestParseMode covers every supported input plus the fallback for
// unknown / empty values.
func TestParseMode(t *testing.T) {
cases := map[string]Mode{
"": ModeDeny,
"deny": ModeDeny,
"DENY": ModeDeny,
" deny ": ModeDeny,
"enrich": ModeEnrich,
"ENRICH": ModeEnrich,
"consult-unlock": ModeConsultUnlock,
"Consult-Unlock": ModeConsultUnlock,
" consult-unlock": ModeConsultUnlock,
"nudge": ModeAdaptiveNudge,
"NUDGE": ModeAdaptiveNudge,
"adaptive-nudge": ModeAdaptiveNudge,
"unknown": ModeDeny,
}
for input, want := range cases {
t.Run(input, func(t *testing.T) {
if got := ParseMode(input); got != want {
t.Errorf("ParseMode(%q) = %v, want %v", input, got, want)
}
})
}
}
func TestModeString(t *testing.T) {
if ModeDeny.String() != "deny" {
t.Errorf("ModeDeny.String() = %q, want \"deny\"", ModeDeny.String())
}
if ModeEnrich.String() != "enrich" {
t.Errorf("ModeEnrich.String() = %q, want \"enrich\"", ModeEnrich.String())
}
if ModeConsultUnlock.String() != "consult-unlock" {
t.Errorf("ModeConsultUnlock.String() = %q, want \"consult-unlock\"", ModeConsultUnlock.String())
}
if ModeAdaptiveNudge.String() != "nudge" {
t.Errorf("ModeAdaptiveNudge.String() = %q, want \"nudge\"", ModeAdaptiveNudge.String())
}
}
// decodeHookOutput unmarshals captured stdout into a HookOutput,
// failing the test on malformed JSON.
func decodeHookOutput(t *testing.T, out string) HookOutput {
t.Helper()
var dec HookOutput
if err := json.Unmarshal([]byte(out), &dec); err != nil {
t.Fatalf("invalid JSON: %v\n%s", err, out)
}
return dec
}
// TestRunPreToolUse_ConsultUnlock_DeniesBeforeConsult verifies that
// under ModeConsultUnlock a fallback Read of indexed source is hard-
// denied while the session has not yet queried the Gortex graph, and
// the deny reason explains how to unlock.
func TestRunPreToolUse_ConsultUnlock_DeniesBeforeConsult(t *testing.T) {
withSessionDir(t)
port := fakeIndexedBridge(t, map[string]bool{"/repo/handler.go": true})
payload := []byte(`{"hook_event_name":"PreToolUse","tool_name":"Read","session_id":"cu-1","tool_input":{"file_path":"/repo/handler.go"}}`)
out := captureStdout(t, func() { runPreToolUse(payload, port, ModeConsultUnlock) })
if out == "" {
t.Fatal("expected JSON output before consult")
}
dec := decodeHookOutput(t, out)
if dec.HookSpecificOutput == nil {
t.Fatal("missing hookSpecificOutput")
}
if dec.HookSpecificOutput.PermissionDecision != "deny" {
t.Errorf("expected hard deny before consult, got %q", dec.HookSpecificOutput.PermissionDecision)
}
if !strings.Contains(dec.HookSpecificOutput.PermissionDecisionReason, "mcp__gortex__") {
t.Errorf("deny reason should tell the agent to query the graph, got:\n%s",
dec.HookSpecificOutput.PermissionDecisionReason)
}
}
// TestRunPreToolUse_ConsultUnlock_AllowsAfterConsult verifies that once
// a mcp__gortex__* call has recorded the per-session marker, the same
// fallback Read is downgraded from a deny to additionalContext.
func TestRunPreToolUse_ConsultUnlock_AllowsAfterConsult(t *testing.T) {
withSessionDir(t)
port := fakeIndexedBridge(t, map[string]bool{"/repo/handler.go": true})
// Step 1: the agent queries the Gortex graph. The hook records the
// marker and emits nothing (no-op pass-through).
consult := []byte(`{"hook_event_name":"PreToolUse","tool_name":"mcp__gortex__search_symbols","session_id":"cu-2","tool_input":{"query":"Foo"}}`)
consultOut := captureStdout(t, func() { runPreToolUse(consult, port, ModeConsultUnlock) })
if consultOut != "" {
t.Errorf("gortex MCP call should be a silent no-op, got: %q", consultOut)
}
if !loadSessionState("cu-2").GraphConsulted {
t.Fatal("expected GraphConsulted marker to be set after a mcp__gortex__* call")
}
// Step 2: the previously-denied fallback Read is now downgraded.
payload := []byte(`{"hook_event_name":"PreToolUse","tool_name":"Read","session_id":"cu-2","tool_input":{"file_path":"/repo/handler.go"}}`)
out := captureStdout(t, func() { runPreToolUse(payload, port, ModeConsultUnlock) })
if out == "" {
t.Fatal("expected JSON output after consult")
}
dec := decodeHookOutput(t, out)
if dec.HookSpecificOutput == nil {
t.Fatal("missing hookSpecificOutput")
}
if dec.HookSpecificOutput.PermissionDecision == "deny" {
t.Errorf("after consult the deny must be downgraded, got: %+v", dec.HookSpecificOutput)
}
if dec.HookSpecificOutput.AdditionalContext == "" {
t.Error("downgraded result should still surface the graph guidance as additionalContext")
}
}
// TestRunPreToolUse_ConsultUnlock_MarkerIsPerSession ensures one
// session consulting the graph does not unlock another session.
func TestRunPreToolUse_ConsultUnlock_MarkerIsPerSession(t *testing.T) {
withSessionDir(t)
port := fakeIndexedBridge(t, map[string]bool{"/repo/handler.go": true})
consult := []byte(`{"hook_event_name":"PreToolUse","tool_name":"mcp__gortex__get_symbol","session_id":"cu-A","tool_input":{}}`)
_ = captureStdout(t, func() { runPreToolUse(consult, port, ModeConsultUnlock) })
// A different session has NOT consulted — its fallback Read is
// still hard-denied.
payload := []byte(`{"hook_event_name":"PreToolUse","tool_name":"Read","session_id":"cu-B","tool_input":{"file_path":"/repo/handler.go"}}`)
out := captureStdout(t, func() { runPreToolUse(payload, port, ModeConsultUnlock) })
dec := decodeHookOutput(t, out)
if dec.HookSpecificOutput == nil || dec.HookSpecificOutput.PermissionDecision != "deny" {
t.Errorf("session B never consulted; expected hard deny, got: %+v", dec.HookSpecificOutput)
}
}
// TestRunPreToolUse_ConsultUnlock_SoftPathUnchanged confirms that an
// unindexed file (soft-guidance, not a deny) is unaffected by the
// consult-unlock posture regardless of marker state.
func TestRunPreToolUse_ConsultUnlock_SoftPathUnchanged(t *testing.T) {
withSessionDir(t)
port := fakeIndexedBridge(t, map[string]bool{}) // nothing indexed
payload := []byte(`{"hook_event_name":"PreToolUse","tool_name":"Read","session_id":"cu-soft","tool_input":{"file_path":"/repo/unindexed.go"}}`)
out := captureStdout(t, func() { runPreToolUse(payload, port, ModeConsultUnlock) })
dec := decodeHookOutput(t, out)
if dec.HookSpecificOutput == nil || dec.HookSpecificOutput.AdditionalContext == "" {
t.Fatal("expected soft additionalContext to survive ModeConsultUnlock")
}
if dec.HookSpecificOutput.PermissionDecision == "deny" {
t.Errorf("soft path must not become a deny, got %q", dec.HookSpecificOutput.PermissionDecision)
}
}
// nudgeReadDecision feeds one non-symbolic Read of indexed source
// through ModeAdaptiveNudge and returns the decoded HookOutput. An
// empty out string means the call was allowed with no payload.
func nudgeReadDecision(t *testing.T, port int, sessionID string) (string, HookOutput) {
t.Helper()
payload := []byte(`{"hook_event_name":"PreToolUse","tool_name":"Read","session_id":"` +
sessionID + `","tool_input":{"file_path":"/repo/handler.go"}}`)
out := captureStdout(t, func() { runPreToolUse(payload, port, ModeAdaptiveNudge) })
if out == "" {
return "", HookOutput{}
}
return out, decodeHookOutput(t, out)
}
// TestRunPreToolUse_AdaptiveNudge_FiresOncePerBurst is the core spec:
// the first nudgeThreshold-1 non-symbolic calls are allowed, the Nth is
// soft-denied, and the (N+1)th is allowed again because the streak was
// reset by the nudge.
func TestRunPreToolUse_AdaptiveNudge_FiresOncePerBurst(t *testing.T) {
withSessionDir(t)
port := fakeIndexedBridge(t, map[string]bool{"/repo/handler.go": true})
// Calls 1..nudgeThreshold-1: allowed (no deny).
for i := 1; i < nudgeThreshold; i++ {
_, dec := nudgeReadDecision(t, port, "nudge-1")
if dec.HookSpecificOutput != nil && dec.HookSpecificOutput.PermissionDecision == "deny" {
t.Fatalf("call %d should be allowed (below threshold), got deny", i)
}
}
// Call N (== nudgeThreshold): soft-denied exactly once.
out, dec := nudgeReadDecision(t, port, "nudge-1")
if out == "" || dec.HookSpecificOutput == nil {
t.Fatalf("call %d should emit a soft-deny payload", nudgeThreshold)
}
if dec.HookSpecificOutput.PermissionDecision != "deny" {
t.Fatalf("call %d should be soft-denied, got %q", nudgeThreshold,
dec.HookSpecificOutput.PermissionDecision)
}
reason := dec.HookSpecificOutput.PermissionDecisionReason
if !strings.Contains(reason, "fires once") {
t.Errorf("nudge reason should note it fires once, got:\n%s", reason)
}
if !strings.Contains(reason, "search_symbols") {
t.Errorf("nudge reason should point at Gortex graph tools, got:\n%s", reason)
}
// Call N+1: allowed — the nudge reset the streak.
_, dec = nudgeReadDecision(t, port, "nudge-1")
if dec.HookSpecificOutput != nil && dec.HookSpecificOutput.PermissionDecision == "deny" {
t.Errorf("call %d should proceed (streak reset by the nudge), got deny", nudgeThreshold+1)
}
// The streak counter is back to zero on disk.
if got := loadSessionState("nudge-1").NonSymbolicStreak; got != 1 {
// After the reset (call N) the allowed call N+1 increments the
// streak back to 1.
t.Errorf("expected streak=1 after one post-nudge call, got %d", got)
}
}
// TestRunPreToolUse_AdaptiveNudge_GortexCallResetsStreak verifies a
// mcp__gortex__* call clears the non-symbolic streak so a partial burst
// never escalates to a nudge.
func TestRunPreToolUse_AdaptiveNudge_GortexCallResetsStreak(t *testing.T) {
withSessionDir(t)
port := fakeIndexedBridge(t, map[string]bool{"/repo/handler.go": true})
// Build a streak just shy of the threshold.
for i := 1; i < nudgeThreshold; i++ {
nudgeReadDecision(t, port, "nudge-2")
}
if got := loadSessionState("nudge-2").NonSymbolicStreak; got != nudgeThreshold-1 {
t.Fatalf("expected streak=%d before reset, got %d", nudgeThreshold-1, got)
}
// A Gortex MCP call resets the streak.
gortexCall := []byte(`{"hook_event_name":"PreToolUse","tool_name":"mcp__gortex__search_symbols","session_id":"nudge-2","tool_input":{"query":"Foo"}}`)
_ = captureStdout(t, func() { runPreToolUse(gortexCall, port, ModeAdaptiveNudge) })
if got := loadSessionState("nudge-2").NonSymbolicStreak; got != 0 {
t.Fatalf("gortex MCP call should reset streak to 0, got %d", got)
}
// The next non-symbolic call is allowed — the burst was broken.
_, dec := nudgeReadDecision(t, port, "nudge-2")
if dec.HookSpecificOutput != nil && dec.HookSpecificOutput.PermissionDecision == "deny" {
t.Errorf("post-reset call should be allowed, got deny")
}
}
// TestRunPreToolUse_AdaptiveNudge_SymbolicCallResetsStreak verifies that
// any non-denying call (here, a Read of an unindexed file) also resets
// the streak — only denying fallback calls extend it.
func TestRunPreToolUse_AdaptiveNudge_SymbolicCallResetsStreak(t *testing.T) {
withSessionDir(t)
// handler.go is indexed (denying); unindexed.go is not (soft).
port := fakeIndexedBridge(t, map[string]bool{"/repo/handler.go": true})
for i := 1; i < nudgeThreshold; i++ {
nudgeReadDecision(t, port, "nudge-3")
}
// A Read of an unindexed file produces a non-denying result.
soft := []byte(`{"hook_event_name":"PreToolUse","tool_name":"Read","session_id":"nudge-3","tool_input":{"file_path":"/repo/unindexed.go"}}`)
_ = captureStdout(t, func() { runPreToolUse(soft, port, ModeAdaptiveNudge) })
if got := loadSessionState("nudge-3").NonSymbolicStreak; got != 0 {
t.Errorf("a non-denying call should reset the streak, got %d", got)
}
}
// TestRunPreToolUse_AdaptiveNudge_LogsNudged checks the once-per-burst
// soft-deny is recorded in the telemetry log as DecisionNudged.
func TestRunPreToolUse_AdaptiveNudge_LogsNudged(t *testing.T) {
withSessionDir(t)
logPath := redirectTelemetry(t)
port := fakeIndexedBridge(t, map[string]bool{"/repo/handler.go": true})
for i := 1; i <= nudgeThreshold; i++ {
nudgeReadDecision(t, port, "nudge-4")
}
recs := readDecisions(t, logPath)
var nudged int
for _, r := range recs {
if r.Decision == DecisionNudged {
nudged++
if r.Tool != "Read" {
t.Errorf("nudged record should carry the tool name, got %q", r.Tool)
}
}
}
if nudged != 1 {
t.Errorf("expected exactly one nudged telemetry record, got %d (all: %+v)", nudged, recs)
}
}
// TestRunPreToolUse_AutoApprove_GortexToolUnderPermissiveMode verifies
// that a Gortex MCP tool call under a permissive permission mode
// ("acceptEdits") is auto-approved with an explicit allow decision.
func TestRunPreToolUse_AutoApprove_GortexToolUnderPermissiveMode(t *testing.T) {
payload := []byte(`{"hook_event_name":"PreToolUse","tool_name":"mcp__gortex__search_symbols","permission_mode":"acceptEdits","tool_input":{"query":"Foo"}}`)
out := captureStdout(t, func() { runPreToolUse(payload, 0, ModeDeny) })
if out == "" {
t.Fatal("expected an allow payload for a gortex tool under acceptEdits")
}
dec := decodeHookOutput(t, out)
if dec.HookSpecificOutput == nil {
t.Fatal("missing hookSpecificOutput")
}
if dec.HookSpecificOutput.PermissionDecision != "allow" {
t.Errorf("expected permissionDecision=allow, got %q", dec.HookSpecificOutput.PermissionDecision)
}
if dec.HookSpecificOutput.PermissionDecisionReason == "" {
t.Error("allow decision should carry a reason")
}
}
// TestRunPreToolUse_AutoApprove_BypassPermissionsNotApproved verifies
// the allowlist excludes "bypassPermissions" — this branch must not
// emit an allow for it.
func TestRunPreToolUse_AutoApprove_BypassPermissionsNotApproved(t *testing.T) {
payload := []byte(`{"hook_event_name":"PreToolUse","tool_name":"mcp__gortex__search_symbols","permission_mode":"bypassPermissions","tool_input":{"query":"Foo"}}`)
out := captureStdout(t, func() { runPreToolUse(payload, 0, ModeDeny) })
// The auto-approve branch must not fire. A gortex tool is otherwise
// not handled by the enrich switch, so the call produces no output
// at all — and in particular never an "allow".
if out != "" {
dec := decodeHookOutput(t, out)
if dec.HookSpecificOutput != nil && dec.HookSpecificOutput.PermissionDecision == "allow" {
t.Errorf("bypassPermissions must NOT be auto-approved by this branch, got: %+v",
dec.HookSpecificOutput)
}
}
}
// TestRunPreToolUse_AutoApprove_NonGortexToolNotApproved verifies a
// non-Gortex tool under a permissive mode is not auto-approved — the
// branch is gated on the mcp__gortex__ prefix.
func TestRunPreToolUse_AutoApprove_NonGortexToolNotApproved(t *testing.T) {
withSessionDir(t)
port := fakeIndexedBridge(t, map[string]bool{}) // unindexed → soft path
payload := []byte(`{"hook_event_name":"PreToolUse","tool_name":"Read","permission_mode":"acceptEdits","tool_input":{"file_path":"/repo/unindexed.go"}}`)
out := captureStdout(t, func() { runPreToolUse(payload, port, ModeDeny) })
if out != "" {
dec := decodeHookOutput(t, out)
if dec.HookSpecificOutput != nil && dec.HookSpecificOutput.PermissionDecision == "allow" {
t.Errorf("a non-gortex tool must never be auto-approved, got: %+v", dec.HookSpecificOutput)
}
}
}
// TestRunPreToolUse_AutoApprove_FiresInAnyMode confirms the branch is
// independent of the Mode enum — it auto-approves a Gortex tool under
// acceptEdits even when the posture is, e.g., ModeConsultUnlock.
func TestRunPreToolUse_AutoApprove_FiresInAnyMode(t *testing.T) {
withSessionDir(t)
payload := []byte(`{"hook_event_name":"PreToolUse","tool_name":"mcp__gortex__get_symbol","permission_mode":"auto","session_id":"aa-1","tool_input":{}}`)
for _, mode := range []Mode{ModeDeny, ModeEnrich, ModeConsultUnlock, ModeAdaptiveNudge} {
out := captureStdout(t, func() { runPreToolUse(payload, 0, mode) })
if out == "" {
t.Fatalf("mode %s: expected an allow payload", mode)
}
dec := decodeHookOutput(t, out)
if dec.HookSpecificOutput == nil || dec.HookSpecificOutput.PermissionDecision != "allow" {
t.Errorf("mode %s: expected auto-approve allow, got: %+v", mode, dec.HookSpecificOutput)
}
}
}
// TestIsPermissivePermissionMode exercises the allowlist directly:
// only "acceptEdits" and "auto" are permissive; everything else,
// including unknown future modes, is not.
func TestIsPermissivePermissionMode(t *testing.T) {
cases := map[string]bool{
"acceptEdits": true,
"auto": true,
" acceptEdits ": true, // trimmed
"bypassPermissions": false,
"default": false,
"plan": false,
"": false,
"AcceptEdits": false, // case-sensitive — exact match only
"someFutureMode": false,
}
for input, want := range cases {
t.Run(input, func(t *testing.T) {
if got := isPermissivePermissionMode(input); got != want {
t.Errorf("isPermissivePermissionMode(%q) = %v, want %v", input, got, want)
}
})
}
}