From 027db2ebede471376767815c34363c44e00c6847 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 8 Oct 2026 06:24:42 +0000 Subject: [PATCH] fix(freeze): NotebookEdit respects the freeze boundary (#3067) NotebookEdit was in no freeze/guard/investigate matcher, and check-freeze.sh read only file_path, so notebook edits ignored /freeze on every platform. NotebookEdit carries tool_input.notebook_path. - freeze, guard and investigate register a NotebookEdit matcher beside Edit and Write. - check-freeze.sh reads file_path, then notebook_path, through the shared extractor; denials name the tool, the field, the boundary and /unfreeze. A helper without the combined reader denies as out of date. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- freeze/SKILL.md | 30 ++++++--- freeze/SKILL.md.tmpl | 30 ++++++--- freeze/bin/check-freeze.sh | 18 +++-- guard/SKILL.md | 5 ++ guard/SKILL.md.tmpl | 5 ++ investigate/SKILL.md | 7 +- investigate/SKILL.md.tmpl | 7 +- test/fixtures/context-budget.json | 2 +- test/hook-scripts.test.ts | 108 +++++++++++++++++++++++++++++- 9 files changed, 179 insertions(+), 33 deletions(-) diff --git a/freeze/SKILL.md b/freeze/SKILL.md index e0783fdb6..23dd76f48 100644 --- a/freeze/SKILL.md +++ b/freeze/SKILL.md @@ -22,6 +22,11 @@ hooks: - type: command command: 'bash -c "exec bash \"$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh\""' statusMessage: "Checking freeze boundary..." + - matcher: "NotebookEdit" + hooks: + - type: command + command: 'bash -c "exec bash \"$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh\""' + statusMessage: "Checking freeze boundary..." --- @@ -29,16 +34,17 @@ hooks: ## When to invoke this skill -Blocks Edit and -Write outside the allowed path. Use when debugging to prevent accidentally +Blocks Edit, +Write and NotebookEdit outside the allowed path. Use when debugging to prevent accidentally "fixing" unrelated code, or when you want to scope changes to one module. Use when asked to "freeze", "restrict edits", "only edit this folder", or "lock down edits". # /freeze — Restrict Edits to a Directory -Lock file edits to a specific directory. Any Edit or Write operation targeting -a file outside the allowed path will be **blocked** (not just warned). +Lock file edits to a specific directory. Any Edit, Write or NotebookEdit +operation targeting a file outside the allowed path will be **blocked** (not +just warned). ```bash GSTACK_STATE_ROOT=$(~/.claude/skills/gstack/bin/gstack-paths --get GSTACK_STATE_ROOT); : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}" @@ -62,13 +68,14 @@ bash "$HOME/.claude/skills/gstack/freeze/bin/freeze-state.sh" set "/`. Any Edit or Write -outside this directory will be blocked. To change the boundary, run `/freeze` +Tell the user: "Edits are now restricted to `/`. Any Edit, Write or +NotebookEdit outside this directory will be blocked. To change the boundary, run `/freeze` again. To remove it, run `/unfreeze`." ## How it works -The hook reads `file_path` from the Edit/Write tool input JSON (shared +The hook reads `file_path` from the Edit/Write tool input JSON, or +`notebook_path` from a NotebookEdit (Jupyter notebook) call (shared real-JSON extractor with /careful — one copy, sourced by both hooks), then checks whether the path starts with the freeze directory. If not, it returns a `hookSpecificOutput` payload with `permissionDecision: "deny"` to block the @@ -77,17 +84,18 @@ operation (nested under `hookSpecificOutput` — Claude Code ignores a top-level Polarity is fail-closed: a tool payload the hook cannot parse is DENIED, not allowed — a boundary that fails open is not a boundary. A payload that parses -but has no `file_path` (a non-file tool) is allowed. Symlinks are resolved +but has neither path field (a non-file tool) is allowed. A deny names the +tool, the path field, the boundary and `/unfreeze`. Symlinks are resolved through their FINAL component, so an in-boundary symlink pointing outside the boundary is checked against its target. The freeze boundary persists until explicitly removed via the state file. The hook -script reads it on every Edit/Write invocation. Boundaries containing spaces +script reads it on every Edit/Write/NotebookEdit invocation. Boundaries containing spaces are supported. ## Notes - The trailing `/` on the freeze directory prevents `/src` from matching `/src-old` -- Freeze applies to Edit and Write tools only — Read, Bash, Glob, Grep are unaffected -- This prevents accidental edits, not a security boundary — Bash commands like `sed` can still modify files outside the boundary +- Freeze applies to Edit, Write and NotebookEdit only — Read, Bash, PowerShell, Glob, Grep are unaffected +- This prevents accidental edits, not a security boundary — Bash or PowerShell commands like `sed` or `Set-Content` can still modify files outside the boundary - To deactivate, run `/unfreeze`; ending or killing a conversation does not remove persisted state diff --git a/freeze/SKILL.md.tmpl b/freeze/SKILL.md.tmpl index efd888c25..4b95fd3e4 100644 --- a/freeze/SKILL.md.tmpl +++ b/freeze/SKILL.md.tmpl @@ -2,8 +2,8 @@ name: freeze version: 0.1.0 description: | - Restrict file edits to a specific directory for the session. Blocks Edit and - Write outside the allowed path. Use when debugging to prevent accidentally + Restrict file edits to a specific directory for the session. Blocks Edit, + Write and NotebookEdit outside the allowed path. Use when debugging to prevent accidentally "fixing" unrelated code, or when you want to scope changes to one module. Use when asked to "freeze", "restrict edits", "only edit this folder", or "lock down edits". (gstack) @@ -27,13 +27,19 @@ hooks: - type: command command: 'bash -c "exec bash \"$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh\""' statusMessage: "Checking freeze boundary..." + - matcher: "NotebookEdit" + hooks: + - type: command + command: 'bash -c "exec bash \"$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh\""' + statusMessage: "Checking freeze boundary..." sensitive: true --- # /freeze — Restrict Edits to a Directory -Lock file edits to a specific directory. Any Edit or Write operation targeting -a file outside the allowed path will be **blocked** (not just warned). +Lock file edits to a specific directory. Any Edit, Write or NotebookEdit +operation targeting a file outside the allowed path will be **blocked** (not +just warned). ```bash GSTACK_STATE_ROOT=$(~/.claude/skills/gstack/bin/gstack-paths --get GSTACK_STATE_ROOT); : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}" @@ -57,13 +63,14 @@ bash "{{RUNTIME_ROOT}}/freeze/bin/freeze-state.sh" set "" Only report success if the helper succeeds. On `FREEZE_BUSY` or unexpected state, preserve it and ask the user to inspect recovery after any active writer finishes; never write or delete the state file directly. -Tell the user: "Edits are now restricted to `/`. Any Edit or Write -outside this directory will be blocked. To change the boundary, run `/freeze` +Tell the user: "Edits are now restricted to `/`. Any Edit, Write or +NotebookEdit outside this directory will be blocked. To change the boundary, run `/freeze` again. To remove it, run `/unfreeze`." ## How it works -The hook reads `file_path` from the Edit/Write tool input JSON (shared +The hook reads `file_path` from the Edit/Write tool input JSON, or +`notebook_path` from a NotebookEdit (Jupyter notebook) call (shared real-JSON extractor with /careful — one copy, sourced by both hooks), then checks whether the path starts with the freeze directory. If not, it returns a `hookSpecificOutput` payload with `permissionDecision: "deny"` to block the @@ -72,17 +79,18 @@ operation (nested under `hookSpecificOutput` — Claude Code ignores a top-level Polarity is fail-closed: a tool payload the hook cannot parse is DENIED, not allowed — a boundary that fails open is not a boundary. A payload that parses -but has no `file_path` (a non-file tool) is allowed. Symlinks are resolved +but has neither path field (a non-file tool) is allowed. A deny names the +tool, the path field, the boundary and `/unfreeze`. Symlinks are resolved through their FINAL component, so an in-boundary symlink pointing outside the boundary is checked against its target. The freeze boundary persists until explicitly removed via the state file. The hook -script reads it on every Edit/Write invocation. Boundaries containing spaces +script reads it on every Edit/Write/NotebookEdit invocation. Boundaries containing spaces are supported. ## Notes - The trailing `/` on the freeze directory prevents `/src` from matching `/src-old` -- Freeze applies to Edit and Write tools only — Read, Bash, Glob, Grep are unaffected -- This prevents accidental edits, not a security boundary — Bash commands like `sed` can still modify files outside the boundary +- Freeze applies to Edit, Write and NotebookEdit only — Read, Bash, PowerShell, Glob, Grep are unaffected +- This prevents accidental edits, not a security boundary — Bash or PowerShell commands like `sed` or `Set-Content` can still modify files outside the boundary - To deactivate, run `/unfreeze`; ending or killing a conversation does not remove persisted state diff --git a/freeze/bin/check-freeze.sh b/freeze/bin/check-freeze.sh index 4b67dae03..30d13502b 100755 --- a/freeze/bin/check-freeze.sh +++ b/freeze/bin/check-freeze.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # check-freeze.sh — PreToolUse hook for /freeze skill -# Reads JSON from stdin, checks if file_path is within the freeze boundary. +# Reads JSON from stdin, checks if the edited path is within the freeze +# boundary: file_path for Edit/Write, notebook_path for NotebookEdit (#3067). # Returns a PreToolUse hookSpecificOutput with permissionDecision "deny" to block, # or {} to allow. The decision MUST be nested under hookSpecificOutput — Claude # Code ignores a top-level permissionDecision, which silently no-ops the block. @@ -56,7 +57,7 @@ fi # A helper from an older install that lacks the function must fail CLOSED # (the existence check above only proves the file sourced), never exit 127 # with no JSON — Claude Code treats that as non-blocking. -if ! command -v gstack_hook_state_root >/dev/null 2>&1 || ! command -v gstack_hook_normalize_path >/dev/null 2>&1; then +if ! command -v gstack_hook_state_root >/dev/null 2>&1 || ! command -v gstack_hook_normalize_path >/dev/null 2>&1 || ! command -v gstack_hook_extract_tool >/dev/null 2>&1; then _FREEZE_DECIDED=1 printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"[freeze] Hook helpers out of date (partial upgrade?) - blocked, fail closed. Re-run ./setup or /unfreeze."}}\n' exit 0 @@ -108,11 +109,16 @@ case "$FREEZE_DIR" in ;; esac -# Extract file_path from tool_input with the shared real-JSON parser. +# Extract the edited path with the shared real-JSON parser. Edit and Write +# carry tool_input.file_path; NotebookEdit carries tool_input.notebook_path +# (Claude Code Agent SDK reference, NotebookEditInput), so a notebook edit +# used to parse as "no path" and pass every boundary. set +e -FILE_PATH=$(gstack_hook_extract_field "$INPUT" file_path) +gstack_hook_extract_tool "$INPUT" file_path notebook_path EXTRACT_RC=$? set -e +FILE_PATH="$GSTACK_HOOK_VALUE" +TOOL_LABEL="${GSTACK_HOOK_TOOL:+$GSTACK_HOOK_TOOL }$GSTACK_HOOK_FIELD" # Unparseable payload (or no parser available): DENY. A boundary hook that # allows what it cannot read is not a boundary. @@ -122,7 +128,7 @@ if [ "$EXTRACT_RC" -ne 0 ] && [ -n "$INPUT" ]; then exit 0 fi -# Parsed fine but no file_path field: a non-file tool payload — allow. +# Parsed fine but no path field: a non-file tool payload — allow. if [ -z "$FILE_PATH" ]; then _FREEZE_DECIDED=1 echo '{}' @@ -198,7 +204,7 @@ case "$FILE_PATH" in # The reason is JSON-encoded by the shared helper. Never interpolate paths # into hand-built JSON: a path containing a quote or newline produced # malformed JSON here, and the deny silently no-oped. - gstack_hook_decision deny "[freeze] Blocked: $FILE_PATH is outside the freeze boundary ($FREEZE_DIR). Only edits within the frozen directory are allowed." + gstack_hook_decision deny "[freeze] Blocked: $TOOL_LABEL $FILE_PATH is outside the freeze boundary ($FREEZE_DIR). Only edits within the frozen directory are allowed; run /unfreeze to remove the boundary." _FREEZE_DECIDED=1 ;; esac diff --git a/guard/SKILL.md b/guard/SKILL.md index d41c35f2e..2b2117db8 100644 --- a/guard/SKILL.md +++ b/guard/SKILL.md @@ -32,6 +32,11 @@ hooks: - type: command command: 'bash -c "exec bash \"$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh\""' statusMessage: "Checking freeze boundary..." + - matcher: "NotebookEdit" + hooks: + - type: command + command: 'bash -c "exec bash \"$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh\""' + statusMessage: "Checking freeze boundary..." --- diff --git a/guard/SKILL.md.tmpl b/guard/SKILL.md.tmpl index 025576427..a7a278913 100644 --- a/guard/SKILL.md.tmpl +++ b/guard/SKILL.md.tmpl @@ -37,6 +37,11 @@ hooks: - type: command command: 'bash -c "exec bash \"$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh\""' statusMessage: "Checking freeze boundary..." + - matcher: "NotebookEdit" + hooks: + - type: command + command: 'bash -c "exec bash \"$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh\""' + statusMessage: "Checking freeze boundary..." sensitive: true --- diff --git a/investigate/SKILL.md b/investigate/SKILL.md index d1e63cac0..7c33545d9 100644 --- a/investigate/SKILL.md +++ b/investigate/SKILL.md @@ -30,6 +30,11 @@ hooks: - type: command command: 'bash -c "test -x \"$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh\" && exec bash \"$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh\"; exit 0"' statusMessage: "Checking debug scope boundary..." + - matcher: "NotebookEdit" + hooks: + - type: command + command: 'bash -c "test -x \"$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh\" && exec bash \"$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh\"; exit 0"' + statusMessage: "Checking debug scope boundary..." gbrain: schema: 1 context_queries: @@ -496,7 +501,7 @@ bash "$HOME/.claude/skills/gstack/freeze/bin/freeze-state.sh" acquire "` with the actual path (e.g., `src/auth/`). Retain the exact returned `FREEZE_OWNER` token in this run's context (including any checkpoint); never reconstruct it from the current state file. Only a returned token means this run owns a new lock. `FREEZE_PRESERVED` means keep the existing boundary and do not clean it up. On acquisition error, pause before edits and report it; never claim a lock was acquired. Relative legacy state is ambiguous: ask the user to re-establish an absolute boundary via `/freeze`, rather than guessing its original cwd. -Tell the user the boundary and its owner disposition. Hooks enforce Edit/Write restrictions only on hosts supporting those callbacks; on Capy they are advisory. Bash remains outside hook enforcement. +Tell the user the boundary and its owner disposition. Hooks enforce Edit/Write/NotebookEdit restrictions only on hosts supporting those callbacks; on Capy they are advisory. Bash remains outside hook enforcement. **Terminal cleanup:** On completion, explicit abort, or any known error that ends this investigation, run the following with this run's retained token, before the final response. Skip it when this run acquired no token: diff --git a/investigate/SKILL.md.tmpl b/investigate/SKILL.md.tmpl index 3fd9ca14c..bde740ea8 100644 --- a/investigate/SKILL.md.tmpl +++ b/investigate/SKILL.md.tmpl @@ -37,6 +37,11 @@ hooks: - type: command command: 'bash -c "test -x \"$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh\" && exec bash \"$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh\"; exit 0"' statusMessage: "Checking debug scope boundary..." + - matcher: "NotebookEdit" + hooks: + - type: command + command: 'bash -c "test -x \"$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh\" && exec bash \"$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh\"; exit 0"' + statusMessage: "Checking debug scope boundary..." gbrain: schema: 1 context_queries: @@ -133,7 +138,7 @@ bash "{{RUNTIME_ROOT}}/freeze/bin/freeze-state.sh" acquire " Substitute `` with the actual path (e.g., `src/auth/`). Retain the exact returned `FREEZE_OWNER` token in this run's context (including any checkpoint); never reconstruct it from the current state file. Only a returned token means this run owns a new lock. `FREEZE_PRESERVED` means keep the existing boundary and do not clean it up. On acquisition error, pause before edits and report it; never claim a lock was acquired. Relative legacy state is ambiguous: ask the user to re-establish an absolute boundary via `/freeze`, rather than guessing its original cwd. -Tell the user the boundary and its owner disposition. Hooks enforce Edit/Write restrictions only on hosts supporting those callbacks; on Capy they are advisory. Bash remains outside hook enforcement. +Tell the user the boundary and its owner disposition. Hooks enforce Edit/Write/NotebookEdit restrictions only on hosts supporting those callbacks; on Capy they are advisory. Bash remains outside hook enforcement. **Terminal cleanup:** On completion, explicit abort, or any known error that ends this investigation, run the following with this run's retained token, before the final response. Skip it when this run acquired no token: diff --git a/test/fixtures/context-budget.json b/test/fixtures/context-budget.json index f3f29c2e0..0d86950b1 100644 --- a/test/fixtures/context-budget.json +++ b/test/fixtures/context-budget.json @@ -22,7 +22,7 @@ "diagram": 4271, "document-generate": 11696, "document-release": 10059, - "freeze": 1073, + "freeze": 1194, "gstack": 4151, "gstack-upgrade": 5793, "guard": 1082, diff --git a/test/hook-scripts.test.ts b/test/hook-scripts.test.ts index f5520ff2e..6e3a6c962 100644 --- a/test/hook-scripts.test.ts +++ b/test/hook-scripts.test.ts @@ -134,7 +134,7 @@ describe('frontmatter hook command paths', () => { // Claude Code fires a hook only for the tools its matcher names. The // PowerShell tool is the primary shell on Windows (and the only one without -// Git Bash) (#3067). +// Git Bash), and NotebookEdit edits files without a file_path (#3067). function hookCommandsFor(rel: string, matcher: string): string[] { const content = fs.readFileSync(path.join(ROOT, rel), 'utf-8'); const frontmatter = content.split('\n---')[0]; @@ -144,7 +144,7 @@ function hookCommandsFor(rel: string, matcher: string): string[] { .flatMap((b) => b.split('\n').filter((l) => l.trim().startsWith('command:'))); } -describe('generated hook matchers cover PowerShell (#3067)', () => { +describe('generated hook matchers cover PowerShell and NotebookEdit (#3067)', () => { test.each(['careful/SKILL.md', 'guard/SKILL.md'])('%s routes Bash and PowerShell to check-careful.sh', (rel) => { for (const matcher of ['Bash', 'PowerShell']) { const commands = hookCommandsFor(rel, matcher); @@ -153,6 +153,14 @@ describe('generated hook matchers cover PowerShell (#3067)', () => { } }); + test.each(['freeze/SKILL.md', 'guard/SKILL.md', 'investigate/SKILL.md'])('%s routes Edit, Write and NotebookEdit to check-freeze.sh', (rel) => { + for (const matcher of ['Edit', 'Write', 'NotebookEdit']) { + const commands = hookCommandsFor(rel, matcher); + expect(commands.length).toBe(1); + expect(commands[0]).toContain('freeze/bin/check-freeze.sh'); + } + }); + test('careful/SKILL.md says PowerShell coverage is best-effort and points to permission deny rules', () => { const content = fs.readFileSync(path.join(ROOT, 'careful', 'SKILL.md'), 'utf-8'); expect(content).toContain('**Best-effort on PowerShell.**'); @@ -1228,6 +1236,102 @@ describe('check-freeze.sh', () => { }); }); +// ============================================================ +// check-freeze.sh on NotebookEdit (#3067) +// ============================================================ +// Payload shape: NotebookEdit sends tool_input.notebook_path (plus cell_id, +// new_source, cell_type, edit_mode) and no file_path (Claude Code Agent SDK +// reference, NotebookEditInput, code.claude.com/docs/en/agent-sdk/typescript). +function notebookInput(notebookPath: string) { + return { tool_name: 'NotebookEdit', tool_input: { notebook_path: notebookPath, new_source: 'print(1)', edit_mode: 'replace' } }; +} + +describe('check-freeze.sh NotebookEdit boundary (#3067)', () => { + const BOUNDARY = '/Users/dev/project/src/'; + + test('a notebook inside the boundary allows', () => { + withFreezeDir(BOUNDARY, (stateDir) => { + const { exitCode, output } = runHook(FREEZE_SCRIPT, notebookInput('/Users/dev/project/src/analysis.ipynb'), freezeEnv(stateDir)); + expect(exitCode).toBe(0); + expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined(); + }); + }); + + test('a notebook outside the boundary denies, naming tool, notebook_path, boundary and /unfreeze', () => { + withFreezeDir(BOUNDARY, (stateDir) => { + const { exitCode, output } = runHook(FREEZE_SCRIPT, notebookInput('/etc/x.ipynb'), freezeEnv(stateDir)); + expect(exitCode).toBe(0); + expect(output.hookSpecificOutput?.permissionDecision).toBe('deny'); + const reason: string = output.hookSpecificOutput?.permissionDecisionReason ?? ''; + expect(reason).toContain('NotebookEdit'); + expect(reason).toContain('notebook_path'); + expect(reason).toContain('/etc/x.ipynb'); + expect(reason).toContain('/Users/dev/project/src'); + expect(reason).toContain('/unfreeze'); + }); + }); + + test('a C:/ notebook path is normalized and denied outside a POSIX boundary', () => { + withFreezeDir(BOUNDARY, (stateDir) => { + const { output } = runHook(FREEZE_SCRIPT, notebookInput('C:/Users/dev/other/x.ipynb'), freezeEnv(stateDir)); + expect(output.hookSpecificOutput?.permissionDecision).toBe('deny'); + expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('/c/Users/dev/other/x.ipynb'); + }); + }); + + test('a C:\\ notebook inside a C:\\ boundary allows', () => { + withFreezeDir('C:\\dev\\proj\\', (stateDir) => { + const { output } = runHook(FREEZE_SCRIPT, notebookInput('C:\\dev\\proj\\nb\\x.ipynb'), freezeEnv(stateDir)); + expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined(); + }); + }); + + test('file_path wins when both fields are present', () => { + withFreezeDir(BOUNDARY, (stateDir) => { + const input = { tool_name: 'NotebookEdit', tool_input: { file_path: '/etc/x.ipynb', notebook_path: '/Users/dev/project/src/a.ipynb' } }; + const { output } = runHook(FREEZE_SCRIPT, input, freezeEnv(stateDir)); + expect(output.hookSpecificOutput?.permissionDecision).toBe('deny'); + }); + }); + + test('a malformed NotebookEdit payload denies (fail closed)', () => { + withFreezeDir(BOUNDARY, (stateDir) => { + const { exitCode, output } = runHookRaw(FREEZE_SCRIPT, '{"tool_name":"NotebookEdit","tool_input":{"notebook_path":"/etc/x', freezeEnv(stateDir)); + expect(exitCode).toBe(0); + expect(output.hookSpecificOutput?.permissionDecision).toBe('deny'); + }); + }); + + test('Edit denials name the tool and file_path too', () => { + withFreezeDir(BOUNDARY, (stateDir) => { + const { output } = runHook(FREEZE_SCRIPT, { tool_name: 'Edit', tool_input: { file_path: '/etc/hosts' } }, freezeEnv(stateDir)); + expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('Edit file_path /etc/hosts'); + }); + }); + + test('a hook helper that lacks gstack_hook_extract_tool DENIES as out of date', () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-freeze-oldextract-')); + const freezeBin = path.join(base, 'freeze', 'bin'); + const carefulBin = path.join(base, 'careful', 'bin'); + fs.mkdirSync(freezeBin, { recursive: true }); + fs.mkdirSync(carefulBin, { recursive: true }); + fs.copyFileSync(FREEZE_SCRIPT, path.join(freezeBin, 'check-freeze.sh')); + const helper = fs.readFileSync(HOOK_EXTRACT, 'utf-8'); + const start = helper.indexOf('gstack_hook_extract_tool() {'); + const end = helper.indexOf('\n}\n', start) + 3; + fs.writeFileSync(path.join(carefulBin, 'hook-extract.sh'), helper.slice(0, start) + helper.slice(end)); + try { + withFreezeDir(BOUNDARY, (stateDir) => { + const { output } = runHook(path.join(freezeBin, 'check-freeze.sh'), notebookInput('/Users/dev/project/src/a.ipynb'), freezeEnv(stateDir)); + expect(output.hookSpecificOutput?.permissionDecision).toBe('deny'); + expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('out of date'); + }); + } finally { + fs.rmSync(base, { recursive: true, force: true }); + } + }); +}); + // ============================================================ // check-freeze.sh state-root resolution (#1459 / #1509) // ============================================================