* fix(freeze): resolve the full path before the boundary check, fail closed when it can't (#3623) check-freeze.sh canonicalized only an existing parent directory and compared the raw string otherwise, so any path through a directory that did not exist yet kept its literal in-boundary prefix: '<b>/../outside/new/x', '<b>/link-out/new/x', '<b>/..', '~/.zshrc' with cwd inside, and 'C:\proj\src\..\..\other\new\x' were all allowed. _resolve_path now walks the path one component at a time, follows symlinks at any depth, and requires both the OS reading and a lexical normalizer's reading of '..' to land inside. Paths it cannot place deny with a reason: symlink loops and chains over 40 links, line breaks, Windows drive- and root-relative paths, names Windows trims, odd UNC roots. Windows rules keep '..' at the drive or share root. The shared extractor keeps trailing newlines and maps lone surrogates on path fields only; /careful's command values are unchanged. Co-authored-by: Quinn O'Donnell <90010678+Quinnod345@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(freeze): deny paths over PATH_MAX before the quadratic walk (#3623) A PreToolUse hook that outlives the host's timeout does not block the edit, and the component walk is quadratic in path length. Deny a file_path over 4096 characters, and links that grow the walk past 8192, before walking. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(freeze): bound the total length of followed link targets (#3623) The PATH_MAX cap bounded only the file_path text; each link hop could add up to 8192 characters again, and a padded 40-link chain kept the quadratic walk busy for 104s. Count every target against one 8192-character budget. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(freeze): cheap link-chain budget test; name the path in the budget reason (#3623) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): /freeze boundary escape fix (#3623) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(freeze): describe full-path resolution and fail-closed cases (#3623) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * freeze: deny Windows link targets relative to a drive or its root (#3623) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * freeze: read a leading // as / on POSIX hosts, never as a UNC prefix (#3623) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * freeze: compare Git Bash paths by cygpath's drive spelling (#3623) Git Bash mounts %TEMP% at /tmp and converts HOME through its mounts, so '~/x' and 'D:\...\x' named one file in two spellings and the hook denied a legit edit (windows-free-shard 2 on #3758). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * freeze: name the edited path as given in the deny, with where it lands (#3623) cygpath's drive spelling made a Windows deny print /c/Program Files/Git/etc/hosts for /etc/hosts (windows-free-shard 2 on #3758, 'Edit denials name the tool and file_path too'). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: Quinn O'Donnell <90010678+Quinnod345@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
4.3 KiB
name, version, description, triggers, allowed-tools, hooks
| name | version | description | triggers | allowed-tools | hooks | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| freeze | 0.1.0 | Restrict file edits to a specific directory for the session. (gstack) |
|
|
|
/freeze — Restrict Edits to a Directory
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).
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}"
mkdir -p "$GSTACK_STATE_ROOT"/analytics
echo '{"skill":"freeze","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> "$GSTACK_STATE_ROOT"/analytics/skill-usage.jsonl 2>/dev/null || true
Setup
Ask the user which directory to restrict edits to. Use AskUserQuestion:
- Question: "Which directory should I restrict edits to? Files outside this path will be blocked from editing."
- Text input (not multiple choice) — the user types a path.
Once the user provides a directory path:
Set the user-selected boundary with the shared state writer. It resolves the physical absolute path and serializes replacement with investigation cleanup:
bash "$HOME/.claude/skills/gstack/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, 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, 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
operation (nested under hookSpecificOutput — Claude Code ignores a top-level
permissionDecision).
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 neither path field (a non-file tool) is allowed. A deny names the
tool, the path field, the boundary and /unfreeze. The path is resolved before
the check: ~, .. and relative paths, and symlinks at every depth, even
through folders that do not exist yet. A path the hook cannot place (a symlink
loop, a line break, a Windows drive-relative path, over 4096 characters) is
denied.
The freeze boundary persists until explicitly removed via the state file. The hook script reads it on every Edit/Write/NotebookEdit invocation. Boundaries containing spaces are supported.
Notes
- The trailing
/on the freeze directory prevents/srcfrom matching/src-old - 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
sedorSet-Contentcan still modify files outside the boundary - To deactivate, run
/unfreeze; ending or killing a conversation does not remove persisted state