docs(05): create phase plan — mount rewrite + per-project isolation + GC

This commit is contained in:
Christopher Mühl 2026-04-13 08:47:04 +00:00
parent a040aaa58a
commit dd064aa858
3 changed files with 571 additions and 2 deletions

View file

@ -50,7 +50,10 @@ Plans:
2. Launching claudebox from a git worktree shares instance state with the main worktree of the same repo 2. Launching claudebox from a git worktree shares instance state with the main worktree of the same repo
3. Two concurrent claudebox sessions in the same project do not corrupt each other's state 3. Two concurrent claudebox sessions in the same project do not corrupt each other's state
4. Running `claudebox --gc` removes instance directories for project roots that no longer exist on disk 4. Running `claudebox --gc` removes instance directories for project roots that no longer exist on disk
**Plans**: TBD **Plans**: 2 plans
Plans:
- [ ] 05-01-PLAN.md — Mount architecture rewrite + per-project isolation
- [ ] 05-02-PLAN.md — GC mechanism + integration test
### Phase 6: Tiered Network Isolation ### Phase 6: Tiered Network Isolation
**Goal**: Users can select a network access tier at launch to control whether Claude has no network, internet-only, or full host network access **Goal**: Users can select a network access tier at launch to control whether Claude has no network, internet-only, or full host network access
@ -84,6 +87,6 @@ Plans:
| 2. Env Audit and CLI Polish | v1.0 | 2/2 | Complete | 2026-04-09 | | 2. Env Audit and CLI Polish | v1.0 | 2/2 | Complete | 2026-04-09 |
| 3. Sandbox-Aware Prompting | v1.0 | 1/1 | Complete | 2026-04-10 | | 3. Sandbox-Aware Prompting | v1.0 | 1/1 | Complete | 2026-04-10 |
| 4. Auth Passthrough | v2.0 | 1/1 | Complete | 2026-04-10 | | 4. Auth Passthrough | v2.0 | 1/1 | Complete | 2026-04-10 |
| 5. Per-Project Instance Isolation | v2.0 | 0/? | Not started | - | | 5. Per-Project Instance Isolation | v2.0 | 0/2 | In progress | - |
| 6. Tiered Network Isolation | v2.0 | 0/? | Not started | - | | 6. Tiered Network Isolation | v2.0 | 0/? | Not started | - |
| 7. Named Profiles | v2.0 | 0/? | Not started | - | | 7. Named Profiles | v2.0 | 0/? | Not started | - |

View file

