claudebox/.planning/quick/260505-le7-add-harness-config-file-support-to-claud/260505-le7-PLAN.md

18 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
260505-le7 01 execute 1
claudebox.sh
true
HARNESS-01
HARNESS-02
HARNESS-03
HARNESS-04
truths artifacts key_links
User can put `cmd = gsd` in `~/.claudebox/config` or `<project>/.claudebox` and claudebox launches gsd inside the sandbox instead of claude
User can put `mount_home = .gsd` in a config file and `~/.gsd` is rw-bound into the sandbox
User can put `path_add = ~/.local/share/npm/bin` in a config file and that dir appears prepended to PATH inside the sandbox
Per-project `.claudebox` overrides global `~/.claudebox/config` for `cmd` (last-wins), but `mount_home` and `path_add` accumulate across both files
CLI flags `--cmd`, `--mount-home`, `--path-add` override/append on top of config files with same semantics
When `cmd` resolves to anything other than the default `claude` binary, `--dangerously-skip-permissions` is NOT prepended
Audit shows a `[config]` section listing which config files were loaded; extra mounts appear in Mounts section; path additions appear in PATH list
`--dry-run` reflects extra mounts and PATH additions
`--check` reports presence/absence of `~/.claudebox/config` and `<CWD>/.claudebox`
If `cmd` resolves to a non-existent binary, claudebox prints an error and exits 1
If a `mount_home` subdir does not exist on host, claudebox warns but does not error
path provides contains
claudebox.sh Config file parsing, CLI flag handling, harness binary resolution, extra mounts, PATH augmentation load_config_file
from to via pattern
config file parser MOUNT_HOME / PATH_ADD / HARNESS_CMD globals load_config_file appends to arrays / sets scalar load_config_file
from to via pattern
HARNESS_CMD SANDBOX_CMD construction command -v resolution; conditional --dangerously-skip-permissions SANDBOX_CMD=.*HARNESS_BIN
from to via pattern
PATH_ADD entries SANDBOX_PATH passed via --setenv PATH prepend with `:` separator before bwrap exec SANDBOX_PATH=
from to via pattern
MOUNT_HOME entries BWRAP_ARGS / dry-run output --bind $HOME/<sub> $HOME/<sub> --bind.*HOME
Add config file support to `claudebox` so users can pin alternate harnesses (e.g. `gsd`) and accompanying home mounts / PATH dirs without remembering CLI flags.

Purpose: Make claudebox usable as a generic sandbox launcher for non-claude CLIs while keeping claude as the zero-config default.

Output: Updated claudebox.sh with config file loading, CLI flag overrides, harness binary resolution with conditional --dangerously-skip-permissions, extra home mounts, PATH augmentation, audit/dry-run/check integration.

<execution_context> @/home/toph/code/tools/claudebox/.claude/get-shit-done/workflows/execute-plan.md </execution_context>

@/home/toph/code/tools/claudebox/CLAUDE.md @/home/toph/code/tools/claudebox/claudebox.sh @/home/toph/code/tools/claudebox/flake.nix

Current flag parser (lines 11-28): while-loop case statement, -- ends parsing, unknown args fall through to CLAUDE_ARGS+=("$1").

Existing globals set during init:

  • CWD (line 178)
  • CANONICAL_ROOT (line 199) — also used to locate per-project .claudebox.env; reuse for <project-root>/.claudebox
  • HOME, SANDBOX_PATH (injected by flake.nix), SANDBOX_BASH, CLAUDE_BIN

Existing parallel pattern to mimic — env files (lines 386-408):

load_env_file() {
  local file="$1"
  [[ -f "$file" ]] || return 0
  while IFS= read -r line || [[ -n "$line" ]]; do
    line="${line#"${line%%[! ]*}"}"
    [[ -z "$line" || "$line" == '#'* ]] && continue
    [[ "$line" != *=* ]] && continue
    local key="${line%%=*}"
    local val="${line#*=}"
    ...
  done < "$file"
}
load_env_file "$HOME/.claudebox/env"
load_env_file "$CANONICAL_ROOT/.claudebox.env"

