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) } }) } }