Files
gstack/freeze/SKILL.md
510dd07d6b v1.92.9.0 fix: /freeze checks where an edit really lands, so new-folder paths can't escape it (#3623) (#3758)
* 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>
2026-11-01 02:06:25 +00:00

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 edits to directory
lock editing scope
restrict file changes
Bash
Read
AskUserQuestion
PreToolUse
matcher hooks
Edit
type command statusMessage
command bash -c "exec bash "$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh"" Checking freeze boundary...
matcher hooks
Write
type command statusMessage
command bash -c "exec bash "$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh"" Checking freeze boundary...
matcher hooks
NotebookEdit
type command statusMessage
command bash -c "exec bash "$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh"" Checking freeze boundary...

/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 /src from 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 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