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
523 lines
20 KiB
Go
523 lines
20 KiB
Go
package claudecode
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/zzet/gortex/internal/agents"
|
|
)
|
|
|
|
// agentsApplyOptsZero is the zero value of agents.ApplyOpts. Helper so
|
|
// the mode-switching tests don't drag the agents import into every
|
|
// call site.
|
|
func agentsApplyOptsZero() agents.ApplyOpts { return agents.ApplyOpts{} }
|
|
|
|
// readSettingsHooks reads settings.local.json from path and returns
|
|
// the hooks map (or fails the test if the file is missing / malformed).
|
|
// The mode-switching tests assert on hook structure between InstallHook
|
|
// calls, so we centralise the read-and-shape-check here.
|
|
func readSettingsHooks(t *testing.T, path string) map[string]any {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
require.NoError(t, err, "read %s", path)
|
|
var settings map[string]any
|
|
require.NoError(t, json.Unmarshal(data, &settings))
|
|
hooks, ok := settings["hooks"].(map[string]any)
|
|
require.True(t, ok, "settings.hooks missing or wrong type")
|
|
return hooks
|
|
}
|
|
|
|
// TestHookCommandPathIsEphemeral covers every branch of the
|
|
// ephemeral-path detector because this function's output directly
|
|
// decides whether a user's settings.local.json gets rewritten on
|
|
// re-run. A false positive here would thrash the user's hook
|
|
// config; a false negative leaves them with stale /tmp paths.
|
|
func TestHookCommandPathIsEphemeral(t *testing.T) {
|
|
// /bin/sh is a stable POSIX path, not under any ephemeral root.
|
|
// os.Executable() would land in /private/var/folders under go
|
|
// test, which is itself ephemeral — hence hard-coding.
|
|
const existing = "/bin/sh"
|
|
if _, err := os.Stat(existing); err != nil {
|
|
t.Skipf("test fixture %s not present: %v", existing, err)
|
|
}
|
|
|
|
missing := filepath.Join("/nonexistent-root-for-gortex-test", "ghost-binary")
|
|
|
|
cases := []struct {
|
|
name string
|
|
cmd string
|
|
want bool
|
|
comment string
|
|
}{
|
|
{"empty", "", false, "no fields to inspect"},
|
|
{"bareName", "gortex hook", false, "PATH lookup happens at fire time"},
|
|
{"relative", "./gortex hook", false, "relative paths are user choice, not ephemeral"},
|
|
{"tmp", "/tmp/gortex-hook-fix hook", true, "/tmp is wiped between sessions"},
|
|
{"varFolders", "/var/folders/x/y/z/gortex hook", true, "macOS go-build cache"},
|
|
{"privateTmp", "/private/tmp/gortex hook", true, "macOS resolves /tmp via /private/tmp"},
|
|
{"privateVarFolders", "/private/var/folders/x/y/z/gortex hook", true, "fully resolved go-build cache"},
|
|
{"missingAbsolute", missing + " hook", true, "absolute path that no longer exists"},
|
|
{"healthyAbsolute", existing + " hook", false, "absolute path that exists on disk"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := HookCommandPathIsEphemeral(tc.cmd)
|
|
assert.Equal(t, tc.want, got, tc.comment)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHealStaleHookCommands(t *testing.T) {
|
|
const newCmd = "/opt/homebrew/bin/gortex hook"
|
|
|
|
t.Run("emptyHooks", func(t *testing.T) {
|
|
hooks := map[string]any{}
|
|
got := healStaleHookCommands(hooks, newCmd)
|
|
assert.Equal(t, 0, got)
|
|
})
|
|
|
|
t.Run("noGortexEntries", func(t *testing.T) {
|
|
hooks := map[string]any{
|
|
"PreToolUse": []any{
|
|
makeHookEntry("Read", "/usr/local/bin/some-other-tool run"),
|
|
},
|
|
}
|
|
got := healStaleHookCommands(hooks, newCmd)
|
|
assert.Equal(t, 0, got)
|
|
entries := hooks["PreToolUse"].([]any)
|
|
inner := entries[0].(map[string]any)["hooks"].([]any)
|
|
cmd := inner[0].(map[string]any)["command"].(string)
|
|
assert.Equal(t, "/usr/local/bin/some-other-tool run", cmd)
|
|
})
|
|
|
|
t.Run("healthyGortexEntryUntouched", func(t *testing.T) {
|
|
hooks := map[string]any{
|
|
"PreToolUse": []any{makeHookEntry("Read", "./gortex hook")},
|
|
}
|
|
got := healStaleHookCommands(hooks, newCmd)
|
|
assert.Equal(t, 0, got)
|
|
assert.Equal(t, "./gortex hook", extractCmd(t, hooks, "PreToolUse", 0))
|
|
})
|
|
|
|
t.Run("staleEntryRewritten", func(t *testing.T) {
|
|
hooks := map[string]any{
|
|
"Stop": []any{makeHookEntry("", "/tmp/gortex-hook-fix hook")},
|
|
}
|
|
got := healStaleHookCommands(hooks, newCmd)
|
|
assert.Equal(t, 1, got)
|
|
assert.Equal(t, newCmd, extractCmd(t, hooks, "Stop", 0))
|
|
})
|
|
|
|
t.Run("multipleEventsAndMixed", func(t *testing.T) {
|
|
hooks := map[string]any{
|
|
"PreToolUse": []any{
|
|
makeHookEntry("Read", "./gortex hook"),
|
|
makeHookEntry("Read", "/usr/local/bin/lint --strict"),
|
|
},
|
|
"PreCompact": []any{makeHookEntry("", "/tmp/gortex-hook-fix hook")},
|
|
"Stop": []any{makeHookEntry("", "/tmp/gortex-hook-fix hook")},
|
|
}
|
|
got := healStaleHookCommands(hooks, newCmd)
|
|
assert.Equal(t, 2, got)
|
|
assert.Equal(t, "./gortex hook", extractCmd(t, hooks, "PreToolUse", 0))
|
|
assert.Equal(t, "/usr/local/bin/lint --strict", extractCmd(t, hooks, "PreToolUse", 1))
|
|
assert.Equal(t, newCmd, extractCmd(t, hooks, "PreCompact", 0))
|
|
assert.Equal(t, newCmd, extractCmd(t, hooks, "Stop", 0))
|
|
})
|
|
}
|
|
|
|
func TestResolveHookCommand(t *testing.T) {
|
|
t.Run("foundOnPath", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
fake := filepath.Join(dir, "gortex")
|
|
require.NoError(t, os.WriteFile(fake, []byte("#!/bin/sh\nexit 0\n"), 0o755))
|
|
t.Setenv("PATH", dir)
|
|
|
|
got := ResolveHookCommand(io.Discard)
|
|
assert.Equal(t, fake+" hook", got, "should resolve to absolute path on PATH")
|
|
})
|
|
|
|
t.Run("notFoundFallsBackToBare", func(t *testing.T) {
|
|
t.Setenv("PATH", t.TempDir())
|
|
got := ResolveHookCommand(io.Discard)
|
|
assert.Equal(t, "gortex hook", got, "fallback to bare name keeps init working in sandboxes")
|
|
})
|
|
|
|
t.Run("commandNeverContainsBackslash", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
fake := filepath.Join(dir, "gortex")
|
|
require.NoError(t, os.WriteFile(fake, []byte("#!/bin/sh\nexit 0\n"), 0o755))
|
|
t.Setenv("PATH", dir)
|
|
|
|
got := ResolveHookCommand(io.Discard)
|
|
assert.NotContains(t, got, "\\",
|
|
"baked hook command must use forward slashes — Git Bash on Windows mangles backslash paths")
|
|
})
|
|
|
|
t.Run("agreesWithMCPStanzaBinary", func(t *testing.T) {
|
|
// The hook command and the MCP server stanza must launch the same
|
|
// gortex binary; a side-by-side install must never split them
|
|
// across two daemons. With a gortex on PATH the hook pins the
|
|
// absolute path while the stanza collapses to the bare name — both
|
|
// resolve to the same file.
|
|
dir := t.TempDir()
|
|
fake := filepath.Join(dir, "gortex")
|
|
require.NoError(t, os.WriteFile(fake, []byte("#!/bin/sh\nexit 0\n"), 0o755))
|
|
t.Setenv("PATH", dir)
|
|
|
|
hookBin := strings.TrimSuffix(ResolveHookCommand(io.Discard), " hook")
|
|
mcpCmd := agents.ResolveGortexCommand()
|
|
// Map the bare name to the PATH gortex it launches, then compare
|
|
// the concrete on-disk binaries.
|
|
launched := func(cmd string) string {
|
|
if cmd == "gortex" {
|
|
return fake
|
|
}
|
|
return cmd
|
|
}
|
|
assert.Equal(t, launched(mcpCmd), launched(hookBin),
|
|
"hook command and MCP stanza must resolve to the same gortex binary")
|
|
})
|
|
}
|
|
|
|
// TestShellSafeHookBinary pins the Windows-path fix: a resolved binary
|
|
// path with backslashes (as exec.LookPath returns on Windows) must be
|
|
// rewritten to forward slashes so Claude Code's shell-run hook does not
|
|
// swallow the separators. Runs on every platform because the helper is
|
|
// a pure string transform.
|
|
func TestShellSafeHookBinary(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
want string
|
|
}{
|
|
{"windowsAbsolute", `C:\Users\Borislav\AppData\Local\Programs\gortex\gortex.exe`, "C:/Users/Borislav/AppData/Local/Programs/gortex/gortex.exe"},
|
|
{"windowsMixed", `C:\Users\me/gortex.exe`, "C:/Users/me/gortex.exe"},
|
|
{"unixAbsolute", "/opt/homebrew/bin/gortex", "/opt/homebrew/bin/gortex"},
|
|
{"bareName", "gortex", "gortex"},
|
|
{"forwardSlashesUnchanged", "C:/Users/me/gortex.exe", "C:/Users/me/gortex.exe"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := shellSafeHookBinary(tc.in)
|
|
assert.Equal(t, tc.want, got)
|
|
assert.NotContains(t, got, "\\", "result must be free of backslashes")
|
|
})
|
|
}
|
|
}
|
|
|
|
func makeHookEntry(matcher, command string) map[string]any {
|
|
entry := map[string]any{
|
|
"hooks": []any{
|
|
map[string]any{
|
|
"type": "command",
|
|
"command": command,
|
|
"timeout": 3000,
|
|
},
|
|
},
|
|
}
|
|
if matcher != "" {
|
|
entry["matcher"] = matcher
|
|
}
|
|
return entry
|
|
}
|
|
|
|
func extractCmd(t *testing.T, hooks map[string]any, event string, idx int) string {
|
|
t.Helper()
|
|
list, ok := hooks[event].([]any)
|
|
require.True(t, ok, "event %q missing", event)
|
|
require.Greater(t, len(list), idx, "event %q has fewer than %d entries", event, idx+1)
|
|
entry, ok := list[idx].(map[string]any)
|
|
require.True(t, ok)
|
|
inner, ok := entry["hooks"].([]any)
|
|
require.True(t, ok)
|
|
require.NotEmpty(t, inner)
|
|
em, ok := inner[0].(map[string]any)
|
|
require.True(t, ok)
|
|
cmd, _ := em["command"].(string)
|
|
return cmd
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mode parsing + command rendering
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestNormalizeHookMode(t *testing.T) {
|
|
cases := map[string]string{
|
|
"": HookModeDeny,
|
|
"deny": HookModeDeny,
|
|
"DENY": HookModeDeny,
|
|
" deny ": HookModeDeny,
|
|
"enrich": HookModeEnrich,
|
|
"Enrich": HookModeEnrich,
|
|
"consult-unlock": HookModeConsultUnlock,
|
|
"Consult-Unlock": HookModeConsultUnlock,
|
|
"nudge": HookModeAdaptiveNudge,
|
|
"NUDGE": HookModeAdaptiveNudge,
|
|
"adaptive-nudge": HookModeAdaptiveNudge, // accepted alias
|
|
"unknown": HookModeDeny, // safe fallback
|
|
"off": HookModeDeny, // install flag handles disable separately
|
|
}
|
|
for input, want := range cases {
|
|
t.Run(input, func(t *testing.T) {
|
|
assert.Equal(t, want, normalizeHookMode(input))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHookCommandWithMode(t *testing.T) {
|
|
base := "/usr/local/bin/gortex hook"
|
|
assert.Equal(t, base, hookCommandWithMode(base, HookModeDeny),
|
|
"deny mode must NOT append --mode flag (back-compat with settings written before --hook-mode existed)")
|
|
assert.Equal(t, base+" --mode=enrich", hookCommandWithMode(base, HookModeEnrich),
|
|
"enrich mode must append --mode=enrich")
|
|
assert.Equal(t, base+" --mode=consult-unlock", hookCommandWithMode(base, HookModeConsultUnlock),
|
|
"consult-unlock mode must append --mode=consult-unlock")
|
|
assert.Equal(t, base+" --mode=nudge", hookCommandWithMode(base, HookModeAdaptiveNudge),
|
|
"nudge mode must append --mode=nudge")
|
|
assert.Equal(t, base+" --mode=nudge", hookCommandWithMode(base, "adaptive-nudge"),
|
|
"adaptive-nudge alias must normalize to --mode=nudge")
|
|
assert.Equal(t, base, hookCommandWithMode(base, ""),
|
|
"empty mode defaults to deny — no flag")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// removeGortexHookEntries — used when switching enrich → deny
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestRemoveGortexHookEntries(t *testing.T) {
|
|
hooks := map[string]any{
|
|
"PostToolUse": []any{
|
|
makeHookEntry("Read|Grep|Glob", "/opt/gortex hook --mode=enrich"),
|
|
map[string]any{
|
|
"hooks": []any{
|
|
map[string]any{"type": "command", "command": "/usr/bin/other-tool"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
removed := removeGortexHookEntries(hooks, "PostToolUse")
|
|
assert.Equal(t, 1, removed)
|
|
list := hooks["PostToolUse"].([]any)
|
|
require.Len(t, list, 1, "non-Gortex entry must be preserved")
|
|
em := list[0].(map[string]any)
|
|
inner := em["hooks"].([]any)
|
|
cmd := inner[0].(map[string]any)["command"]
|
|
assert.Equal(t, "/usr/bin/other-tool", cmd)
|
|
}
|
|
|
|
func TestRemoveGortexHookEntries_DeletesEmptyEvent(t *testing.T) {
|
|
hooks := map[string]any{
|
|
"PostToolUse": []any{
|
|
makeHookEntry("Read|Grep|Glob", "/opt/gortex hook --mode=enrich"),
|
|
},
|
|
}
|
|
removed := removeGortexHookEntries(hooks, "PostToolUse")
|
|
assert.Equal(t, 1, removed)
|
|
_, exists := hooks["PostToolUse"]
|
|
assert.False(t, exists, "empty event should be deleted, not left as []")
|
|
}
|
|
|
|
func TestRemoveGortexHookEntries_NoOpOnMissingEvent(t *testing.T) {
|
|
hooks := map[string]any{}
|
|
assert.Equal(t, 0, removeGortexHookEntries(hooks, "PostToolUse"))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// rewriteGortexHookMode — switches mode without touching user fields
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestRewriteGortexHookMode_UpdatesCommandPreservingMeta(t *testing.T) {
|
|
hooks := map[string]any{
|
|
"PreToolUse": []any{
|
|
map[string]any{
|
|
"matcher": "Read|Grep",
|
|
"hooks": []any{
|
|
map[string]any{
|
|
"type": "command",
|
|
"command": "/opt/gortex hook",
|
|
"timeout": 3000,
|
|
"statusMessage": "custom user-set message",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
rewritten := rewriteGortexHookMode(hooks, "/opt/gortex hook --mode=enrich")
|
|
assert.Equal(t, 1, rewritten)
|
|
list := hooks["PreToolUse"].([]any)
|
|
em := list[0].(map[string]any)["hooks"].([]any)[0].(map[string]any)
|
|
assert.Equal(t, "/opt/gortex hook --mode=enrich", em["command"])
|
|
assert.Equal(t, "custom user-set message", em["statusMessage"],
|
|
"rewrite must preserve user-added fields like statusMessage")
|
|
}
|
|
|
|
func TestRewriteGortexHookMode_NoOpOnIdenticalCommand(t *testing.T) {
|
|
hooks := map[string]any{
|
|
"PreToolUse": []any{makeHookEntry("Read|Grep|Glob", "/opt/gortex hook")},
|
|
}
|
|
assert.Equal(t, 0, rewriteGortexHookMode(hooks, "/opt/gortex hook"))
|
|
}
|
|
|
|
func TestRewriteGortexHookMode_IgnoresNonGortexEntries(t *testing.T) {
|
|
hooks := map[string]any{
|
|
"PreToolUse": []any{makeHookEntry("Bash", "/usr/bin/other --thing")},
|
|
}
|
|
assert.Equal(t, 0, rewriteGortexHookMode(hooks, "/opt/gortex hook --mode=enrich"))
|
|
cmd := extractCmd(t, hooks, "PreToolUse", 0)
|
|
assert.Equal(t, "/usr/bin/other --thing", cmd,
|
|
"non-Gortex entries must NOT be touched even when we're rewriting")
|
|
}
|
|
|
|
// TestUpgradeGortexMatcher_RewritesPreviousCurrent verifies the
|
|
// migration path: an existing install pinned to the previous canonical
|
|
// matcher (no MCP read tools) upgrades in place so the read-discipline
|
|
// nudge starts firing without the user touching settings.json.
|
|
func TestUpgradeGortexMatcher_RewritesPreviousCurrent(t *testing.T) {
|
|
hooks := map[string]any{
|
|
"PreToolUse": []any{makeHookEntry("Read|Grep|Glob|Task|Bash|Edit|Write", "/opt/gortex hook")},
|
|
}
|
|
assert.True(t, upgradeGortexMatcher(hooks), "previous-current matcher should upgrade")
|
|
got := hooks["PreToolUse"].([]any)[0].(map[string]any)["matcher"].(string)
|
|
assert.Equal(t, CurrentPreToolUseMatcher, got)
|
|
assert.Contains(t, got, "mcp__gortex__read_file",
|
|
"upgraded matcher must include the gortex read tools")
|
|
}
|
|
|
|
func TestUpgradeGortexMatcher_NoOpOnCurrent(t *testing.T) {
|
|
hooks := map[string]any{
|
|
"PreToolUse": []any{makeHookEntry(CurrentPreToolUseMatcher, "/opt/gortex hook")},
|
|
}
|
|
assert.False(t, upgradeGortexMatcher(hooks), "already-current matcher must not be rewritten")
|
|
}
|
|
|
|
func TestUpgradeGortexMatcher_IgnoresNonGortexEntries(t *testing.T) {
|
|
hooks := map[string]any{
|
|
"PreToolUse": []any{makeHookEntry("Read|Grep|Glob|Task|Bash|Edit|Write", "/usr/bin/other thing")},
|
|
}
|
|
assert.False(t, upgradeGortexMatcher(hooks), "a non-Gortex entry must not be upgraded")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// InstallHookWithMode — end-to-end: deny → enrich → deny round-trip
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestInstallHookWithMode_DenyMode(t *testing.T) {
|
|
dir := t.TempDir()
|
|
settingsPath := filepath.Join(dir, "settings.local.json")
|
|
t.Setenv("PATH", t.TempDir()) // force fallback to bare "gortex hook"
|
|
|
|
_, err := InstallHookWithMode(io.Discard, settingsPath, HookModeDeny, agentsApplyOptsZero())
|
|
require.NoError(t, err)
|
|
|
|
hooks := readSettingsHooks(t, settingsPath)
|
|
assert.NotContains(t, hooks, "PostToolUse",
|
|
"deny mode must NOT install a PostToolUse entry")
|
|
cmd := extractCmd(t, hooks, "PreToolUse", 0)
|
|
assert.Equal(t, "gortex hook", cmd,
|
|
"deny mode keeps the bare command — no --mode flag")
|
|
}
|
|
|
|
func TestInstallHook_InstallsUserPromptSubmit(t *testing.T) {
|
|
dir := t.TempDir()
|
|
settingsPath := filepath.Join(dir, "settings.local.json")
|
|
t.Setenv("PATH", t.TempDir()) // force fallback to bare "gortex hook"
|
|
|
|
_, err := InstallHookWithMode(io.Discard, settingsPath, HookModeDeny, agentsApplyOptsZero())
|
|
require.NoError(t, err)
|
|
|
|
hooks := readSettingsHooks(t, settingsPath)
|
|
require.Contains(t, hooks, "UserPromptSubmit",
|
|
"UserPromptSubmit hook must be installed for per-turn context injection")
|
|
cmd := extractCmd(t, hooks, "UserPromptSubmit", 0)
|
|
assert.Equal(t, "gortex hook", cmd)
|
|
}
|
|
|
|
func TestInstallHookWithMode_EnrichMode(t *testing.T) {
|
|
dir := t.TempDir()
|
|
settingsPath := filepath.Join(dir, "settings.local.json")
|
|
t.Setenv("PATH", t.TempDir())
|
|
|
|
_, err := InstallHookWithMode(io.Discard, settingsPath, HookModeEnrich, agentsApplyOptsZero())
|
|
require.NoError(t, err)
|
|
|
|
hooks := readSettingsHooks(t, settingsPath)
|
|
require.Contains(t, hooks, "PostToolUse")
|
|
postCmd := extractCmd(t, hooks, "PostToolUse", 0)
|
|
assert.Equal(t, "gortex hook --mode=enrich", postCmd)
|
|
|
|
preCmd := extractCmd(t, hooks, "PreToolUse", 0)
|
|
assert.Equal(t, "gortex hook --mode=enrich", preCmd,
|
|
"PreToolUse must use the same --mode=enrich command so it falls back to soft-context")
|
|
|
|
// PostToolUse uses the matcher restricted to read-shaped tools.
|
|
post := hooks["PostToolUse"].([]any)[0].(map[string]any)
|
|
assert.Equal(t, CurrentPostToolUseMatcher, post["matcher"])
|
|
}
|
|
|
|
func TestInstallHookWithMode_DenyToEnrichRoundTrip(t *testing.T) {
|
|
dir := t.TempDir()
|
|
settingsPath := filepath.Join(dir, "settings.local.json")
|
|
t.Setenv("PATH", t.TempDir())
|
|
|
|
// Initial deny install.
|
|
_, err := InstallHookWithMode(io.Discard, settingsPath, HookModeDeny, agentsApplyOptsZero())
|
|
require.NoError(t, err)
|
|
hooks := readSettingsHooks(t, settingsPath)
|
|
require.NotContains(t, hooks, "PostToolUse")
|
|
assert.Equal(t, "gortex hook", extractCmd(t, hooks, "PreToolUse", 0))
|
|
|
|
// Switch to enrich.
|
|
_, err = InstallHookWithMode(io.Discard, settingsPath, HookModeEnrich, agentsApplyOptsZero())
|
|
require.NoError(t, err)
|
|
hooks = readSettingsHooks(t, settingsPath)
|
|
require.Contains(t, hooks, "PostToolUse", "enrich install must add PostToolUse")
|
|
assert.Equal(t, "gortex hook --mode=enrich", extractCmd(t, hooks, "PreToolUse", 0),
|
|
"PreToolUse command must be rewritten on mode switch")
|
|
assert.Equal(t, "gortex hook --mode=enrich", extractCmd(t, hooks, "PostToolUse", 0))
|
|
|
|
// Switch back to deny — PostToolUse must be removed, PreToolUse rewritten.
|
|
_, err = InstallHookWithMode(io.Discard, settingsPath, HookModeDeny, agentsApplyOptsZero())
|
|
require.NoError(t, err)
|
|
hooks = readSettingsHooks(t, settingsPath)
|
|
assert.NotContains(t, hooks, "PostToolUse", "switch back to deny must drop PostToolUse")
|
|
assert.Equal(t, "gortex hook", extractCmd(t, hooks, "PreToolUse", 0),
|
|
"switch back to deny must restore bare command on PreToolUse")
|
|
}
|
|
|
|
func TestInstallHookWithMode_EnrichIdempotent(t *testing.T) {
|
|
dir := t.TempDir()
|
|
settingsPath := filepath.Join(dir, "settings.local.json")
|
|
t.Setenv("PATH", t.TempDir())
|
|
|
|
for i := range 3 {
|
|
_, err := InstallHookWithMode(io.Discard, settingsPath, HookModeEnrich, agentsApplyOptsZero())
|
|
require.NoError(t, err, "iteration %d", i)
|
|
}
|
|
hooks := readSettingsHooks(t, settingsPath)
|
|
// PostToolUse should have exactly one entry — re-running install
|
|
// must not append duplicates.
|
|
post := hooks["PostToolUse"].([]any)
|
|
assert.Len(t, post, 1, "repeated install must not duplicate the PostToolUse entry")
|
|
}
|
|
|
|
// InstallHook (the back-compat wrapper) must default to deny mode.
|
|
func TestInstallHook_DefaultsToDeny(t *testing.T) {
|
|
dir := t.TempDir()
|
|
settingsPath := filepath.Join(dir, "settings.local.json")
|
|
t.Setenv("PATH", t.TempDir())
|
|
|
|
_, err := InstallHook(io.Discard, settingsPath, agentsApplyOptsZero())
|
|
require.NoError(t, err)
|
|
hooks := readSettingsHooks(t, settingsPath)
|
|
assert.NotContains(t, hooks, "PostToolUse",
|
|
"InstallHook (no mode) must behave as deny — no PostToolUse entry")
|
|
}
|