investigate: Scope Lock writes a relative freeze boundary and never clears it, so one debug session's lock governs every later session on the machine
- Dominant language
- TypeScript
- Stars
- 133k
- Forks
- 19.9k
- Avg merge
- 18h 46m
- Merged PRs (30d)
- 26
Description
## What breaks
`/investigate`'s Scope Lock step writes the debug boundary into the machine-global freeze state file (`~/.gstack/freeze-dir.txt`) as a **relative** path, and nothing in the skill ever removes it. Two consequences:
1. **The boundary re-anchors to whatever directory the next tool call runs in.** `freeze/bin/check-freeze.sh` resolves a relative `FREEZE_DIR` against `$(pwd)`, so a lock of `src/` written while debugging repo A means `/src/` in a later session in repo B, and `/src/` in a sibling worktree of the same repo. The lock silently permits edits to the wrong tree and blocks edits to files the user never froze.
2. **The lock outlives the session that set it.** `/freeze` says "To remove it, run `/unfreeze` or end the session", but the state file is on disk and no SessionEnd step clears it. A `src/` lock written on 2026-09-07 was still live on 2026-09-10 and blocked a scratchpad `Write` in an unrelated Claude Code session, three days and several repos later.
`/freeze` and `/guard` do not have problem 1: both resolve the user's path with `$(cd "" && pwd)` before writing it. Only the investigate step writes the placeholder verbatim, and its own worked example is a relative path (`src/auth/`).
## Reproduction
Real hook, temp state root, two directories standing in for two worktrees. The state file holds `src/`, exactly what `investigate/SKILL.md` writes.
```
$ HOOK=~/.claude/skills/gstack/freeze/bin/check-freeze.sh
$ T=$(mktemp -d); mkdir -p $T/state $T/wt-a/src $T/wt-b/src $T/wt-b/scratch
$ echo "src/" > $T/state/freeze-dir.txt
# Same Write, two working directories
$ (cd $T/wt-a && printf '{"tool_input":{"file_path":"%s/wt-a/src/a.ts"}}' $T | GSTACK_HOME=$T/state bash $HOOK)
{}
$ (cd $T/wt-b && printf '{"tool_input":{"file_path":"%s/wt-a/src/a.ts"}}' $T | GSTACK_HOME=$T/state bash $HOOK)
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"[freeze] Blocked: .../wt-a/src/a.ts is outside the freeze boundary (.../wt-b/src). Only edits within the frozen directory are allowed."}}
# A session in wt-b that never ran /investigate is now locked to wt-b/src
$ (cd $T/wt-b && printf '{"tool_input":{"file_path":"%s/wt-b/scratch/notes.md"}}' $T | GSTACK_HOME=$T/state bash $HOOK)
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"[freeze] Blocked: .../wt-b/scratch/notes.md is outside the freeze boundary (.../wt-b/src). ..."}}
$ (cd $T/wt-b && printf '{"tool_input":{"file_path":"%s/wt-b/src/b.ts"}}' $T | GSTACK_HOME=$T/state bash $HOOK)
{}
```
With the state file holding the absolute path `$T/wt-a/src/` instead, the verdicts stop depending on the working directory: the `wt-a/src/a.ts` write is allowed from `wt-b`, and the `wt-b/src/b.ts` write is denied.
## Where
`investigate/SKILL.md.tmpl`, Scope Lock:
```bash
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"
STATE_DIR="$GSTACK_STATE_ROOT"
mkdir -p "$STATE_DIR"
echo "/" > "$STATE_DIR/freeze-dir.txt"
```
Prose: "Substitute `` with the actual directory path (e.g., `src/auth/`)." No later phase touches the file; Phase 5 ends with the debug report and the learnings log.
## Expected
- The Scope Lock writes an absolute, resolved path, the same way `/freeze` and `/guard` do, and skips the lock (loudly) when the directory does not resolve, rather than writing a boundary that fails open.
- The skill releases the lock it wrote when the run ends, in Phase 5, whatever the completion status. Releasing should be compare-and-delete on the path this run wrote, so it never removes a boundary the user set with `/freeze` before invoking `/investigate`.
- `/freeze`'s prose stops claiming the boundary ends with the session.
## Related
- #1647, #2469 (closed): the hook command path. Both fixed how the hook is *reached*; this is what the hook is *given*. The relative write became load-bearing once #2469 made the hook resolve.
- #1459 (open): writer and reader disagreeing on the state root. Different failure (fail-open), same file.
## Environment
- gstack 1.84.1.0, global install at `~/.claude/skills/gstack`, macOS 15
- Claude Code, several concurrent sessions across sibling git worktrees
Contributor guide
Research direction
Start with investigate/SKILL.md.tmpl's Scope Lock and Phase 5, then compare its state-file handling with /freeze, /unfreeze, /guard and freeze/bin/check-freeze.sh. Run the two-worktree reproduction in the issue. Done means unresolved directories do not create a lock, the run's absolute boundary is released safely at completion, and /freeze prose no longer promises automatic session cleanup.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- markdown, shell
- Domain
- cli, developer-experience, security
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100