SANDBOX_CMD construction (lines 492-496):

if [[ "$SHELL_MODE" == true ]]; then
  SANDBOX_CMD=("$SANDBOX_BASH" "${CLAUDE_ARGS[@]}")
else
  SANDBOX_CMD=("$CLAUDE_BIN" --dangerously-skip-permissions "${CLAUDE_ARGS[@]}")
fi

BWRAP_ARGS assembly (lines 565-624) and dry-run output (lines 499-563) are parallel — any new mount must be added to BOTH.

ENV_ARGS already wires --setenv PATH "$SANDBOX_PATH". To prepend dirs, mutate SANDBOX_PATH BEFORE building ENV_ARGS (line 320) OR after, and update AUDIT_SANDBOX_VALS[PATH] to match.

Task 1: Parse config files and CLI flags; expose HARNESS_CMD / MOUNT_HOME / PATH_ADD globals claudebox.sh Add config file parsing and new CLI flags to `claudebox.sh`.
  1. Initialise globals near the top (alongside SKIP_AUDIT=false etc., line 1-9):

    HARNESS_CMD=""           # set by config or --cmd; empty means "use default claude"
    MOUNT_HOME=()            # array of subdir names (relative to $HOME)
    PATH_ADD=()              # array of dirs to prepend to sandbox PATH
    CONFIG_FILES_LOADED=()   # for audit: list of loaded config paths
    
  2. Add CLI flags to the while-loop (lines 11-28):

    • --cmd <binary> → sets HARNESS_CMD="$2"; shift
    • --mount-home <subdir>MOUNT_HOME+=("$2"); shift
    • --path-add <dir>PATH_ADD+=("${2/#\~/$HOME}"); shift
    • All three must validate the next arg exists and error+exit 1 if missing (mirror the --ssh-key pattern at lines 19-22).
  3. Add config loader function alongside load_env_file (after line 408 is fine, but it MUST run BEFORE flag parsing applies overrides — see step 4 for ordering). Function:

    load_config_file() {
      local file="$1"
      [[ -f "$file" ]] || return 0
      CONFIG_FILES_LOADED+=("$file")
      while IFS= read -r line || [[ -n "$line" ]]; do
        line="${line#"${line%%[! ]*}"}"           # ltrim
        [[ -z "$line" || "$line" == '#'* ]] && continue
        [[ "$line" != *=* ]] && continue
        local key="${line%%=*}"
        local val="${line#*=}"
        # trim surrounding whitespace from key and val
        key="${key%"${key##*[! ]}"}"; key="${key#"${key%%[! ]*}"}"
        val="${val#"${val%%[! ]*}"}"; val="${val%"${val##*[! ]}"}"
        case "$key" in
          cmd)        HARNESS_CMD="$val" ;;
          mount_home) MOUNT_HOME+=("$val") ;;
          path_add)   PATH_ADD+=("${val/#\~/$HOME}") ;;
          *) echo "${YELLOW:-}Warning: unknown key '$key' in $file${RESET:-}" >&2 ;;
        esac
      done < "$file"
    }
    
  4. Ordering constraint (CRITICAL): Config files must load BEFORE CLI flags take effect, but CANONICAL_ROOT is computed at line 199 — AFTER current flag parsing. Solution: split into two passes.

    • Move compute_canonical_root + CANONICAL_ROOT computation earlier (right after CWD=$(pwd) at line 178), so it is available before config loading. Verify nothing earlier in the script depends on CWD having NOT been canonicalised — it doesn't, only CWD itself is used.
    • Then load configs in this order (cascading, later overrides earlier scalar / appends to arrays):
      load_config_file "$HOME/.claudebox/config"
      load_config_file "$CANONICAL_ROOT/.claudebox"
      
    • The CLI flag handling already happens at the top of the script. To preserve "CLI overrides config" semantics, capture the CLI values into separate variables during arg parsing (e.g. CLI_HARNESS_CMD, CLI_MOUNT_HOME=(), CLI_PATH_ADD=()), then after config loading apply:
      [[ -n "$CLI_HARNESS_CMD" ]] && HARNESS_CMD="$CLI_HARNESS_CMD"
      MOUNT_HOME+=("${CLI_MOUNT_HOME[@]}")
      PATH_ADD+=("${CLI_PATH_ADD[@]}")
      
      Note the bash gotcha: MOUNT_HOME+=("${CLI_MOUNT_HOME[@]}") errors under set -u when the array is empty. Use MOUNT_HOME+=("${CLI_MOUNT_HOME[@]:-}") guarded by length check, or (( ${#CLI_MOUNT_HOME[@]} > 0 )) && MOUNT_HOME+=("${CLI_MOUNT_HOME[@]}").
  5. Resolve harness binary. After CLAUDE_BIN="$(command -v claude)" (line 175), add:

    if [[ -n "$HARNESS_CMD" ]]; then
      HARNESS_BIN="$(command -v "$HARNESS_CMD" 2>/dev/null)" || {
        echo "${RED:-}Error: configured cmd '$HARNESS_CMD' not found in PATH${RESET:-}" >&2
        exit 1
      }
    else
      HARNESS_BIN="$CLAUDE_BIN"
      HARNESS_CMD="claude"
    fi
    IS_DEFAULT_CLAUDE=false
    [[ "$HARNESS_BIN" == "$CLAUDE_BIN" ]] && IS_DEFAULT_CLAUDE=true
    

    Note ANSI colour vars are defined at line 121 — make sure binary resolution happens AFTER that block so error styling works. If config loading must happen before colour-var setup for ordering, fall back to plain text in the error.

  6. --check additions: in the CHECK_MODE block (lines 71-112), after the ~/.claudebox check, add:

    if [[ -f "$HOME/.claudebox/config" ]]; then
      echo "${green}OK${reset}    ~/.claudebox/config exists" >&2
    else
      echo "${yellow}WARN${reset}  ~/.claudebox/config -- not found (optional)" >&2
    fi
    _proj_cfg=$(compute_canonical_root "$PWD")/.claudebox
    if [[ -f "$_proj_cfg" ]]; then
      echo "${green}OK${reset}    $_proj_cfg exists" >&2
    else
      echo "${yellow}WARN${reset}  $_proj_cfg -- not found (optional)" >&2
    fi
    

    compute_canonical_root is defined at line 181; the function definition must be moved above the CHECK_MODE block too (or duplicated check moved below — simpler: hoist the function definition near the top with the other helpers, alongside gc_instances).

Do not introduce new external dependencies. Keep everything in pure bash. Preserve set -euo pipefail semantics imposed by writeShellApplication. cd /home/toph/code/tools/claudebox && nix build .#claudebox 2>&1 | tail -20 && ./result/bin/claudebox --check 2>&1 | grep -E '(claudebox/config|/.claudebox)'

  • New flags --cmd, --mount-home, --path-add parse without error
  • nix build .#claudebox succeeds (shellcheck clean)
  • --check reports presence/absence of both config file paths
  • HARNESS_BIN resolves correctly; missing harness binary produces error+exit 1
  • IS_DEFAULT_CLAUDE flag correctly distinguishes default-claude from override
Task 2: Wire HARNESS_BIN, MOUNT_HOME, PATH_ADD into SANDBOX_CMD, BWRAP_ARGS, ENV_ARGS, audit, and dry-run claudebox.sh Consume the globals produced by Task 1 throughout the rest of the pipeline.
  1. PATH augmentation. BEFORE building ENV_ARGS (currently at line 320), prepend PATH_ADD entries to SANDBOX_PATH:

    if (( ${#PATH_ADD[@]} > 0 )); then
      _path_prefix=""
      for _p in "${PATH_ADD[@]}"; do
        _path_prefix+="${_p}:"
      done
      SANDBOX_PATH="${_path_prefix}${SANDBOX_PATH}"
      unset _path_prefix _p
    fi
    

    This mutates SANDBOX_PATH before it is consumed by --setenv PATH "$SANDBOX_PATH" and by AUDIT_SANDBOX_VALS[PATH] — both will reflect the prepended dirs automatically. Verify by inspecting lines 324 and 333 use $SANDBOX_PATH directly.

  2. MOUNT_HOME validation and bwrap wiring. After the existing SSH-related mount block in BWRAP_ARGS (after line 616, before the closing BWRAP_ARGS+=(...) block at 618):

    for _sub in "${MOUNT_HOME[@]}"; do
      _src="$HOME/$_sub"
      if [[ ! -e "$_src" ]]; then
        echo "${YELLOW}Warning: mount_home '$_sub' does not exist at $_src; skipping${RESET}" >&2
        continue
      fi
      BWRAP_ARGS+=(--bind "$_src" "$_src")
    done
    unset _sub _src
    

    Mirror this in the dry-run block (after line 555, before line 557 --ro-bind .gitconfig):

    for _dry_sub in "${MOUNT_HOME[@]}"; do
      _dry_src="$HOME/$_dry_sub"
      [[ -e "$_dry_src" ]] || continue
      echo "  --bind $_dry_src $_dry_src \\"
    done
    unset _dry_sub _dry_src
    
  3. SANDBOX_CMD construction. Replace lines 492-496:

    if [[ "$SHELL_MODE" == true ]]; then
      SANDBOX_CMD=("$SANDBOX_BASH" "${CLAUDE_ARGS[@]}")
    elif [[ "$IS_DEFAULT_CLAUDE" == true ]]; then
      SANDBOX_CMD=("$HARNESS_BIN" --dangerously-skip-permissions "${CLAUDE_ARGS[@]}")
    else
      SANDBOX_CMD=("$HARNESS_BIN" "${CLAUDE_ARGS[@]}")
    fi
    
  4. Audit display additions in print_audit() (lines 411-470):

    • At the very top of the function (before the === Sandbox Environment === header), add a config block when configs were loaded:
      if (( ${#CONFIG_FILES_LOADED[@]} > 0 )); then
        echo "${BOLD}${CYAN}=== Config ===${RESET}" >&2
        for _cf in "${CONFIG_FILES_LOADED[@]}"; do
          echo "  loaded: $_cf" >&2
        done
        echo "  cmd=$HARNESS_CMD ($HARNESS_BIN)" >&2
        (( ${#MOUNT_HOME[@]} > 0 )) && echo "  mount_home: ${MOUNT_HOME[*]}" >&2
        (( ${#PATH_ADD[@]} > 0 )) && echo "  path_add: ${PATH_ADD[*]}" >&2
        echo "" >&2
        unset _cf
      fi
      
      Even when no config file is loaded but HARNESS_CMD != claude (set via --cmd), still show the cmd line so users know they're not running stock claude. Adjust the condition: show the section if ${#CONFIG_FILES_LOADED[@]} > 0 || $IS_DEFAULT_CLAUDE != true.
    • In the Mounts section (lines 439-463), after the credentials/SSH blocks, add:
      for _sub in "${MOUNT_HOME[@]}"; do
        _src="$HOME/$_sub"
        [[ -e "$_src" ]] || continue
        printf '  %-12s %s   (read-write, mount_home)\n' "home" "$_src" >&2
      done
      unset _sub _src
      
    • PATH addition is already visible in the audit because we mutated SANDBOX_PATH in step 1; no further change needed (the per-entry display at lines 419-422 will show prepended dirs first).
  5. Sanity-check shellcheck cleanliness — writeShellApplication runs shellcheck at build. Common gotchas:

    • Empty array expansion under set -u: guard with (( ${#arr[@]} > 0 )) or use "${arr[@]:-}"
    • Unused vars when arrays are empty: prefix with _ or use unset after the loop
    • local only valid inside functions

Do not modify mount logic for existing ~/.claude, history, SANDBOX.md, credentials, or SSH paths. Do not add any package to runtimeDeps in flake.nix — pure bash only. cd /home/toph/code/tools/claudebox && nix build .#claudebox 2>&1 | tail -5 && mkdir -p /tmp/le7-test && printf 'cmd = bash\nmount_home = .config\npath_add = ~/.local/bin\n' > /tmp/le7-test/.claudebox && cd /tmp/le7-test && git init -q && /home/toph/code/tools/claudebox/result/bin/claudebox --dry-run --yes 2>&1 | grep -E '(bash|--bind.*.config|/.local/bin)' && cd - && rm -rf /tmp/le7-test

  • nix build .#claudebox succeeds (shellcheck clean, no runtime errors)
  • Per-project .claudebox with cmd = bash causes dry-run to show bash (not claude --dangerously-skip-permissions) as the final exec target
  • mount_home = .config produces a --bind $HOME/.config $HOME/.config line in dry-run
  • path_add = ~/.local/bin causes ~/.local/bin to appear at the front of the --setenv PATH value in dry-run
  • Audit output shows === Config === block listing loaded files when configs are in play
  • Default invocation (no config, no flags) is byte-identical in behaviour to current claudebox: claude is launched with --dangerously-skip-permissions
  • Non-existent mount_home subdir produces a warning but does not abort
  • Non-existent cmd binary aborts with exit 1 and an error message
After both tasks complete, run the full integration check:
nix build .#claudebox

# 1. Default behaviour preserved (no config, no flags)
./result/bin/claudebox --dry-run --yes 2>&1 | grep -- '--dangerously-skip-permissions' || echo "FAIL: default claude invocation lost"

# 2. Per-project .claudebox with custom cmd
mkdir -p /tmp/le7-int && cd /tmp/le7-int && git init -q
printf 'cmd = bash\n' > .claudebox
/home/toph/code/tools/claudebox/result/bin/claudebox --dry-run --yes 2>&1 | tail -3 | grep -v -- '--dangerously-skip-permissions' | grep -q bash && echo "OK: harness override works"

# 3. mount_home + path_add
printf 'cmd = bash\nmount_home = .config\npath_add = ~/.local/bin\n' > .claudebox
/home/toph/code/tools/claudebox/result/bin/claudebox --dry-run --yes 2>&1 | grep -E '(\.local/bin|--bind.*\.config)' && echo "OK: extras wired"

# 4. CLI flag override
/home/toph/code/tools/claudebox/result/bin/claudebox --cmd /usr/bin/env --dry-run --yes 2>&1 | tail -3

# 5. Missing harness binary
printf 'cmd = totally-nonexistent-binary-xyz\n' > .claudebox
/home/toph/code/tools/claudebox/result/bin/claudebox --dry-run --yes 2>&1 | grep -i 'not found' && echo "OK: missing binary errors"

# 6. --check reports config files
rm .claudebox
/home/toph/code/tools/claudebox/result/bin/claudebox --check 2>&1 | grep -E '\.claudebox(/config)?'

cd - && rm -rf /tmp/le7-int

<success_criteria>

  • nix build .#claudebox succeeds (shellcheck clean)
  • Default invocation unchanged: claude --dangerously-skip-permissions still launched when no config / no --cmd
  • cmd = <other> config or --cmd <other> launches that binary WITHOUT --dangerously-skip-permissions
  • Missing harness binary aborts with error+exit 1
  • mount_home entries become --bind in BWRAP_ARGS and dry-run; missing subdirs warn but do not abort
  • path_add entries are prepended to SANDBOX_PATH and visible in audit + dry-run
  • Cascade order respected: ~/.claudebox/config loaded first, <CANONICAL_ROOT>/.claudebox second; CLI flags applied last
  • Audit shows === Config === section listing loaded files, harness cmd, and accumulated arrays when relevant
  • --check reports presence/absence of both config file paths </success_criteria>
After completion, create `.planning/quick/260505-le7-add-harness-config-file-support-to-claud/260505-le7-SUMMARY.md` documenting: - Final claudebox.sh structure (where config loading lives, ordering rationale) - Any deviations from the plan - Manual smoke test result with a real harness (`gsd` if available)