claudebox/.planning/phases/05-per-project-instance-isolation/05-01-PLAN.md

15 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
05-per-project-instance-isolation 01 execute 1
claudebox.sh
false
INST-01
INST-02
INST-03
truths artifacts key_links
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
path provides contains
claudebox.sh compute_canonical_root function, instance initialization, new mount layout compute_canonical_root
path provides contains
claudebox.sh BWRAP_ARGS with direct ~/.claude bind and overlay mounts --bind "$HOME/.claude" "$HOME/.claude"
from to via pattern
claudebox.sh (compute_canonical_root) INSTANCE_DIR variable sha256sum of canonical root path sha256sum.*cut -c1-16
from to via pattern
claudebox.sh (BWRAP_ARGS) bwrap execution overlay mounts after parent bind --bind.*.claude/projects
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.

<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>

@.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

From claudebox.sh (flag parsing, lines 1-18):

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):

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):

CREDS_FILE="$HOME/.claudebox/.credentials.json"
CLAUDE_JSON_FILE="$HOME/.claude.json"
Task 1: Rewrite mount architecture and add per-project isolation claudebox.sh - 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) Modify claudebox.sh with these changes, in order:

1. Add compute_canonical_root function (insert after CWD=$(pwd) on line 99):

# 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):

# 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:

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:

  --bind "$HOME/.claudebox" "$HOME/.claudebox"
  --symlink "$HOME/.claudebox" "$HOME/.claude"

With (per D-01, D-02, D-03, D-06):

  # 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:

BWRAP_ARGS+=(--bind "$CREDS_FILE" "$HOME/.claudebox/.credentials.json")

To:

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:

    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:

    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:

  printf '  %-12s %s   (read-write)\n' "$HOME/.claude" "$HOME/.claudebox" >&2

With:

  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.) 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" <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> 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.

Task 2: Verify mount architecture and per-project isolation claudebox.sh Human verifies the mount architecture rewrite works correctly end-to-end. Complete mount architecture rewrite: direct ~/.claude bind with per-project overlay isolation. Dry-run and audit display updated. 1. Run `claudebox --dry-run` from this repo — verify output shows `--bind $HOME/.claude $HOME/.claude` followed by `--bind $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) Human confirms all 5 verification steps pass 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. Type "approved" or describe issues

<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
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>
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//project-root` contains the correct canonical root 5. Claude Code launches successfully with plugins visible

<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>
After completion, create `.planning/phases/05-per-project-instance-isolation/05-01-SUMMARY.md`