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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-08 06:24:42 +00:00
1 parent a479ac80fa
commit 027db2ebed
9 files changed
+179 -33

No files matched your search

+19 -11
View File
@@ -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..."
---
<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->
<!-- Regenerate: bun run gen:skill-docs -->
@@ -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 "<user-provide
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 `<path>/`. Any Edit or Write
outside this directory will be blocked. To change the boundary, run `/freeze`
Tell the user: "Edits are now restricted to `<path>/`. 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
+19 -11
View File
@@ -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 "<user-provided-path>"
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 `<path>/`. Any Edit or Write
outside this directory will be blocked. To change the boundary, run `/freeze`
Tell the user: "Edits are now restricted to `<path>/`. 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
+12 -6
View File
@@ -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
+5
View File
@@ -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..."
---
<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->
<!-- Regenerate: bun run gen:skill-docs -->
+5
View File
@@ -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
---
+6 -1
View File
@@ -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 "<detected
Substitute `<detected-directory>` 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:
+6 -1
View File
@@ -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 "<detected-directory>
Substitute `<detected-directory>` 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:
+1 -1
View File
@@ -22,7 +22,7 @@
"diagram": 4271,
"document-generate": 11696,
"document-release": 10059,
"freeze": 1073,
"freeze": 1194,
"gstack": 4151,
"gstack-upgrade": 5793,
"guard": 1082,
+106 -2
View File
@@ -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)
// ============================================================