mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-11 19:20:26 +01:00
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:
1 parent
a479ac80fa
commit
027db2ebed
9 files changed
+179
-33
No files matched your search
+19
-11
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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
|
||||
---
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
Vendored
+1
-1
@@ -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
@@ -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)
|
||||
// ============================================================
|
||||
|
||||
Reference in new issue
Block a user