From fbbb35577e0a57f9fbe115e75e28f8dcb0488405 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christopher=20M=C3=BChl?= Date: Tue, 5 May 2026 15:31:11 +0000 Subject: [PATCH] feat(260505-le7): add config file globals, CLI flags, load_config_file, HARNESS_BIN resolution --- .planning/STATE.md | 3 +- .../260504-bw4-PLAN.md | 290 ---------------- .../260504-bw4-SUMMARY.md | 68 ---- README.md | 45 --- claudebox.sh | 314 +++++++++--------- 5 files changed, 156 insertions(+), 564 deletions(-) delete mode 100644 .planning/quick/260504-bw4-add-ssh-support-to-claudebox-with-ssh-fl/260504-bw4-PLAN.md delete mode 100644 .planning/quick/260504-bw4-add-ssh-support-to-claudebox-with-ssh-fl/260504-bw4-SUMMARY.md diff --git a/.planning/STATE.md b/.planning/STATE.md index eacdc39..187f20e 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -28,7 +28,7 @@ See: .planning/PROJECT.md (updated 2026-04-09) Phase: 04 of 3 (sandbox aware prompting) Plan: Not started Status: Ready to execute -Last activity: 2026-05-04 - Completed quick task 260504-bw4: Add SSH support to claudebox +Last activity: 2026-04-10 Progress: [███░░░░░░░] 33% @@ -63,7 +63,6 @@ None. | # | Description | Date | Commit | Directory | |---|-------------|------|--------|-----------| | 260410-d4u | on non-nixos hosts, bwrap fails because /etc/static does not exist | 2026-04-10 | 97c10f8 | [260410-d4u-on-non-nixos-hosts-bwrap-fails-because-e](./quick/260410-d4u-on-non-nixos-hosts-bwrap-fails-because-e/) | -| 260504-bw4 | Add SSH support to claudebox: --with-ssh flag forwards SSH_AUTH_SOCK agent socket, --ssh-key flag mounts specific key files read-only into sandbox ~/.ssh/ | 2026-05-04 | b2aeb2f | [260504-bw4-add-ssh-support-to-claudebox-with-ssh-fl](./quick/260504-bw4-add-ssh-support-to-claudebox-with-ssh-fl/) | ## Session Continuity diff --git a/.planning/quick/260504-bw4-add-ssh-support-to-claudebox-with-ssh-fl/260504-bw4-PLAN.md b/.planning/quick/260504-bw4-add-ssh-support-to-claudebox-with-ssh-fl/260504-bw4-PLAN.md deleted file mode 100644 index 8d2405c..0000000 --- a/.planning/quick/260504-bw4-add-ssh-support-to-claudebox-with-ssh-fl/260504-bw4-PLAN.md +++ /dev/null @@ -1,290 +0,0 @@ ---- -phase: 260504-bw4 -plan: 01 -type: execute -wave: 1 -depends_on: [] -files_modified: - - claudebox.sh - - README.md -autonomous: true -requirements: - - SSH-01 - - SSH-02 - - SSH-03 - - SSH-04 -must_haves: - truths: - - "Running `claudebox --with-ssh` forwards $SSH_AUTH_SOCK into the sandbox at the same path with SSH_AUTH_SOCK env var set" - - "Running `claudebox --ssh-key ~/.ssh/id_ed25519` mounts that file (and its .pub if present) read-only into the sandbox at ~/.ssh/id_ed25519" - - "When any SSH mechanism is active, ~/.ssh/known_hosts is mounted read-only (if it exists on the host)" - - "--ssh-key is repeatable; multiple keys all land in the synthetic sandbox ~/.ssh/" - - "--with-ssh and --ssh-key can be combined in one invocation" - - "The audit display shows active SSH mechanism(s) and mounts" - - "The --dry-run output includes the SSH bwrap flags" - - "SANDBOX.md inside the sandbox reflects that SSH is available when SSH flags are active" - - "README.md documents SSH usage including ssh-agent setup for bash and fish" - artifacts: - - path: "claudebox.sh" - provides: "--with-ssh and --ssh-key flags, SSH bwrap mounts, conditional SANDBOX.md, audit/dry-run integration" - contains: "--with-ssh" - - path: "README.md" - provides: "SSH section + Flags table entries" - contains: "## SSH" - key_links: - - from: "claudebox.sh flag parser" - to: "BWRAP_ARGS SSH mount block" - via: "WITH_SSH and SSH_KEYS array set during arg parsing, consumed when assembling bwrap args" - pattern: "WITH_SSH|SSH_KEYS" - - from: "claudebox.sh SSH state" - to: "SANDBOX.md heredoc generation" - via: "conditional Default Restrictions text based on WITH_SSH/SSH_KEYS" - pattern: "SANDBOX.md" - - from: "claudebox.sh SSH state" - to: "print_audit + dry-run output" - via: "Mounts section emits SSH lines when active" - pattern: "print_audit|DRY_RUN" ---- - - -Add two opt-in SSH mechanisms to claudebox so users can `git push/pull` from inside the sandbox without exposing SSH keys by default. - -Purpose: Today the sandbox blocks all SSH. Real workflows need it for git remotes. The right answer is opt-in agent forwarding (`--with-ssh`) plus explicit key file mounting (`--ssh-key`), with audit visibility so the user always sees what crossed the boundary. - -Output: Updated `claudebox.sh` implementing both flags, audit + dry-run + SANDBOX.md integration, and an updated `README.md` documenting setup and usage. - - - -@./CLAUDE.md -@./claudebox.sh -@./README.md -@.planning/STATE.md - - - - -Flag parsing pattern (lines 9-20): -```bash -while (( $# > 0 )); do - case "$1" in - --yes|-y) SKIP_AUDIT=true ;; - ... - *) CLAUDE_ARGS+=("$1") ;; - esac - shift -done -``` - -Audit data structure (lines 240-245): AUDIT_SANDBOX_KEYS / AUDIT_HOST_KEYS / AUDIT_EXTRA_KEYS arrays + parallel _VALS assoc arrays. SSH mounts are mounts, not env vars — they belong in the print_audit `Mounts:` section (line 361), not in the env arrays. SSH_AUTH_SOCK is an env var — it should go through ENV_ARGS via the AUDIT_HOST_KEYS path. - -SANDBOX.md generation (lines 185-222): single heredoc with `'SANDBOXEOF'` (literal). To make it conditional we either (a) split it into pieces, or (b) generate it without a quoted heredoc and use bash conditionals. Approach: keep the static parts as a heredoc, then append a conditional "SSH" subsection before writing the "Git" section, OR rewrite as `cat < - - - - - - Task 1: Implement --with-ssh and --ssh-key flag parsing + bwrap mounts - claudebox.sh - - - `--with-ssh` sets WITH_SSH=true. If $SSH_AUTH_SOCK is set and is a socket on the host, add `--bind $SSH_AUTH_SOCK $SSH_AUTH_SOCK` to BWRAP_ARGS and `--setenv SSH_AUTH_SOCK $SSH_AUTH_SOCK` to ENV_ARGS. If the var is unset or the path is not a socket, print a warning to stderr and continue without forwarding. - - `--ssh-key ` is repeatable. Each value is appended to a SSH_KEYS array. Path is expanded (`~` -> $HOME) and validated: file must exist and be readable; otherwise exit 1 with an error. - - When WITH_SSH=true OR SSH_KEYS is non-empty: add `--dir $HOME/.ssh` to BWRAP_ARGS so the sandbox has a real ~/.ssh directory inside the home tmpfs. - - For each key in SSH_KEYS: add `--ro-bind $HOME/.ssh/`. If `.pub` exists on the host, also `--ro-bind .pub $HOME/.ssh/.pub`. - - When SSH is active AND `~/.ssh/known_hosts` exists on the host: add `--ro-bind $HOME/.ssh/known_hosts $HOME/.ssh/known_hosts` exactly once (shared between both mechanisms). - - The dry-run block (lines 405-451) emits the same SSH lines so `claudebox --dry-run --with-ssh --ssh-key ~/.ssh/id_ed25519` prints them. - - - 1. In the flag-parsing `case` block (around line 10), add: - ```bash - --with-ssh) WITH_SSH=true ;; - --ssh-key) - shift - [[ $# -gt 0 ]] || { echo "Error: --ssh-key requires a path" >&2; exit 1; } - SSH_KEYS+=("${1/#\~/$HOME}") - ;; - ``` - Initialize `WITH_SSH=false` and `SSH_KEYS=()` near the top with the other flag defaults. - - 2. After argument parsing, add a validation+resolution block that: - - For each path in SSH_KEYS: resolve to absolute, verify exists+readable, replace array entry with absolute path, error+exit if missing. - - If WITH_SSH=true: check `[[ -v SSH_AUTH_SOCK && -S "$SSH_AUTH_SOCK" ]]`. If not, print `${YELLOW}Warning: --with-ssh given but SSH_AUTH_SOCK is unset or not a socket; agent will not be forwarded.${RESET}` and set WITH_SSH=false. (Color vars are defined later — move this block to AFTER the ANSI block at line 107, or use plain text.) - - Compute `SSH_ACTIVE=true` if WITH_SSH=true OR ${#SSH_KEYS[@]} > 0; else false. - - Compute `KNOWN_HOSTS_MOUNT=true` if SSH_ACTIVE && `[[ -f $HOME/.ssh/known_hosts ]]`. - - 3. Where ENV_ARGS is built (after line 256): if WITH_SSH=true, append `--setenv SSH_AUTH_SOCK $SSH_AUTH_SOCK` and add to AUDIT_HOST_KEYS/VALS so it shows in the audit's `[>]` section. - - 4. Where BWRAP_ARGS is assembled (lines 453-487), after the existing conditional mounts (CLAUDE_JSON, CREDS) and before the trailing `--ro-bind GITCONFIG_TMP ...` line, insert: - ```bash - if [[ "$SSH_ACTIVE" == true ]]; then - BWRAP_ARGS+=(--dir "$HOME/.ssh") - if [[ "$WITH_SSH" == true ]]; then - BWRAP_ARGS+=(--bind "$SSH_AUTH_SOCK" "$SSH_AUTH_SOCK") - fi - for key in "${SSH_KEYS[@]}"; do - base=$(basename "$key") - BWRAP_ARGS+=(--ro-bind "$key" "$HOME/.ssh/$base") - if [[ -f "${key}.pub" ]]; then - BWRAP_ARGS+=(--ro-bind "${key}.pub" "$HOME/.ssh/$base.pub") - fi - done - if [[ "$KNOWN_HOSTS_MOUNT" == true ]]; then - BWRAP_ARGS+=(--ro-bind "$HOME/.ssh/known_hosts" "$HOME/.ssh/known_hosts") - fi - fi - ``` - - 5. Mirror all of (4) in the dry-run echo block (after the CREDS_MOUNT block around line 444, before the GITCONFIG line at 445), printing the same flags as quoted strings. - - 6. Update the audit display (`print_audit`, around line 361) to emit additional Mounts lines when SSH_ACTIVE: - - `agent (read-write, --with-ssh)` if WITH_SSH - - For each key: `ssh-key (read-only)`; add ` + .pub` line if pub exists - - `known_hosts (read-only)` if KNOWN_HOSTS_MOUNT - - - bash -n claudebox.sh && claudebox --dry-run --with-ssh 2>&1 | grep -q "SSH_AUTH_SOCK\|Warning: --with-ssh" && echo "Note: full agent forwarding only verifiable when ssh-agent is running on host" - - - - `claudebox --dry-run --with-ssh` (with agent running) prints `--bind $SSH_AUTH_SOCK ...` and `--setenv SSH_AUTH_SOCK ...`. - - `claudebox --dry-run --ssh-key ~/.ssh/id_ed25519` prints `--dir $HOME/.ssh`, `--ro-bind $HOME/.ssh/id_ed25519`, and (if present) the matching .pub bind. - - Both flags together print all of the above plus a single `--ro-bind .../known_hosts ...` line (if known_hosts exists). - - Missing key file → `claudebox --ssh-key /nonexistent` exits 1 with a clear error. - - Audit display shows the SSH mounts in the `Mounts:` section. - - `bash -n claudebox.sh` passes; shellcheck (run by writeShellApplication at build time) passes. - - - - - Task 2: Make SANDBOX.md conditional on SSH activation - claudebox.sh - - - When SSH_ACTIVE=false: SANDBOX.md keeps the current "Default Restrictions" section listing SSH keys as not mounted, and the Git section recommends HTTPS. - - When SSH_ACTIVE=true: "Default Restrictions" no longer lists SSH keys; a new "SSH" subsection states which mechanism is active (agent forwarding via $SSH_AUTH_SOCK and/or explicit key files at ~/.ssh/), and the Git section drops the HTTPS-preference sentence (or replaces it with: "SSH remotes work in this session."). - - - 1. Replace the quoted heredoc at lines 185-222 with an unquoted heredoc using shell-side composed variables. Build them before the heredoc: - ```bash - if [[ "$SSH_ACTIVE" == true ]]; then - _SSH_NOTES="" - [[ "$WITH_SSH" == true ]] && _SSH_NOTES+="- ssh-agent socket forwarded via \$SSH_AUTH_SOCK\n" - (( ${#SSH_KEYS[@]} > 0 )) && _SSH_NOTES+="- Explicit key file(s) mounted read-only at ~/.ssh/\n" - SANDBOX_RESTRICTIONS_BLOCK=$'## Default Restrictions\n\nBy default, the following are not mounted into the sandbox:\n- GPG and age keys (~/.gnupg, age key files)\n- Cloud credentials (~/.aws, ~/.config/gcloud)\n- Tailscale state\n\n## SSH\n\nSSH is available in this session:\n'"$(printf "$_SSH_NOTES")"$'\nUse `git push`/`git pull` over SSH normally.' - SANDBOX_GIT_TAIL="SSH remotes work in this session." - else - SANDBOX_RESTRICTIONS_BLOCK=$'## Default Restrictions\n\nBy default, the following are not mounted into the sandbox:\n- SSH keys (~/.ssh)\n- GPG and age keys (~/.gnupg, age key files)\n- Cloud credentials (~/.aws, ~/.config/gcloud)\n- Tailscale state\n\nIf your setup has been customized, some of these may be available.' - SANDBOX_GIT_TAIL="For remote operations, prefer HTTPS URLs over SSH since SSH keys are not available by default." - fi - ``` - 2. Rewrite the heredoc as `cat > "$HOME/.claudebox/SANDBOX.md" < - - bash -n claudebox.sh && claudebox --dry-run -y >/dev/null 2>&1 && grep -q "SSH keys (~/.ssh)" "$HOME/.claudebox/SANDBOX.md" && echo "no-ssh path OK" && claudebox --dry-run -y --ssh-key /etc/hostname >/dev/null 2>&1 && grep -q "## SSH" "$HOME/.claudebox/SANDBOX.md" && ! grep -q "SSH keys (~/.ssh)" "$HOME/.claudebox/SANDBOX.md" && echo "ssh-active path OK" - - - - Without SSH flags: SANDBOX.md contains "SSH keys (~/.ssh)" in restrictions and HTTPS preference in Git section. - - With `--with-ssh` or `--ssh-key`: SANDBOX.md drops the SSH-keys restriction line, gains a "## SSH" section listing active mechanisms, and Git section says SSH works. - - `bash -n` and shellcheck pass. - - - - - Task 3: Document SSH support in README.md - README.md - - - Flags table includes `--with-ssh` and `--ssh-key ` rows with concise descriptions. - - New `## SSH` section after `## Env vars` (and before `## How it works`) covers: when you need SSH (git push/pull over SSH remotes), the agent-forwarding flow with bash and fish setup commands, the agent-dies-with-shell caveat, the explicit key-file flow, and guidance on when to prefer each. - - - 1. Update the Flags table (lines 34-41) by inserting two rows after `--shell`: - ``` - | `--with-ssh` | Forward $SSH_AUTH_SOCK into the sandbox (requires running ssh-agent) | - | `--ssh-key ` | Mount a private key file read-only into the sandbox ~/.ssh/ (repeatable) | - ``` - 2. Add a new `## SSH` section between the existing `## Env vars` and `## How it works` sections with this content (verbatim shape, write in the same plain-prose tone as the rest of the README — no marketing fluff): - - ```markdown - ## SSH - - SSH is opt-in. By default no keys or agent socket cross the sandbox boundary, which means git push/pull over SSH remotes won't work. Two mechanisms are available — pick whichever matches your workflow. - - ### `--with-ssh` (agent forwarding) - - Forwards `$SSH_AUTH_SOCK` into the sandbox so any keys loaded in your ssh-agent are usable inside. Your private key files are never mounted; only the agent socket is. - - Start an agent before launching claudebox. The agent dies with the shell that started it, so don't expect it to survive across terminals. - - Bash: - ```bash - eval "$(ssh-agent)" - ssh-add ~/.ssh/id_ed25519 - claudebox --with-ssh - ``` - - Fish: - ```fish - eval (ssh-agent -c) - ssh-add ~/.ssh/id_ed25519 - claudebox --with-ssh - ``` - - If `--with-ssh` is passed but no agent is running, claudebox warns and continues without forwarding. - - ### `--ssh-key ` (explicit key files) - - Mounts a specific private key (and matching `.pub`, if present) read-only into the sandbox at `~/.ssh/`. Repeatable — pass it multiple times for multiple keys. - - ```bash - claudebox --ssh-key ~/.ssh/id_ed25519 - claudebox --ssh-key ~/.ssh/id_work --ssh-key ~/.ssh/id_personal - ``` - - Prefer this when you don't have an agent running, or when you want to scope exactly which keys the sandbox can use regardless of what's loaded in the agent. - - ### known_hosts - - When either flag is active, `~/.ssh/known_hosts` is mounted read-only (if it exists) so SSH host verification works without prompting. - - Both flags can be combined. - ``` - - - grep -q "^## SSH" README.md && grep -q "\-\-with-ssh" README.md && grep -q "\-\-ssh-key" README.md && grep -q "ssh-agent -c" README.md && grep -q "known_hosts" README.md - - - - README.md Flags table lists both new flags. - - README.md has a `## SSH` section with bash + fish agent setup, explicit-key usage, and known_hosts note. - - No broken markdown structure (sections in order, code fences balanced). - - - - - - -End-to-end smoke checks: - -1. `bash -n claudebox.sh` — syntax valid. -2. `nix build` (or `nix flake check`) succeeds — shellcheck via writeShellApplication passes. -3. `claudebox --dry-run` (no SSH flags) — output contains no `--bind $SSH_AUTH_SOCK`, no `~/.ssh` mounts. -4. With agent running: `eval "$(ssh-agent)" && claudebox --dry-run --with-ssh` — output contains `--bind ` and `--setenv SSH_AUTH_SOCK ...`. -5. `claudebox --dry-run --ssh-key ~/.ssh/id_ed25519` (assuming key exists) — output contains `--dir $HOME/.ssh`, `--ro-bind $HOME/.ssh/id_ed25519`, and `.pub` bind if present. -6. `claudebox --dry-run --ssh-key /nonexistent` — exits non-zero with clear error. -7. SANDBOX.md content matches SSH state (verify by inspecting `~/.claudebox/SANDBOX.md` after a dry run with and without flags). -8. README renders correctly (visual or `, glow README.md`). - - - -- All three tasks complete and `` criteria met. -- Both flags work in isolation, together, and respect missing-input failure modes. -- Audit display + dry-run + SANDBOX.md all reflect SSH state consistently. -- README documents the feature for both bash and fish users. -- No regressions: running `claudebox` without any SSH flag behaves exactly as before. - - - -After completion, create `.planning/quick/260504-bw4-add-ssh-support-to-claudebox-with-ssh-fl/260504-bw4-SUMMARY.md`. - diff --git a/.planning/quick/260504-bw4-add-ssh-support-to-claudebox-with-ssh-fl/260504-bw4-SUMMARY.md b/.planning/quick/260504-bw4-add-ssh-support-to-claudebox-with-ssh-fl/260504-bw4-SUMMARY.md deleted file mode 100644 index c68f26a..0000000 --- a/.planning/quick/260504-bw4-add-ssh-support-to-claudebox-with-ssh-fl/260504-bw4-SUMMARY.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -phase: 260504-bw4 -plan: 01 -subsystem: sandbox/ssh -tags: [ssh, bwrap, security, opt-in] -dependency_graph: - requires: [] - provides: [ssh-agent-forwarding, ssh-key-mounts, sandbox-ssh-awareness] - affects: [claudebox.sh, README.md] -tech_stack: - added: [] - patterns: [opt-in SSH via bwrap --bind/--ro-bind, conditional SANDBOX.md generation] -key_files: - modified: - - claudebox.sh - - README.md -decisions: - - SSH is opt-in: no keys or sockets cross the sandbox boundary without explicit flags - - --with-ssh validation: silently degrades to no-op with warning if ssh-agent is not running - - SANDBOX.md uses unquoted heredoc with pre-composed variables for conditional content - - known_hosts mounted once if either SSH mechanism is active (shared between --with-ssh and --ssh-key) -metrics: - duration: 8min - completed: 2026-05-04 - tasks: 3 - files: 2 ---- - -# Quick Task 260504-bw4: Add SSH Support to claudebox Summary - -One-liner: Opt-in SSH via `--with-ssh` (agent socket forwarding) and `--ssh-key` (explicit key file mounts), with audit/dry-run/SANDBOX.md integration and README documentation. - -## Tasks Completed - -| Task | Name | Commit | Files | -|------|------|--------|-------| -| 1 | Implement --with-ssh and --ssh-key flag parsing + bwrap mounts | 41ebf10 | claudebox.sh | -| 2 | Make SANDBOX.md conditional on SSH activation | e9154fd | claudebox.sh | -| 3 | Document SSH support in README.md | b2aeb2f | README.md | - -## What Was Built - -**claudebox.sh** now accepts two new flags: - -- `--with-ssh`: validates `$SSH_AUTH_SOCK` is a real socket, adds `--bind $SSH_AUTH_SOCK $SSH_AUTH_SOCK` and `--setenv SSH_AUTH_SOCK` to bwrap args, degrades gracefully with a warning if no agent is running. -- `--ssh-key `: repeatable, validates file exists+readable, mounts key (and `.pub` if present) read-only into `~/.ssh/` inside the sandbox. -- When either mechanism is active: `--dir ~/.ssh` is added, and `~/.ssh/known_hosts` is mounted read-only if it exists on the host. -- Audit display shows SSH mounts in the Mounts section. -- `--dry-run` output mirrors all SSH bwrap flags. -- SANDBOX.md is now generated conditionally: no-SSH mode lists SSH keys in restrictions and recommends HTTPS; SSH-active mode drops that restriction, adds a `## SSH` section describing which mechanisms are active, and says SSH remotes work. - -**README.md** gains two flag table rows and a `## SSH` section covering both mechanisms, bash/fish agent setup, the agent-lifetime caveat, explicit key usage, and the known_hosts note. - -## Deviations from Plan - -None - plan executed exactly as written. - -## Threat Flags - -No new threat surface introduced. SSH flags are opt-in and explicitly documented. The agent socket bind is scope-limited to `--bind $SSH_AUTH_SOCK $SSH_AUTH_SOCK` (only the socket path the user explicitly opts into). Key files are read-only. - -## Self-Check: PASSED - -- claudebox.sh: FOUND -- README.md: FOUND -- 41ebf10 (Task 1): FOUND -- e9154fd (Task 2): FOUND -- b2aeb2f (Task 3): FOUND diff --git a/README.md b/README.md index 745347c..b727cf5 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,6 @@ Then add `inputs.claudebox.packages.${system}.default` to your `environment.syst | `--check` | Verify prerequisites and exit | | `--shell` | Drop into a bash shell instead of Claude Code | | `--gc` | Remove stale per-project instance dirs and exit | -| `--with-ssh` | Forward $SSH_AUTH_SOCK into the sandbox (requires running ssh-agent) | -| `--ssh-key ` | Mount a private key file read-only into the sandbox ~/.ssh/ (repeatable) | | `--` | Pass remaining args to Claude Code | ## Env vars @@ -67,49 +65,6 @@ CLAUDEBOX_EXTRA_ENV=MY_VAR,OTHER_VAR claudebox All injected vars appear in the `[+]` section of the env audit. -## SSH - -SSH is opt-in. By default no keys or agent socket cross the sandbox boundary, which means git push/pull over SSH remotes won't work. Two mechanisms are available — pick whichever matches your workflow. - -### `--with-ssh` (agent forwarding) - -Forwards `$SSH_AUTH_SOCK` into the sandbox so any keys loaded in your ssh-agent are usable inside. Your private key files are never mounted; only the agent socket is. - -Start an agent before launching claudebox. The agent dies with the shell that started it, so don't expect it to survive across terminals. - -Bash: -```bash -eval "$(ssh-agent)" -ssh-add ~/.ssh/id_ed25519 -claudebox --with-ssh -``` - -Fish: -```fish -eval (ssh-agent -c) -ssh-add ~/.ssh/id_ed25519 -claudebox --with-ssh -``` - -If `--with-ssh` is passed but no agent is running, claudebox warns and continues without forwarding. - -### `--ssh-key ` (explicit key files) - -Mounts a specific private key (and matching `.pub`, if present) read-only into the sandbox at `~/.ssh/`. Repeatable — pass it multiple times for multiple keys. - -```bash -claudebox --ssh-key ~/.ssh/id_ed25519 -claudebox --ssh-key ~/.ssh/id_work --ssh-key ~/.ssh/id_personal -``` - -Prefer this when you don't have an agent running, or when you want to scope exactly which keys the sandbox can use regardless of what's loaded in the agent. - -### known_hosts - -When either flag is active, `~/.ssh/known_hosts` is mounted read-only (if it exists) so SSH host verification works without prompting. - -Both flags can be combined. - ## How it works ``` diff --git a/claudebox.sh b/claudebox.sh index 96bbb73..29650eb 100644 --- a/claudebox.sh +++ b/claudebox.sh @@ -4,10 +4,19 @@ DRY_RUN=false CHECK_MODE=false SHELL_MODE=false GC_MODE=false -WITH_SSH=false -SSH_KEYS=() CLAUDE_ARGS=() +# Config / harness globals (set by config files; CLI overrides applied after config loading) +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 + +# CLI override captures (applied on top of config after loading) +CLI_HARNESS_CMD="" +CLI_MOUNT_HOME=() +CLI_PATH_ADD=() + while (( $# > 0 )); do case "$1" in --yes|-y) SKIP_AUDIT=true ;; @@ -15,12 +24,15 @@ while (( $# > 0 )); do --check) CHECK_MODE=true ;; --shell) SHELL_MODE=true ;; --gc) GC_MODE=true ;; - --with-ssh) WITH_SSH=true ;; - --ssh-key) - shift - [[ $# -gt 0 ]] || { echo "Error: --ssh-key requires a path" >&2; exit 1; } - SSH_KEYS+=("${1/#\~/$HOME}") - ;; + --cmd) + [[ -z "${2:-}" ]] && { echo "Error: --cmd requires a binary name" >&2; exit 1; } + CLI_HARNESS_CMD="$2"; shift ;; + --mount-home) + [[ -z "${2:-}" ]] && { echo "Error: --mount-home requires a subdir name" >&2; exit 1; } + CLI_MOUNT_HOME+=("$2"); shift ;; + --path-add) + [[ -z "${2:-}" ]] && { echo "Error: --path-add requires a directory" >&2; exit 1; } + CLI_PATH_ADD+=("${2/#\~/$HOME}"); shift ;; --) shift; CLAUDE_ARGS+=("$@"); break ;; *) CLAUDE_ARGS+=("$1") ;; esac @@ -28,21 +40,45 @@ while (( $# > 0 )); do done export SKIP_AUDIT # consumed by Plan 02 audit display -# Validate and resolve SSH key paths -for _i in "${!SSH_KEYS[@]}"; do - _key="${SSH_KEYS[$_i]}" - # Expand ~ if not already done - _key="${_key/#\~/$HOME}" - # Make absolute - if [[ "$_key" != /* ]]; then - _key="$PWD/$_key" +# Compute canonical project root — worktree-aware (D-08, INST-02) +# Defined here (near top) so it can be used in --check mode and config loading. +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 - if [[ ! -f "$_key" || ! -r "$_key" ]]; then - echo "Error: --ssh-key path does not exist or is not readable: $_key" >&2 - exit 1 - fi - SSH_KEYS[$_i]="$_key" -done + dirname "$(readlink -f "$git_common")" +} + +# Config file loader — KEY = VALUE format, blank/# lines ignored +load_config_file() { + local file="$1" + [[ -f "$file" ]] || return 0 + CONFIG_FILES_LOADED+=("$file") + while IFS= read -r line || [[ -n "$line" ]]; do + # ltrim + line="${line#"${line%%[! ]*}"}" + [[ -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 "Warning: unknown key '$key' in $file" >&2 ;; + esac + done < "$file" +} # Garbage-collect stale instance directories (D-11, INST-04) gc_instances() { @@ -95,6 +131,19 @@ if [[ "$CHECK_MODE" == true ]]; then echo "${red}FAIL${reset} ~/.claudebox -- not found (will be created on first run)" >&2 fi + 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 + unset _proj_cfg + if [[ -v ANTHROPIC_API_KEY ]]; then echo "${green}OK${reset} ANTHROPIC_API_KEY is set" >&2 else @@ -130,30 +179,6 @@ else BOLD="" RESET="" DIM="" CYAN="" YELLOW="" GREEN="" RED="" fi -# SSH agent validation (must be after ANSI vars are set) -if [[ "$WITH_SSH" == true ]]; then - if [[ -v SSH_AUTH_SOCK && -S "$SSH_AUTH_SOCK" ]]; then - : # agent is running, keep WITH_SSH=true - else - echo "${YELLOW}Warning: --with-ssh given but SSH_AUTH_SOCK is unset or not a socket; agent will not be forwarded.${RESET}" >&2 - WITH_SSH=false - fi -fi - -# Compute SSH active state -if [[ "$WITH_SSH" == true ]] || (( ${#SSH_KEYS[@]} > 0 )); then - SSH_ACTIVE=true -else - SSH_ACTIVE=false -fi - -# Determine if known_hosts should be mounted -if [[ "$SSH_ACTIVE" == true && -f "$HOME/.ssh/known_hosts" ]]; then - KNOWN_HOSTS_MOUNT=true -else - KNOWN_HOSTS_MOUNT=false -fi - # Mask sensitive values (D-04) mask_value() { local name="$1" value="$2" @@ -177,21 +202,6 @@ CLAUDE_BIN="$(command -v claude)" # Record CWD CWD=$(pwd) -# 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")" -} - # Ensure ~/.claudebox exists mkdir -p "$HOME/.claudebox" @@ -208,6 +218,15 @@ fi # Ensure history.jsonl source exists — bwrap bind requires source to exist (D-04) touch "$HOME/.claudebox/history.jsonl" +# Load config files — CANONICAL_ROOT is now available (cascade: global then per-project) +load_config_file "$HOME/.claudebox/config" +load_config_file "$CANONICAL_ROOT/.claudebox" + +# Apply CLI overrides on top of config (CLI wins for scalar, appends for arrays) +[[ -n "$CLI_HARNESS_CMD" ]] && HARNESS_CMD="$CLI_HARNESS_CMD" +(( ${#CLI_MOUNT_HOME[@]} > 0 )) && MOUNT_HOME+=("${CLI_MOUNT_HOME[@]}") +(( ${#CLI_PATH_ADD[@]} > 0 )) && PATH_ADD+=("${CLI_PATH_ADD[@]}") + # Credential file mount (AUTH-01, AUTH-02) # Credential file lives in ~/.claudebox on the host; mounted into sandbox at ~/.claude/.credentials.json CREDS_FILE="$HOME/.claudebox/.credentials.json" @@ -229,40 +248,8 @@ fi # === Sandbox-aware prompting (AWARE-01, AWARE-02) === -# Build SSH-conditional SANDBOX.md content blocks -if [[ "$SSH_ACTIVE" == true ]]; then - _ssh_notes="" - [[ "$WITH_SSH" == true ]] && _ssh_notes+="- ssh-agent socket forwarded via \$SSH_AUTH_SOCK"$'\n' - (( ${#SSH_KEYS[@]} > 0 )) && _ssh_notes+="- Explicit key file(s) mounted read-only at ~/.ssh/"$'\n' - SANDBOX_RESTRICTIONS_BLOCK="## Default Restrictions - -By default, the following are not mounted into the sandbox: -- GPG and age keys (~/.gnupg, age key files) -- Cloud credentials (~/.aws, ~/.config/gcloud) -- Tailscale state - -## SSH - -SSH is available in this session: -${_ssh_notes} -Use \`git push\`/\`git pull\` over SSH normally." - SANDBOX_GIT_TAIL="SSH remotes work in this session." - unset _ssh_notes -else - SANDBOX_RESTRICTIONS_BLOCK="## Default Restrictions - -By default, the following are not mounted into the sandbox: -- SSH keys (~/.ssh) -- GPG and age keys (~/.gnupg, age key files) -- Cloud credentials (~/.aws, ~/.config/gcloud) -- Tailscale state - -If your setup has been customized, some of these may be available." - SANDBOX_GIT_TAIL="For remote operations, prefer HTTPS URLs over SSH since SSH keys are not available by default." -fi - # Write SANDBOX.md -- fully managed, overwritten every launch (D-02) -cat > "$HOME/.claudebox/SANDBOX.md" < "$HOME/.claudebox/SANDBOX.md" << 'SANDBOXEOF' # Sandbox Environment You are running inside a bubblewrap (bwrap) sandbox managed by claudebox. @@ -275,21 +262,30 @@ from the host, with per-project isolation for conversation history. You have two ways to install tools on the fly: **Comma (preferred for quick one-off commands):** -\`, ripgrep\` runs ripgrep without permanent installation. Comma uses +`, ripgrep` runs ripgrep without permanent installation. Comma uses nix-index to find the right package automatically. **Nix shell (for persistent access within the session):** -\`nix shell nixpkgs#python3 -c python3 script.py\` runs a command with +`nix shell nixpkgs#python3 -c python3 script.py` runs a command with a package available. To keep it in your PATH for the session: -\`nix shell nixpkgs#python3\` then use \`python3\` normally. +`nix shell nixpkgs#python3` then use `python3` normally. -${SANDBOX_RESTRICTIONS_BLOCK} +## Default Restrictions + +By default, the following are not mounted into the sandbox: +- SSH keys (~/.ssh) +- GPG and age keys (~/.gnupg, age key files) +- Cloud credentials (~/.aws, ~/.config/gcloud) +- Tailscale state + +If your setup has been customized, some of these may be available. ## Git Your git identity (name and email) is pre-configured from the host. -The \`safe.directory\` setting trusts the mounted working directory. -${SANDBOX_GIT_TAIL} +The `safe.directory` setting trusts the mounted working directory. +For remote operations, prefer HTTPS URLs over SSH since SSH keys +are not available by default. SANDBOXEOF # Generate minimal .gitconfig (D-05) @@ -307,6 +303,29 @@ cat > "$GITCONFIG_TMP" </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 + +# Prepend PATH_ADD entries to SANDBOX_PATH before ENV_ARGS is built (HARNESS-03) +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 + # Parallel display data for env audit (D-01) declare -a AUDIT_SANDBOX_KEYS=() declare -A AUDIT_SANDBOX_VALS=() @@ -361,13 +380,6 @@ for var in "${HOST_ALLOWLIST[@]}"; do fi done -# SSH_AUTH_SOCK: pass into sandbox when agent forwarding is active -if [[ "$WITH_SSH" == true ]]; then - ENV_ARGS+=(--setenv SSH_AUTH_SOCK "$SSH_AUTH_SOCK") - AUDIT_HOST_KEYS+=(SSH_AUTH_SOCK) - AUDIT_HOST_VALS[SSH_AUTH_SOCK]="$SSH_AUTH_SOCK" -fi - # CLAUDEBOX_EXTRA_ENV escape hatch (D-03, comma-separated) if [[ -v CLAUDEBOX_EXTRA_ENV ]]; then IFS=',' read -ra EXTRAS <<< "$CLAUDEBOX_EXTRA_ENV" @@ -409,6 +421,19 @@ load_env_file "$CANONICAL_ROOT/.claudebox.env" # Env audit display (D-01, D-02, D-03, D-04, D-07, UX-01) print_audit() { + # Config section — shown when config files were loaded or a non-default harness is active + if (( ${#CONFIG_FILES_LOADED[@]} > 0 )) || [[ "$IS_DEFAULT_CLAUDE" != true ]]; 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 + echo "${BOLD}${CYAN}=== Sandbox Environment ===${RESET}" >&2 echo "" >&2 @@ -445,22 +470,12 @@ print_audit() { if [[ "$CREDS_MOUNT" == true ]]; then printf ' %-12s %s (read-write)\n' "credentials" "$CREDS_FILE" >&2 fi - if [[ "$SSH_ACTIVE" == true ]]; then - if [[ "$WITH_SSH" == true ]]; then - printf ' %-12s %s (read-write, --with-ssh)\n' "agent" "$SSH_AUTH_SOCK" >&2 - fi - for _key in "${SSH_KEYS[@]}"; do - _base=$(basename "$_key") - printf ' %-12s %s (read-only)\n' "ssh-key" "$_key" >&2 - if [[ -f "${_key}.pub" ]]; then - printf ' %-12s %s (read-only)\n' "ssh-key" "${_key}.pub" >&2 - fi - unset _base - done - if [[ "$KNOWN_HOSTS_MOUNT" == true ]]; then - printf ' %-12s %s (read-only)\n' "known_hosts" "$HOME/.ssh/known_hosts" >&2 - fi - fi + 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 echo "" >&2 @@ -491,8 +506,10 @@ fi # Build sandbox command 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=("$CLAUDE_BIN" --dangerously-skip-permissions "${CLAUDE_ARGS[@]}") + SANDBOX_CMD=("$HARNESS_BIN" "${CLAUDE_ARGS[@]}") fi # --dry-run: print the bwrap command without executing (D-09, UX-04) @@ -536,24 +553,12 @@ if [[ "$DRY_RUN" == true ]]; then if [[ "$CREDS_MOUNT" == true ]]; then echo " --bind $CREDS_FILE $HOME/.claude/.credentials.json \\" fi - if [[ "$SSH_ACTIVE" == true ]]; then - echo " --dir $HOME/.ssh \\" - if [[ "$WITH_SSH" == true ]]; then - echo " --bind $SSH_AUTH_SOCK $SSH_AUTH_SOCK \\" - fi - for _dry_key in "${SSH_KEYS[@]}"; do - _dry_base=$(basename "$_dry_key") - echo " --ro-bind $_dry_key $HOME/.ssh/$_dry_base \\" - if [[ -f "${_dry_key}.pub" ]]; then - echo " --ro-bind ${_dry_key}.pub $HOME/.ssh/${_dry_base}.pub \\" - fi - unset _dry_base - done - if [[ "$KNOWN_HOSTS_MOUNT" == true ]]; then - echo " --ro-bind $HOME/.ssh/known_hosts $HOME/.ssh/known_hosts \\" - fi - fi - unset _dry_key + 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 printf ' --ro-bind %q %s/.gitconfig \\\n' "$GITCONFIG_TMP" "$HOME" echo " --bind $CWD $CWD \\" echo " --chdir $CWD \\" @@ -597,24 +602,15 @@ fi if [[ "$CREDS_MOUNT" == true ]]; then BWRAP_ARGS+=(--bind "$CREDS_FILE" "$HOME/.claude/.credentials.json") fi -if [[ "$SSH_ACTIVE" == true ]]; then - BWRAP_ARGS+=(--dir "$HOME/.ssh") - if [[ "$WITH_SSH" == true ]]; then - BWRAP_ARGS+=(--bind "$SSH_AUTH_SOCK" "$SSH_AUTH_SOCK") +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 - for _key in "${SSH_KEYS[@]}"; do - _base=$(basename "$_key") - BWRAP_ARGS+=(--ro-bind "$_key" "$HOME/.ssh/$_base") - if [[ -f "${_key}.pub" ]]; then - BWRAP_ARGS+=(--ro-bind "${_key}.pub" "$HOME/.ssh/${_base}.pub") - fi - unset _base - done - if [[ "$KNOWN_HOSTS_MOUNT" == true ]]; then - BWRAP_ARGS+=(--ro-bind "$HOME/.ssh/known_hosts" "$HOME/.ssh/known_hosts") - fi -fi -unset _key + BWRAP_ARGS+=(--bind "$_src" "$_src") +done +unset _sub _src BWRAP_ARGS+=( --ro-bind "$GITCONFIG_TMP" "$HOME/.gitconfig" --bind "$CWD" "$CWD"