@ -0,0 +1,324 @@
---
phase: 05-per-project-instance-isolation
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- claudebox.sh
autonomous: false
requirements:
- INST-01
- INST-02
- INST-03
must_haves:
truths:
- "Launching claudebox in project A produces conversation history isolated from project B"
- "Launching claudebox from a git worktree shares instance state with the main worktree"
- "All Claude Code plugins, skills, hooks, MCP configs, commands, and settings are visible inside the sandbox"
- "Two concurrent sessions in the same project share the same instance dir without corruption"
artifacts:
- path: "claudebox.sh"
provides: "compute_canonical_root function, instance initialization, new mount layout"
contains: "compute_canonical_root"
- path: "claudebox.sh"
provides: "BWRAP_ARGS with direct ~/.claude bind and overlay mounts"
contains: "--bind \"$HOME/.claude\" \"$HOME/.claude\""
key_links:
- from: "claudebox.sh (compute_canonical_root)"
to: "INSTANCE_DIR variable"
via: "sha256sum of canonical root path"
pattern: "sha256sum.*cut -c1-16"
- from: "claudebox.sh (BWRAP_ARGS)"
to: "bwrap execution"
via: "overlay mounts after parent bind"
pattern: "--bind.*\\.claude/projects"
---
<objective>
Rewrite claudebox mount architecture from symlink-based to direct bind + overlay, and implement per-project instance isolation.
Purpose: Fix plugin/skill/hook visibility (all Claude Code config in ~/.claude becomes available) and scope conversation history per project directory so different projects never share state.
Output: Updated claudebox.sh with new mount layout, canonical root computation, per-project hash directories, and updated dry-run/audit display.
</objective>
<execution_context>
@/home/toph/code/tools/claudebox/.claude/get-shit-done/workflows/execute-plan.md
@/home/toph/code/tools/claudebox/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/05-per-project-instance-isolation/05-CONTEXT.md
@.planning/phases/05-per-project-instance-isolation/05-RESEARCH.md
<interfaces>
<!-- Current claudebox.sh structure the executor needs to understand -->
From claudebox.sh (flag parsing, lines 1-18):
```bash
SKIP_AUDIT=false
DRY_RUN=false
CHECK_MODE=false
SHELL_MODE=false
CLAUDE_ARGS=()
while (( $# > 0 )); do
case "$1" in
--yes|-y) SKIP_AUDIT=true ;;
--dry-run) DRY_RUN=true ;;
--check) CHECK_MODE=true ;;
--shell) SHELL_MODE=true ;;
--) shift; CLAUDE_ARGS+=("$@"); break ;;
*) CLAUDE_ARGS+=("$1") ;;
esac
shift
done
```
From claudebox.sh (BWRAP_ARGS, lines 364-398 — MUST BE REPLACED):
```bash
BWRAP_ARGS=(
--clearenv
"${ENV_ARGS[@]}"
--tmpfs /
--proc /proc
--dev /dev
--tmpfs /tmp
--ro-bind /nix/store /nix/store
--bind /nix/var/nix /nix/var/nix
...
--tmpfs "$HOME"
--bind "$HOME/.claudebox" "$HOME/.claudebox" # OLD: remove
--symlink "$HOME/.claudebox" "$HOME/.claude" # OLD: remove
)
```
From claudebox.sh (credential mount, lines 104-122):
```bash
CREDS_FILE="$HOME/.claudebox/.credentials.json"
CLAUDE_JSON_FILE="$HOME/.claude.json"
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Rewrite mount architecture and add per-project isolation</name>
<files>claudebox.sh</files>
<read_first>
- claudebox.sh (entire file — current mount layout, credential logic, SANDBOX.md generation, CLAUDE.md injection, audit display, dry-run block, BWRAP_ARGS)
- .planning/phases/05-per-project-instance-isolation/05-RESEARCH.md (verified patterns, mount order, anti-patterns)
- .planning/phases/05-per-project-instance-isolation/05-CONTEXT.md (locked decisions D-01 through D-14)
</read_first>
<action>
Modify claudebox.sh with these changes, in order:
**1. Add `compute_canonical_root` function** (insert after `CWD=$(pwd)` on line 99):
```bash
# Compute canonical project root — worktree-aware (D-08, INST-02)
compute_canonical_root() {
local cwd="$1"
local git_common
git_common=$(git -C "$cwd" rev-parse --git-common-dir 2>/dev/null) || {
echo "$cwd"
return
}
# git returns relative ".git" for normal repos; make absolute
if [[ "$git_common" != /* ]]; then
git_common="$cwd/$git_common"
fi
dirname "$(readlink -f "$git_common")"
}
```
**2. Add instance initialization** (insert after the compute_canonical_root function):
```bash
# Per-project instance isolation (D-04, D-07, D-09, D-10, INST-01)
CANONICAL_ROOT=$(compute_canonical_root "$CWD")
INSTANCE_HASH=$(printf '%s' "$CANONICAL_ROOT" | sha256sum | cut -c1-16)
INSTANCE_DIR="$HOME/.claudebox/projects/$INSTANCE_HASH"
mkdir -p "$INSTANCE_DIR"
if [[ ! -f "$INSTANCE_DIR/project-root" ]]; then
printf '%s\n' "$CANONICAL_ROOT" > "$INSTANCE_DIR/project-root"
fi
# Ensure history.jsonl source exists — bwrap bind requires source to exist (D-04)
touch "$HOME/.claudebox/history.jsonl"
```
**3. Remove CLAUDE.md injection logic** (delete lines 166-174, the block starting with `# Ensure CLAUDE.md has @SANDBOX.md import`):
Delete the entire block:
```bash
CLAUDEMD="$HOME/.claudebox/CLAUDE.md"
if [[ ! -f "$CLAUDEMD" ]]; then
...
fi
```
Per D-06: user's real `~/.claude/CLAUDE.md` already has `@SANDBOX.md`. The SANDBOX.md file itself is still written to `~/.claudebox/SANDBOX.md` and will be bind-mounted as an overlay. The CLAUDE.md injection is no longer needed and would write to an invisible path.
**4. Replace BWRAP_ARGS mount section** (lines 382-384). Replace these three lines:
```bash
--bind "$HOME/.claudebox" "$HOME/.claudebox"
--symlink "$HOME/.claudebox" "$HOME/.claude"
```
With (per D-01, D-02, D-03, D-06):
```bash
# Phase 5: direct ~/.claude bind (D-01) — all plugins/skills/hooks/MCP visible
--bind "$HOME/.claude" "$HOME/.claude"
# Phase 5: overlay projects/ with per-project isolated dir (D-02, INST-01)
--bind "$INSTANCE_DIR" "$HOME/.claude/projects"
# Phase 5: overlay history.jsonl with sandbox-side file (D-03)
--bind "$HOME/.claudebox/history.jsonl" "$HOME/.claude/history.jsonl"
# Phase 5: inject SANDBOX.md as file overlay (D-06)
--bind "$HOME/.claudebox/SANDBOX.md" "$HOME/.claude/SANDBOX.md"
```
**5. Update credential mount target** (in the conditional block after BWRAP_ARGS, currently line ~390):
Change:
```bash
BWRAP_ARGS+=(--bind "$CREDS_FILE" "$HOME/.claudebox/.credentials.json")
```
To:
```bash
BWRAP_ARGS+=(--bind "$CREDS_FILE" "$HOME/.claude/.credentials.json")
```
Per Pitfall 3 in RESEARCH.md: the old target path `~/.claudebox/.credentials.json` no longer exists in the sandbox since `~/.claudebox` is no longer mounted.
**6. Update dry-run block** (lines 318-361). Replace lines 348-355 (the mount echo lines for claudebox bind, symlink, and credential target):
Replace:
```bash
echo " --bind $HOME/.claudebox $HOME/.claudebox \\"
echo " --symlink $HOME/.claudebox $HOME/.claude \\"
...
if [[ "$CREDS_MOUNT" == true ]]; then
echo " --bind $CREDS_FILE $HOME/.claudebox/.credentials.json \\"
fi
```
With:
```bash
echo " --bind $HOME/.claude $HOME/.claude \\"
echo " --bind $INSTANCE_DIR $HOME/.claude/projects \\"
echo " --bind $HOME/.claudebox/history.jsonl $HOME/.claude/history.jsonl \\"
echo " --bind $HOME/.claudebox/SANDBOX.md $HOME/.claude/SANDBOX.md \\"
if [[ "$CREDS_MOUNT" == true ]]; then
echo " --bind $CREDS_FILE $HOME/.claude/.credentials.json \\"
fi
```
**7. Update print_audit mounts section** (inside print_audit function, around line 276-283). Replace mount display lines:
Replace:
```bash
printf ' %-12s %s (read-write)\n' "$HOME/.claude" "$HOME/.claudebox" >&2
```
With:
```bash
printf ' %-12s %s (read-write)\n' "$HOME/.claude" "$HOME/.claude" >&2
printf ' %-12s %s (read-write, project: %s)\n' "projects/" "$INSTANCE_DIR" "$CANONICAL_ROOT" >&2
printf ' %-12s %s (read-write)\n' "history" "$HOME/.claudebox/history.jsonl" >&2
printf ' %-12s %s (read-only overlay)\n' "SANDBOX.md" "$HOME/.claudebox/SANDBOX.md" >&2
```
**8. Update SANDBOX.md content** (the heredoc starting at line 127). Change the line:
```
Both ~/.claude and ~/.claudebox
point to the same directory inside the sandbox.
```
To:
```
Your filesystem is isolated -- only the current working directory and
essential system paths are mounted. Your ~/.claude directory is bind-mounted
from the host, with per-project isolation for conversation history.
```
(Remove the `~/.claudebox` reference since it's no longer visible in the sandbox.)
</action>
<verify>
<automated>bash -n claudebox.sh && grep -q 'compute_canonical_root' claudebox.sh && grep -q 'INSTANCE_HASH' claudebox.sh && grep -q -- '--bind "$HOME/.claude" "$HOME/.claude"' claudebox.sh && grep -q -- '--bind "$INSTANCE_DIR" "$HOME/.claude/projects"' claudebox.sh && grep -q -- '--bind "$HOME/.claudebox/history.jsonl" "$HOME/.claude/history.jsonl"' claudebox.sh && grep -q -- '--bind "$CREDS_FILE" "$HOME/.claude/.credentials.json"' claudebox.sh && ! grep -q -- '--symlink.*\.claudebox.*\.claude' claudebox.sh && ! grep -q -- '--bind "$HOME/.claudebox" "$HOME/.claudebox"' claudebox.sh && echo "ALL CHECKS PASSED"</automated>
</verify>
<acceptance_criteria>
- claudebox.sh passes `bash -n` syntax check
- claudebox.sh contains `compute_canonical_root()` function with `git rev-parse --git-common-dir` inside it
- claudebox.sh contains `INSTANCE_HASH=$(printf '%s' "$CANONICAL_ROOT" | sha256sum | cut -c1-16)`
- claudebox.sh contains `mkdir -p "$INSTANCE_DIR"`
- claudebox.sh contains `--bind "$HOME/.claude" "$HOME/.claude"` (D-01 direct bind)
- claudebox.sh contains `--bind "$INSTANCE_DIR" "$HOME/.claude/projects"` (D-02 overlay)
- claudebox.sh contains `--bind "$HOME/.claudebox/history.jsonl" "$HOME/.claude/history.jsonl"` (D-03 overlay)
- claudebox.sh contains `--bind "$HOME/.claudebox/SANDBOX.md" "$HOME/.claude/SANDBOX.md"` (D-06 overlay)
- claudebox.sh contains `--bind "$CREDS_FILE" "$HOME/.claude/.credentials.json"` (updated target)
- claudebox.sh does NOT contain `--symlink "$HOME/.claudebox" "$HOME/.claude"` (old symlink removed)
- claudebox.sh does NOT contain `--bind "$HOME/.claudebox" "$HOME/.claudebox"` (old bind removed)
- claudebox.sh does NOT contain `CLAUDEMD="$HOME/.claudebox/CLAUDE.md"` (injection removed)
- Dry-run block echoes `--bind $HOME/.claude $HOME/.claude` instead of old claudebox bind+symlink
- Dry-run block echoes `--bind $INSTANCE_DIR $HOME/.claude/projects`
- print_audit shows `projects/` mount line with `$INSTANCE_DIR` and `$CANONICAL_ROOT`
- SANDBOX.md heredoc does NOT contain `~/.claudebox`
</acceptance_criteria>
<done>
claudebox.sh has new mount architecture (direct ~/.claude bind + overlays for projects/, history.jsonl, SANDBOX.md, credentials), per-project instance isolation via SHA-256 hash of canonical git root, and all display/dry-run blocks updated to match. Old symlink approach completely removed.
</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 2: Verify mount architecture and per-project isolation</name>
<files>claudebox.sh</files>
<action>Human verifies the mount architecture rewrite works correctly end-to-end.</action>
<what-built>Complete mount architecture rewrite: direct ~/.claude bind with per-project overlay isolation. Dry-run and audit display updated.</what-built>
<how-to-verify>
1. Run `claudebox --dry-run` from this repo — verify output shows `--bind $HOME/.claude $HOME/.claude` followed by `--bind <hash-dir> $HOME/.claude/projects` (no `--symlink`, no `--bind ~/.claudebox ~/.claudebox`)
2. Run `claudebox --dry-run` from a different project dir — verify the INSTANCE_DIR path differs (different hash)
3. Run `ls ~/.claudebox/projects/` — verify a hash-named directory was created with a `project-root` file inside it
4. Run `cat ~/.claudebox/projects/*/project-root` — verify it contains the canonical project root path
5. Run `claudebox --yes` briefly (Ctrl+C after launch) — verify Claude Code starts and plugins/skills/hooks are visible (check with `ls ~/.claude/` inside sandbox if using --shell mode)
</how-to-verify>
<verify>Human confirms all 5 verification steps pass</verify>
<done>Mount architecture rewrite verified: direct ~/.claude bind works, per-project overlay produces isolated hash dirs, dry-run output is correct, Claude Code launches with plugins visible.</done>
<resume-signal>Type "approved" or describe issues</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| host filesystem to sandbox | bwrap bind mounts expose host paths inside sandbox; overlay order determines what's visible |
| user input (CWD) to hash computation | CWD is user-controlled; used as input to SHA-256 hash for instance dir path |
| project-root file to GC deletion | plaintext path stored in project-root file is trusted by GC to determine what to delete |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-01 | Tampering | compute_canonical_root | mitigate | Use `readlink -f` to resolve symlinks to real paths before hashing; prevents symlink-based hash collisions |
| T-05-02 | Information Disclosure | overlay mount order | mitigate | Overlay mounts MUST come AFTER parent `~/.claude` bind; bwrap last-mount-wins ensures isolated projects/ is what the sandbox sees, not the host's real projects/ |
| T-05-03 | Tampering | INSTANCE_DIR path construction | mitigate | INSTANCE_DIR is built from `$HOME/.claudebox/projects/$INSTANCE_HASH` where INSTANCE_HASH is hex-only output of sha256sum|cut; no path traversal possible via hash |
| T-05-04 | Information Disclosure | cross-project data | mitigate | Each project gets unique hash dir mounted as entire `~/.claude/projects/`; sandbox cannot see other projects' hash dirs |
| T-05-05 | Denial of Service | unbounded instance dirs | accept | Instance dirs accumulate until `--gc` is run; acceptable for personal tool; GC is Phase 5 Plan 02 |
</threat_model>
<verification>
1. `bash -n claudebox.sh` passes (no syntax errors)
2. `claudebox --dry-run` shows new mount layout with no old symlink/claudebox references
3. Two different project dirs produce different INSTANCE_HASH values
4. `~/.claudebox/projects/<hash>/project-root` contains the correct canonical root
5. Claude Code launches successfully with plugins visible
</verification>
<success_criteria>
- Mount architecture uses direct `~/.claude` bind with overlay mounts for projects/, history.jsonl, SANDBOX.md, and credentials
- Per-project isolation via SHA-256[:16] of canonical git root path
- Git worktrees resolve to same canonical root (via --git-common-dir)
- Old symlink approach completely removed from claudebox.sh
- Dry-run and audit display reflect new mount layout
</success_criteria>
<output>
After completion, create `.planning/phases/05-per-project-instance-isolation/05-01-SUMMARY.md`
</output>

View file

@ -0,0 +1,242 @@
---
phase: 05-per-project-instance-isolation
plan: 02
type: execute
wave: 2
depends_on:
- 05-01
files_modified:
- claudebox.sh
autonomous: true
requirements:
- INST-04
must_haves:
truths:
- "Running `claudebox --gc` removes instance directories whose project root no longer exists on disk"
- "`claudebox --gc` prints removed paths to stderr and exits without launching Claude"
- "`claudebox --gc` does nothing harmful when projects/ directory is empty"
artifacts:
- path: "claudebox.sh"
provides: "gc_instances function and --gc flag handling"
contains: "gc_instances"
- path: "claudebox.sh"
provides: "--gc in flag parsing case statement"
contains: "--gc)"
key_links:
- from: "claudebox.sh (--gc flag)"
to: "gc_instances function"
via: "GC_MODE=true triggers gc_instances call"
pattern: "GC_MODE.*gc_instances"
- from: "gc_instances"
to: "~/.claudebox/projects/*/project-root"
via: "reads project-root file, checks if path exists on disk"
pattern: "project-root"
---
<objective>
Add `--gc` flag and garbage collection function to claudebox for cleaning up stale per-project instance directories.
Purpose: Prevent unbounded growth of `~/.claudebox/projects/` by providing a way to remove instance directories whose project root no longer exists on the host filesystem.
Output: Updated claudebox.sh with --gc flag, gc_instances function, and GC dispatch logic.
</objective>
<execution_context>
@/home/toph/code/tools/claudebox/.claude/get-shit-done/workflows/execute-plan.md
@/home/toph/code/tools/claudebox/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/05-per-project-instance-isolation/05-CONTEXT.md
@.planning/phases/05-per-project-instance-isolation/05-RESEARCH.md
@.planning/phases/05-per-project-instance-isolation/05-01-SUMMARY.md
<interfaces>
<!-- From Plan 01: per-project instance structure on host -->
Host layout after Plan 01:
```
~/.claudebox/projects/<16-char-hash>/
project-root # plaintext file containing canonical root path
-home-user-code-myproject/ # Claude Code writes here
```
From claudebox.sh (flag parsing after Plan 01):
```bash
SKIP_AUDIT=false
DRY_RUN=false
CHECK_MODE=false
SHELL_MODE=false
CLAUDE_ARGS=()
while (( $# > 0 )); do
case "$1" in
--yes|-y) SKIP_AUDIT=true ;;
--dry-run) DRY_RUN=true ;;
--check) CHECK_MODE=true ;;
--shell) SHELL_MODE=true ;;
--) shift; CLAUDE_ARGS+=("$@"); break ;;
*) CLAUDE_ARGS+=("$1") ;;
esac
shift
done
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add --gc flag and gc_instances function</name>
<files>claudebox.sh</files>
<read_first>
- claudebox.sh (entire file — especially flag parsing block and the CHECK_MODE dispatch block pattern)
- .planning/phases/05-per-project-instance-isolation/05-CONTEXT.md (D-11, D-12 decisions)
- .planning/phases/05-per-project-instance-isolation/05-RESEARCH.md (Pattern 3: GC Implementation, Pitfall 7: glob on empty dir)
</read_first>
<action>
Modify claudebox.sh with these changes:
**1. Add GC_MODE flag variable** (line 5, after `SHELL_MODE=false`):
```bash
GC_MODE=false
```
**2. Add --gc case to flag parsing** (inside the while/case block, after the `--shell)` case):
```bash
--gc) GC_MODE=true ;;
```
**3. Add gc_instances function** (insert after the `compute_canonical_root` function, before the instance initialization block). This is a standalone function:
```bash
# Garbage-collect stale instance directories (D-11, INST-04)
gc_instances() {
local removed=0
local projects_dir="$HOME/.claudebox/projects"
if [[ ! -d "$projects_dir" ]]; then
echo "No projects directory found at $projects_dir" >&2
return
fi
for dir in "$projects_dir"/*/; do
[[ -d "$dir" ]] || continue
local root_file="$dir/project-root"
[[ -f "$root_file" ]] || continue
local root_path
root_path=$(< "$root_file")
if [[ ! -d "$root_path" ]]; then
rm -rf "$dir"
echo "Removed: $dir (project root gone: $root_path)" >&2
(( removed++ )) || true
fi
done
echo "GC complete: $removed instance(s) removed." >&2
}
```
**4. Add GC dispatch block** (insert after the CHECK_MODE dispatch block, before the ANSI formatting section). Follow the same pattern as CHECK_MODE — run the function and exit:
```bash
# --gc: remove stale instance directories and exit (D-12, INST-04)
if [[ "$GC_MODE" == true ]]; then
gc_instances
exit 0
fi
```
This ensures `--gc` exits immediately after GC, never launches Claude (same pattern as `--check`).
</action>
<verify>
<automated>bash -n claudebox.sh && grep -q 'GC_MODE=false' claudebox.sh && grep -q -- '--gc)' claudebox.sh && grep -q 'gc_instances' claudebox.sh && grep -q 'project-root' claudebox.sh && grep -q 'GC complete:' claudebox.sh && echo "ALL CHECKS PASSED"</automated>
</verify>
<acceptance_criteria>
- claudebox.sh passes `bash -n` syntax check
- claudebox.sh contains `GC_MODE=false` variable initialization
- claudebox.sh contains `--gc) GC_MODE=true ;;` in the case statement (or `--gc) GC_MODE=true ;;`)
- claudebox.sh contains `gc_instances()` function definition
- gc_instances function contains `for dir in "$projects_dir"/*/;` loop
- gc_instances function contains `[[ -d "$dir" ]] || continue` (Pitfall 7 guard)
- gc_instances function contains `[[ -f "$root_file" ]] || continue` (defensive skip)
- gc_instances function contains `rm -rf "$dir"` for stale dirs
- gc_instances function contains `echo "Removed:` output to stderr
- gc_instances function contains `echo "GC complete:` summary to stderr
- claudebox.sh contains `if [[ "$GC_MODE" == true ]]; then` dispatch block
- The GC dispatch block calls `gc_instances` followed by `exit 0`
- The GC dispatch block appears BEFORE the ANSI formatting section (so it exits early like --check)
</acceptance_criteria>
<done>
`claudebox --gc` iterates `~/.claudebox/projects/*/project-root`, removes directories whose recorded project root no longer exists on disk, prints each removal to stderr, prints a summary count, and exits without launching Claude.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Add GC integration test</name>
<files>test-gc.sh</files>
<read_first>
- claudebox.sh (the gc_instances function from Task 1)
</read_first>
<behavior>
- Test 1: Creating a fake instance dir with a project-root pointing to a non-existent path, then running gc_instances, removes the dir
- Test 2: Creating a fake instance dir with a project-root pointing to an existing path, then running gc_instances, keeps the dir
- Test 3: Running gc_instances on an empty projects/ dir produces "GC complete: 0"
</behavior>
<action>
Create `test-gc.sh` as a standalone bash test script that sources the `gc_instances` function from claudebox.sh (or redefines it inline for isolation) and verifies the three behaviors above.
The test should:
1. Create a temporary directory structure mimicking `~/.claudebox/projects/`
2. Override `HOME` to point to the temp dir
3. Create test instance dirs: one with a valid project-root, one with a stale project-root, one empty projects/ dir
4. Call `gc_instances` and verify:
- Stale dir was removed
- Valid dir was kept
- Empty dir produces "0 instance(s) removed"
5. Exit 0 on success, exit 1 on failure with diagnostic output
Make the script executable with `chmod +x test-gc.sh`.
</action>
<verify>
<automated>bash test-gc.sh && echo "GC TESTS PASSED"</automated>
</verify>
<done>
test-gc.sh runs three test cases covering stale removal, valid preservation, and empty-dir safety. All pass.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| project-root file content to rm -rf | The path read from project-root is used to decide whether to delete the instance dir; a tampered project-root could prevent GC or cause unexpected behavior |
| user CLI input (--gc flag) to filesystem deletion | --gc triggers rm -rf on instance dirs; only dirs under ~/.claudebox/projects/ are affected |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-06 | Tampering | project-root file content | accept | The project-root file is written by claudebox itself and lives under user-owned ~/.claudebox/. A user tampering with their own files is not a threat. GC only deletes the instance dir, not the path recorded in project-root. |
| T-05-07 | Denial of Service | --gc deletes wrong dirs | mitigate | GC loop is scoped to `$HOME/.claudebox/projects/*/` only; `rm -rf` target is `$dir` which is always under that prefix. Cannot escape to arbitrary paths. |
| T-05-08 | Elevation of Privilege | rm -rf via symlink in projects/ | mitigate | Instance dirs are created by claudebox's own `mkdir -p`; if a symlink appears in projects/, the `[[ -d "$dir" ]]` check follows symlinks but `rm -rf` on a symlink to a directory would delete the symlink target. Low risk since ~/.claudebox/ is user-writable anyway. Accept for personal tool. |
</threat_model>
<verification>
1. `bash -n claudebox.sh` passes
2. `claudebox --gc` runs without error on current system (prints "GC complete: 0 instance(s) removed." if no stale dirs)
3. `bash test-gc.sh` passes all three test cases
4. `claudebox --gc` does NOT launch Claude Code (exits immediately)
</verification>
<success_criteria>
- `claudebox --gc` removes stale instance directories and exits
- Valid instance directories (project root still exists) are preserved
- Empty projects/ directory does not cause errors
- GC output goes to stderr with removal details and summary count
</success_criteria>
<output>
After completion, create `.planning/phases/05-per-project-instance-isolation/05-02-SUMMARY.md`
</output>