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
477 lines
20 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|