garrytan / garrytan/gstack

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

Open
#2,845 0 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.