From e7b893fbb7caa64527772d684fe7774c5f09186f Mon Sep 17 00:00:00 2001 From: Josh Nichols Date: Wed, 3 Jun 2026 16:28:37 -0400 Subject: [PATCH 1/5] feat(claude): add sandbox-git-write guard hook A PreToolUse/Bash hook (bin/claude-sandbox-guard) that denies sandboxed git-write and srb commands up front, telling Claude to retry with dangerouslyDisableSandbox=true instead of eating a cryptic EPERM and retrying. Both classes reliably fail under the command sandbox and are safe to run unsandboxed: - git writes hit .git/worktrees/.../index.lock and the hardcoded blocked paths (.claude/, .vscode/, .gitmodules) inside pt worktrees - sorbet (srb) wants to write its mdb / rubocop cache Read-only git (log/show/diff/status, worktree/stash/submodule list) is left sandboxed, so the common path is untouched. claudeconfig.sh now concatenates hooks. arrays across base + active role. The previous deep merge replaced same-event arrays, which would silently drop a base-level PreToolUse hook whenever the active role also defined PreToolUse (work.jsonc does). Behaviour is locked down by a bats suite (test/): hand-written edge cases (env prefixes, git -C, compound chains, read-form list commands, already-unsandboxed calls) plus a data-driven sweep over 90 real commands pulled from the session corpus. Co-Authored-By: Claude Opus 4.8 (1M context) --- .config/prettier/ignore | 4 + bin/CLAUDE.md | 1 + bin/claude-sandbox-guard | 115 +++++++++++++++++++++++ claude/README.md | 8 +- claude/roles/base.jsonc | 24 +++++ claudeconfig.sh | 24 ++++- package.json | 3 +- test/claude-sandbox-guard.bats | 99 +++++++++++++++++++ test/fixtures/sandbox-guard-corpus.jsonl | 90 ++++++++++++++++++ 9 files changed, 360 insertions(+), 8 deletions(-) create mode 100755 bin/claude-sandbox-guard create mode 100644 test/claude-sandbox-guard.bats create mode 100644 test/fixtures/sandbox-guard-corpus.jsonl diff --git a/.config/prettier/ignore b/.config/prettier/ignore index 177c0f8..6af922a 100644 --- a/.config/prettier/ignore +++ b/.config/prettier/ignore @@ -17,3 +17,7 @@ config/fish/fish_plugins .editorconfig home/.gitconfig config/ghostty/config + +# bats tests (prettier-plugin-sh can't parse @test syntax) + JSONL fixtures +*.bats +*.jsonl diff --git a/bin/CLAUDE.md b/bin/CLAUDE.md index 8769ea7..28af175 100644 --- a/bin/CLAUDE.md +++ b/bin/CLAUDE.md @@ -21,6 +21,7 @@ Spotlight is kept enabled (Alfred requires it) but specific directories are excl ## Claude Code Utilities +- `bin/claude-sandbox-guard`: PreToolUse/Bash hook (wired in `claude/roles/base.jsonc`). Denies sandboxed git-write and `srb` commands with an instruction to retry with `dangerouslyDisableSandbox=true`, since those reliably fail under the sandbox and are safe to run unsandboxed. Read-only git is untouched. - `bin/claude-spend-today`: Read today's Claude spend from ccusage cache (for tmux status bar) - `bin/ccusage-refresh`: Refresh ccusage cache (run by LaunchAgent every 5 min) - `bin/tmux-smart-open`: Open URLs/files on double-click in tmux diff --git a/bin/claude-sandbox-guard b/bin/claude-sandbox-guard new file mode 100755 index 0000000..f347ad3 --- /dev/null +++ b/bin/claude-sandbox-guard @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# +# claude-sandbox-guard: a Claude Code PreToolUse/Bash hook. +# +# Some Bash commands reliably fail under Claude Code's command sandbox and have +# to be retried with dangerouslyDisableSandbox=true. Eating that failure first +# wastes a round-trip and surfaces a cryptic EPERM. This hook denies the +# sandboxed attempt up front with a clear instruction to retry unsandboxed, for +# the two classes that are safe to always run unsandboxed: +# +# - git WRITE subcommands (fail on .git/worktrees/.../index.lock and the +# hardcoded blocked-path set .claude/, .vscode/, .gitmodules inside pt +# worktrees). Read-only git (log/show/diff/status, worktree/stash list, ...) +# is left sandboxed. +# - srb / sorbet (always wants to write its mdb + rubocop cache). +# +# Motivation, frequencies, and the test fixture all come from pickletown +# projects/transcript-mining/findings/2026-06-03-sandbox-friction.md +# +# Contract: reads the PreToolUse JSON on stdin, writes a deny decision to stdout +# (exit 0). Any internal error exits non-zero => non-blocking => the command +# proceeds normally. Fail-open by design: a broken guard must never wedge work. +# +# Tests: test/claude-sandbox-guard.bats (run with `bats test/`). + +set -uo pipefail + +command -v jq > /dev/null 2>&1 || exit 0 # no jq => fail open + +# One jq pass does the gating too: emit the command ONLY for a sandboxed Bash +# call (Bash tool, dangerouslyDisableSandbox not already set); empty otherwise. +cmd="$(jq -r ' + if (.tool_name == "Bash") + and ((.tool_input.dangerouslyDisableSandbox // false) | not) + then .tool_input.command // "" else "" end +' 2> /dev/null)" +[ -n "$cmd" ] || exit 0 + +# git subcommands that always write (worktree/index/refs/network). Subcommands +# with a common read-only form (worktree, stash, submodule) are handled below. +GIT_ALWAYS_WRITE=" add commit rebase merge cherry-pick revert rm mv restore reset pull fetch push am apply clean checkout switch clone init " + +# Does one already-separated command segment invoke a git WRITE? +git_write_segment() { + local -a toks + read -ra toks <<< "$1" + [ "${toks[0]:-}" = "git" ] || return 1 + + # walk past git's global options to the subcommand (+ its first argument) + local i=1 sub="" subsub="" t + while [ "$i" -lt "${#toks[@]}" ]; do + t="${toks[$i]}" + case "$t" in + -C | -c | --git-dir | --work-tree | --namespace | --super-prefix) + i=$((i + 2)) + continue + ;; + --git-dir=* | --work-tree=* | --namespace=*) + i=$((i + 1)) + continue + ;; + -*) + i=$((i + 1)) + continue + ;; + *) + sub="$t" + subsub="${toks[$((i + 1))]:-}" + break + ;; + esac + done + [ -n "$sub" ] || return 1 + + if [[ "$GIT_ALWAYS_WRITE" == *" $sub "* ]]; then + return 0 + fi + case "$sub" in + worktree) case "$subsub" in add | remove | move | prune | repair | lock | unlock) return 0 ;; esac ;; + stash) case "$subsub" in list | show) return 1 ;; *) return 0 ;; esac ;; # bare `stash` == push + submodule) case "$subsub" in add | update | init | deinit | sync | set-url | set-branch | foreach) return 0 ;; esac ;; + esac + return 1 +} + +deny() { + jq -n --arg reason "$1" '{ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: $reason + } + }' + exit 0 +} + +# Split on shell separators so each invocation sits on its own line +# (`cd x && git add .` -> two lines; pipelines, subshells likewise). Strip +# leading env assignments (FOO=bar git ...), then inspect each segment. +# `|| [ -n "$seg" ]` so the final newline-less segment is not dropped by read. +while IFS= read -r seg || [ -n "$seg" ]; do + seg="${seg#"${seg%%[![:space:]]*}"}" # ltrim + seg="$(printf '%s' "$seg" | sed -E 's/^([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+)+//')" + [ -n "$seg" ] || continue + + if [[ "$seg" =~ ^(bundle[[:space:]]+exec[[:space:]]+)?(\./)?(bin/)?srb([[:space:]]|$) ]]; then + deny "sorbet (srb) writes its mdb / rubocop cache, which the sandbox blocks (it fails ~90% of the time sandboxed). Re-run this command with dangerouslyDisableSandbox: true." + fi + + if git_write_segment "$seg"; then + deny "Sandboxed git-write commands fail inside pt worktrees (.git/worktrees/.../index.lock and the blocked .claude/.vscode/.gitmodules paths). git writes are safe to run unsandboxed. Re-run this command with dangerouslyDisableSandbox: true." + fi +done < <(printf '%s' "$cmd" | tr ';|&()\n' '\n') + +exit 0 diff --git a/claude/README.md b/claude/README.md index 03acdd4..48adb3e 100644 --- a/claude/README.md +++ b/claude/README.md @@ -93,10 +93,10 @@ All keys are optional. A stack can have only `permissions`, only `sandbox`, or b When you run `./claudeconfig.sh`: -1. **Base role** (`roles/base.jsonc`): settings, permissions, and sandbox extracted -2. **Active role** (`roles/$ROLE.jsonc`): settings deep-merged on top of base. Permissions and sandbox arrays concatenated (not deep-merged, which would replace arrays) -3. **Stacks** (`stacks/*.jsonc`, sorted alphabetically): permissions and sandbox arrays concatenated -4. **Deduplication**: all arrays sorted and deduplicated +1. **Base role** (`roles/base.jsonc`): settings, permissions, sandbox, and hooks extracted +2. **Active role** (`roles/$ROLE.jsonc`): settings deep-merged on top of base. Permissions and sandbox arrays concatenated (not deep-merged, which would replace arrays). `hooks.` arrays are also concatenated (base hooks first, then role), so a base-level hook survives even when a role defines the same event +3. **Stacks** (`stacks/*.jsonc`, sorted alphabetically): permissions and sandbox arrays concatenated (stacks do not carry `hooks`; those live in roles only) +4. **Deduplication**: permission and sandbox arrays sorted and deduplicated (hooks are left in order, not deduped) 5. **Local keys**: `enabledPlugins`, `extraKnownMarketplaces` preserved from existing `~/.claude/settings.json` 6. **Validation and write** diff --git a/claude/roles/base.jsonc b/claude/roles/base.jsonc index db5b341..ca2ac8d 100644 --- a/claude/roles/base.jsonc +++ b/claude/roles/base.jsonc @@ -12,6 +12,30 @@ // session logs "showThinkingSummaries": true, + // Hooks. claudeconfig.sh concatenates hooks. arrays across base + + // active role, so base-level hooks survive even when a role also defines the + // same event (e.g. work.jsonc's PreToolUse/Skill tracking hook). + "hooks": { + // Deny sandboxed git-write and srb commands up front, telling Claude to + // retry with dangerouslyDisableSandbox=true instead of eating the EPERM and + // retrying. These two classes reliably fail under the sandbox (git writes + // hit .git/worktrees index.lock + blocked .claude/.vscode paths in pt + // worktrees; sorbet wants its mdb/rubocop cache) and are safe unsandboxed. + // Read-only git (log/show/diff/status) is left sandboxed. See pickletown + // projects/transcript-mining/findings/2026-06-03-sandbox-friction.md. + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "~/.pickles/bin/claude-sandbox-guard", + }, + ], + }, + ], + }, + // Permissions: base safety rules "permissions": { "defaultMode": "acceptEdits", diff --git a/claudeconfig.sh b/claudeconfig.sh index 0e31366..a0bc151 100755 --- a/claudeconfig.sh +++ b/claudeconfig.sh @@ -115,9 +115,15 @@ generate_settings() { local base_json base_json=$(read_json "$base_role") - # Extract settings (everything except permissions and sandbox) + # Extract settings (everything except permissions, sandbox, and hooks) local merged_settings - merged_settings=$(echo "$base_json" | jq 'del(.permissions, .sandbox)') + merged_settings=$(echo "$base_json" | jq 'del(.permissions, .sandbox, .hooks)') + + # Hooks are concatenated per-event across base + role, NOT deep-merged. A deep + # merge (jq *) replaces same-event arrays, so a base-level PreToolUse hook + # would be silently dropped whenever the active role also defines PreToolUse. + local merged_hooks + merged_hooks=$(echo "$base_json" | jq '.hooks // {}') # Extract permissions arrays from base local merged_allow merged_ask merged_deny @@ -145,9 +151,16 @@ generate_settings() { # Deep merge settings keys (role overrides base) local role_settings - role_settings=$(echo "$role_json" | jq 'del(.permissions, .sandbox)') + role_settings=$(echo "$role_json" | jq 'del(.permissions, .sandbox, .hooks)') merged_settings=$(echo "$merged_settings" | jq --argjson role "$role_settings" '. * $role') + # Concat hooks per-event arrays (role appended after base, no dedup: hook + # entries are objects and order is meaningful) + merged_hooks=$(jq -n \ + --argjson a "$merged_hooks" \ + --argjson b "$(echo "$role_json" | jq '.hooks // {}')" \ + 'reduce (($a, $b) | keys_unsorted[]) as $k ({}; .[$k] = (($a[$k] // []) + ($b[$k] // [])))') + # Concat permissions arrays (not deep merge, which would replace) merged_allow=$(echo "$merged_allow" | jq --argjson r "$(echo "$role_json" | jq '.permissions.allow // []')" '. + $r') merged_ask=$(echo "$merged_ask" | jq --argjson r "$(echo "$role_json" | jq '.permissions.ask // []')" '. + $r') @@ -215,6 +228,11 @@ generate_settings() { }) }') + # Inject merged hooks (only when any are defined) + if [ "$(echo "$merged_hooks" | jq 'length')" -gt 0 ]; then + final_settings=$(echo "$final_settings" | jq --argjson hooks "$merged_hooks" '. + {hooks: $hooks}') + fi + # Merge in local-only settings final_settings=$(echo "$final_settings" | jq --argjson local "$local_settings" '. * $local') diff --git a/package.json b/package.json index 9ae5258..57b7ee2 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "format": "bin/prettier --write .", "format:check": "bin/prettier --check .", "lint": "npm run typecheck && npm run format:check", - "test": "npm run lint" + "test:bats": "bats test/", + "test": "npm run lint && npm run test:bats" }, "devDependencies": { "adr-tools": "^2.0.4", diff --git a/test/claude-sandbox-guard.bats b/test/claude-sandbox-guard.bats new file mode 100644 index 0000000..dd074e2 --- /dev/null +++ b/test/claude-sandbox-guard.bats @@ -0,0 +1,99 @@ +#!/usr/bin/env bats +# +# Tests for bin/claude-sandbox-guard (the PreToolUse/Bash sandbox hook). +# Run: bats test/ (bats-core is on the mise toolchain) +# +# Two layers: +# 1. Hand-written edge cases that pin the tricky parsing behaviour. +# 2. A data-driven sweep over real commands pulled from the session corpus +# (test/fixtures/sandbox-guard-corpus.jsonl), labelled by intended decision. + +GUARD="${BATS_TEST_DIRNAME}/../bin/claude-sandbox-guard" + +# decision_for [unsandboxed] +# echoes DENY or ALLOW. The hook only ever emits a deny decision, so non-empty +# output == DENY, empty == ALLOW (normal permission flow). +decision_for() { + local json out + if [ "${2:-}" = "unsandboxed" ]; then + json=$(jq -nc --arg c "$1" '{tool_name:"Bash",tool_input:{command:$c,dangerouslyDisableSandbox:true}}') + else + json=$(jq -nc --arg c "$1" '{tool_name:"Bash",tool_input:{command:$c}}') + fi + out=$(printf '%s' "$json" | "$GUARD") + if [ -z "$out" ]; then echo ALLOW; else echo DENY; fi +} + +assert_deny() { [ "$(decision_for "$1" "${2:-}")" = DENY ] || { echo "expected DENY: $1"; return 1; }; } +assert_allow() { [ "$(decision_for "$1" "${2:-}")" = ALLOW ] || { echo "expected ALLOW: $1"; return 1; }; } + +# --- git writes: should DENY ------------------------------------------------- + +@test "git add is denied" { assert_deny "git add ."; } +@test "git commit is denied" { assert_deny 'git commit -m "fix"'; } +@test "git push is denied" { assert_deny "git push -u origin feature"; } +@test "git push with redirect is denied" { assert_deny "git push 2>&1 | tail -10"; } +@test "git worktree add is denied" { assert_deny "git worktree add ../foo"; } +@test "git stash (bare == push) is denied" { assert_deny "git stash"; } +@test "git stash push is denied" { assert_deny "git stash push -m wip"; } +@test "git submodule update is denied" { assert_deny "git submodule update --init"; } +@test "cd then git commit (compound) is denied" { assert_deny "cd /some/path && git commit -m x"; } +@test "git -C push is denied" { assert_deny "git -C /repo push origin main"; } +@test "git -c k=v commit is denied" { assert_deny "git -c user.email=a@b.com commit -m x"; } +@test "git --no-pager add is denied" { assert_deny "git --no-pager add ."; } +@test "leading env assignment is denied" { assert_deny "FOO=1 git checkout -b new"; } +@test "commit message containing the word add is still denied" { assert_deny 'git commit -m "add stuff"'; } +@test "chained add && commit && push is denied" { assert_deny "git add -A && git commit -m x && git push"; } + +# --- srb: should DENY -------------------------------------------------------- + +@test "srb tc is denied" { assert_deny "srb tc"; } +@test "bin/srb is denied" { assert_deny "bin/srb tc"; } +@test "bundle exec srb is denied" { assert_deny "bundle exec srb tc"; } + +# --- reads / non-git / ambiguous list-forms: should ALLOW -------------------- + +@test "git log is allowed" { assert_allow "git log --oneline -5"; } +@test "git show is allowed" { assert_allow "git show HEAD"; } +@test "git status is allowed" { assert_allow "git status -sb"; } +@test "git diff is allowed" { assert_allow "git diff --stat"; } +@test "git branch list is allowed" { assert_allow "git branch -a"; } +@test "git config get is allowed" { assert_allow "git config user.email"; } +@test "git worktree list is allowed (read-only)" { assert_allow "git worktree list"; } +@test "git stash list is allowed (read-only)" { assert_allow "git stash list"; } +@test "git submodule status is allowed (read-only)" { assert_allow "git submodule status"; } +@test "plain ls is allowed" { assert_allow "ls -la"; } +@test "echo is allowed" { assert_allow "echo hi"; } +@test "rspec (not srb) is allowed" { assert_allow "bundle exec rspec"; } +@test "grep for the string git add is allowed" { assert_allow 'grep -rn "git add" .'; } +@test "cd then git status is allowed" { assert_allow "cd /p && git status"; } + +# --- gating: already-unsandboxed and non-Bash tools ALLOW (no decision) ------ + +@test "git add already unsandboxed is not re-flagged" { assert_allow "git add ." unsandboxed; } + +@test "non-Bash tool is ignored" { + out=$(jq -nc '{tool_name:"Edit",tool_input:{file_path:"/x"}}' | "$GUARD") + [ -z "$out" ] +} + +# --- data-driven sweep over the real session corpus -------------------------- + +@test "real corpus: every labelled command gets the intended decision" { + local fixture="${BATS_TEST_DIRNAME}/fixtures/sandbox-guard-corpus.jsonl" + [ -f "$fixture" ] || skip "fixture missing" + local fails=0 total=0 line expect cmd got + while IFS= read -r line; do + [ -n "$line" ] || continue + expect=$(jq -r '.expect' <<<"$line") + cmd=$(jq -r '.command' <<<"$line") + got=$(decision_for "$cmd") + total=$((total + 1)) + if [ "$got" != "$expect" ]; then + echo "MISMATCH want=$expect got=$got :: $cmd" + fails=$((fails + 1)) + fi + done <"$fixture" + echo "checked $total corpus commands, $fails mismatches" + [ "$fails" -eq 0 ] +} diff --git a/test/fixtures/sandbox-guard-corpus.jsonl b/test/fixtures/sandbox-guard-corpus.jsonl new file mode 100644 index 0000000..b356ccf --- /dev/null +++ b/test/fixtures/sandbox-guard-corpus.jsonl @@ -0,0 +1,90 @@ +{"expect": "DENY", "command": "git add src/opt/gusto/etc/scope/shared/known-errors/docker/colima-docker-socket-eof.yaml src/opt/gusto/etc/scope/shared/known-errors/docker/colima-docker-socket-eof.txt src/opt/gusto/etc/scope/shared/known-errors/docker/colima-ssh-broken-pipe.yaml src/opt/gusto/etc/scope/shared/known-errors/docker/colima-ssh-broken-pipe.txt src/opt/gusto/etc/scope/shared/known-errors/kafka/kafka-api-version-timeout.yaml src/opt/gusto/etc/scope/shared/known-errors/kafka/kafka-api-version-timeout.txt test/known-errors/docker.bats test/known-errors/kafka.bats && git status --short"} +{"expect": "DENY", "command": "git push -u origin colima-and-rdkafka-known-errors 2>&1 | tail -10"} +{"expect": "DENY", "command": "cd /Users/josh.nichols/pickleton/repos/plannotator/bare.git && git worktree list 2>&1; echo \"---prune---\"; git worktree prune 2>&1; echo \"done\""} +{"expect": "DENY", "command": "git worktree add /Users/josh.nichols/pickleton/repos/plannotator/worktrees/main main 2>&1 | tail -10"} +{"expect": "DENY", "command": "git add projects/agent-orchestration-survey/BRIEFING.md projects/agent-orchestration-survey/FRAMING.md projects/agent-orchestration-survey/PRIMER.md projects/agent-orchestration-survey/PATTERNS.md projects/agent-orchestration-survey/docs/ && git status --short -- projects/agent-orchestration-survey/"} +{"expect": "DENY", "command": "cd /Users/josh.nichols/pickleton && git push 2>&1 | tail -15"} +{"expect": "DENY", "command": "git push origin main 2>&1 | tail -10"} +{"expect": "DENY", "command": "cd repos/pinwheel/worktrees/main && git pull --ff-only"} +{"expect": "DENY", "command": "git -C /Users/josh.nichols/pickleton/repos/pinwheel/worktrees/main pull --ff-only"} +{"expect": "DENY", "command": "git -C /Users/josh.nichols/pickleton/repos/pinwheel/worktrees/pipeline-env-keys push -u origin pipeline-env-keys"} +{"expect": "DENY", "command": "git -C /Users/josh.nichols/pickleton/repos/pinwheel/worktrees/pipeline-env-keys push"} +{"expect": "DENY", "command": "git checkout -b team-hud/horizon-holidays && git status --short"} +{"expect": "DENY", "command": "git add team-hud/src/config.js team-hud/src/fetch.js team-hud/src/horizon.js team-hud/src/render-horizon.js team-hud/src/state.js team-hud/team.yml team-hud/test/horizon.test.js && git status --short"} +{"expect": "DENY", "command": "git add src/config.js src/fetch.js src/horizon.js src/render-horizon.js src/state.js team.yml test/horizon.test.js && git status --short"} +{"expect": "DENY", "command": "git push -u origin team-hud/horizon-holidays 2>&1"} +{"expect": "DENY", "command": "git checkout main && git status --short && git rev-parse --abbrev-ref HEAD"} +{"expect": "DENY", "command": "git add projects/team-time-tools-playground/design/2026-05-21-agent-orchestration-survey-design.md && git status"} +{"expect": "DENY", "command": "git mv projects/team-time-tools-playground projects/agent-orchestration-survey && mkdir -p projects/agent-orchestration-survey/frameworks projects/agent-orchestration-survey/playground && cd projects/agent-orchestration-survey && git mv FINDINGS.md frameworks/team-time-tools.md && git mv playground.html playground/playground.html && git mv extract.py playground/extract.py"} +{"expect": "DENY", "command": "git add projects/agent-orchestration-survey/handoffs projects/agent-orchestration-survey/frameworks/team-time-tools.md beans/gt-tcai--agent-orchestration-survey.md && git status | head -25"} +{"expect": "DENY", "command": "git add projects/agent-orchestration-survey/frameworks/speckit.md && git status --short projects/agent-orchestration-survey/frameworks/"} +{"expect": "DENY", "command": "git -C /Users/josh.nichols/pickleton/repos/bowerbird/worktrees/main add docs/bmad/implementation-artifacts/sprint-status.yaml docs/bmad/planning-artifacts/epics.md docs/bmad/planning-artifacts/sprint-change-proposal-2026-05-27.md docs/bmad/planning-artifacts/sprint-change-proposal-2026-05-27-pid-liveness.md docs/bmad/planning-artifacts/sprint-change-proposal-2026-05-27-epic-5-resequencing.md docs/protocol-changelog.md && git -C /Users/josh.nichols/pickleton/repos/bowerbird/worktrees/main status"} +{"expect": "DENY", "command": "cd /Users/josh.nichols/pickleton/repos/switchboard/worktrees/adr-002-extending-get-deployment-status && git worktree remove ../adr-002-pr1-is-paused-foundation 2>&1"} +{"expect": "DENY", "command": "cd /Users/josh.nichols/pickleton/repos/switchboard/worktrees/adr-002-extending-get-deployment-status && git push origin --delete adr-002-pr1-is-paused-foundation 2>&1"} +{"expect": "DENY", "command": "git push origin --delete adr-002-pr1-is-paused-foundation 2>&1"} +{"expect": "DENY", "command": "git push -u origin extract-is-ci-failing 2>&1"} +{"expect": "DENY", "command": "git push -u origin extract-is-ci-failing 2>&1 | tail -25"} +{"expect": "DENY", "command": "git push -u origin add-get-merge-status 2>&1 | tail -5"} +{"expect": "DENY", "command": "cd /Users/josh.nichols/pickleton/repos/switchboard/worktrees/extend-get-deployment-status && git add plugins/gusto-deployment-backend/src/actions/getDeploymentStatus.ts plugins/gusto-deployment-backend/src/actions/getDeploymentStatus.test.ts plugins/gusto-deployment-backend/src/plugin.ts plugins/gusto-deployment-backend/src/services/GustoDeploymentApiService.ts"} +{"expect": "DENY", "command": "cd /Users/josh.nichols/pickleton/repos/switchboard/worktrees/extend-get-deployment-status && git push -u origin extend-get-deployment-status > /tmp/push-output.txt 2>&1; echo \"PUSH EXIT=$?\"; cat /tmp/push-output.txt"} +{"expect": "DENY", "command": "cd /Users/josh.nichols/pickleton/repos/switchboard/worktrees/add-get-merge-status && git push 2>&1 | tail -3"} +{"expect": "DENY", "command": "cd /Users/josh.nichols/pickleton/repos/switchboard/worktrees/extend-get-deployment-status && git push -u origin extend-get-deployment-status 2>&1; echo \"EXIT=$?\""} +{"expect": "DENY", "command": "cd /Users/josh.nichols/pickleton/repos/switchboard/worktrees/extend-get-deployment-status && git push -u origin extend-get-deployment-status > /tmp/push2.txt 2>&1; echo \"EXIT=$?\"; cat /tmp/push2.txt"} +{"expect": "DENY", "command": "cd /Users/josh.nichols/pickleton/repos/switchboard/worktrees/extend-get-deployment-status && git fetch origin extend-get-deployment-status 2>&1; echo \"FETCH EXIT=$?\"; git log --oneline origin/extend-get-deployment-status 2>/dev/null | head -3 || echo \"remote branch not found yet\""} +{"expect": "DENY", "command": "cd /Users/josh.nichols/pickleton/repos/switchboard/worktrees/extend-get-deployment-status && NODE_OPTIONS=\"--max-old-space-size=8192\" git push -u origin extend-get-deployment-status > /tmp/push-direct.txt 2>&1; echo \"PUSH_EXIT=$?\"; cat /tmp/push-direct.txt | tail -20"} +{"expect": "DENY", "command": "git push 2>&1 | tail -10"} +{"expect": "DENY", "command": "git push 2>&1; echo \"EXIT:$?\""} +{"expect": "DENY", "command": "cd /Users/josh.nichols/pickleton/repos/switchboard/worktrees/add-get-merge-status && pwd && git rev-parse HEAD && git log --oneline -3 && echo \"---FETCH---\" && git fetch origin add-get-merge-status 2>&1 | tail -3 && echo \"---REMOTE---\" && git rev-parse origin/add-get-merge-status"} +{"expect": "DENY", "command": "git push origin add-get-merge-status 2>&1 | tail -5; echo \"EXIT:$?\""} +{"expect": "DENY", "command": "git log --oneline origin/main..HEAD 2>&1; echo \"---\"; git push 2>&1 | tail -3"} +{"expect": "DENY", "command": "git push 2>&1 | tail -5; echo \"EXIT:$?\""} +{"expect": "DENY", "command": "git push 2>&1 | tail -30"} +{"expect": "DENY", "command": "git push 2>&1 | grep -E \"^(remote|error|fatal|To |!|rejected|^\\\\s*[a-f0-9]+\\\\.\\\\.[a-f0-9]+)\" | head -10; echo \"---\"; git push 2>&1 | tail -10"} +{"expect": "DENY", "command": "yarn prettier --write docs/code-reviews/2026-05-07-pr-3221-extend-get-deployment-status.md 2>&1 | tail -3; echo \"---\"; git push 2>&1 | tail -3; echo \"EXIT:$?\""} +{"expect": "DENY", "command": "git switch -c story/5-6-deck-drop-reaction-parenthetical && git add src/index.ts && git status --short"} +{"expect": "DENY", "command": "git add projects/agent-orchestration-survey/FRAMING.md projects/agent-orchestration-survey/README.md projects/agent-orchestration-survey/BRIEFING.md projects/agent-orchestration-survey/handoffs/2026-05-26-v2-iterate-or-share-decision.md && git status --short projects/agent-orchestration-survey/"} +{"expect": "ALLOW", "command": "cd /Users/josh.nichols/pickleton/repos/gusto-karafka/worktrees/devcontainer-scope-experiment && git log --oneline -20 2>&1 | head -30 && echo \"---STATUS---\" && git status --short 2>&1 | head -30"} +{"expect": "ALLOW", "command": "git status --short && echo \"---\" && git log --oneline -5"} +{"expect": "ALLOW", "command": "git branch --show-current"} +{"expect": "ALLOW", "command": "cd /Users/josh.nichols/pickleton/repos/gusto-scope/worktrees/colima-and-rdkafka-known-errors && pwd && git log --oneline -3"} +{"expect": "ALLOW", "command": "git status --short && echo \"---\" && ls bin/"} +{"expect": "ALLOW", "command": "git log --oneline -10"} +{"expect": "ALLOW", "command": "git log --oneline -3 && echo \"---\" && git status --short"} +{"expect": "ALLOW", "command": "echo \"SID=${CLAUDE_SESSION_ID:-unknown}\"; echo \"cwd=$(pwd)\"; echo \"branch=$(git -C /Users/josh.nichols/pickleton rev-parse --abbrev-ref HEAD 2>/dev/null)\""} +{"expect": "ALLOW", "command": "git show b0496bd --stat 2>/dev/null | head -30; echo \"---LIGHTNING TALK FILES---\"; find projects/agent-orchestration-survey -iname '*lightning*' -o -iname '*talk*' 2>/dev/null"} +{"expect": "ALLOW", "command": "cd /Users/josh.nichols/pickleton && git status --short -- projects/agent-orchestration-survey/"} +{"expect": "ALLOW", "command": "git log --oneline -1 && git status --short -- projects/agent-orchestration-survey/"} +{"expect": "ALLOW", "command": "cd /Users/josh.nichols/pickleton && ./projects/agent-orchestration-survey/bin/survey lint; echo \"---\"; git status --short -- projects/agent-orchestration-survey/"} +{"expect": "ALLOW", "command": "cd /Users/josh.nichols/pickleton/repos/ai-devtools/worktrees/main/team-hud && git status && echo \"---BRANCH---\" && git branch --show-current && echo \"---RECENT---\" && git log --oneline -5"} +{"expect": "ALLOW", "command": "cd /Users/josh.nichols/pickleton && git remote -v 2>&1 | head -3"} +{"expect": "ALLOW", "command": "cd /Users/josh.nichols/pickleton/repos/ai-devtools/worktrees/main/team-hud && git status && echo \"---DIFF---\" && git diff --stat"} +{"expect": "ALLOW", "command": "git diff README.md | head -40"} +{"expect": "ALLOW", "command": "git log --oneline -3 origin/main"} +{"expect": "ALLOW", "command": "cd repos/pinwheel/worktrees/main && git status && echo \"---\" && git log --oneline -5"} +{"expect": "ALLOW", "command": "git -C /Users/josh.nichols/pickleton/repos/pinwheel/worktrees/pipeline-env-keys diff .buildkite/pipeline.rb"} +{"expect": "ALLOW", "command": "git -C /Users/josh.nichols/pickleton/repos/pinwheel/worktrees/pipeline-env-keys status && echo \"---log---\" && git -C /Users/josh.nichols/pickleton/repos/pinwheel/worktrees/pipeline-env-keys log --oneline origin/main..HEAD"} +{"expect": "ALLOW", "command": "git -C /Users/josh.nichols/pickleton/repos/pinwheel/worktrees/pipeline-env-keys log --all --oneline -p .buildkite/pipeline.rb 2>&1 | grep -B 2 -A 5 \"Danger\\|danger\\|pull_request\" | head -60"} +{"expect": "ALLOW", "command": "git -C /Users/josh.nichols/pickleton/repos/pinwheel/worktrees/pipeline-env-keys log --all --oneline -S \"build.pull_request.id\" .buildkite/pipeline.rb 2>&1 | head -5"} +{"expect": "ALLOW", "command": "git -C /Users/josh.nichols/pickleton/repos/pinwheel/worktrees/pipeline-env-keys show 0129b2d --stat 2>&1 | head -10; echo \"---commit msg---\"; git -C /Users/josh.nichols/pickleton/repos/pinwheel/worktrees/pipeline-env-keys log -1 --format=\"%s%n%n%b\" 0129b2d 2>&1"} +{"expect": "ALLOW", "command": "cd /Users/josh.nichols/pickleton/repos/dotfiles/worktrees/main && ls && git status -s 2>&1 | head -20"} +{"expect": "ALLOW", "command": "git config --global user.email; echo \"---\"; git config --global include.path 2>&1; ls ~/.gitconfig.d/ 2>/dev/null; echo \"---\"; cat ~/.gitconfig.local 2>/dev/null | head -20"} +{"expect": "ALLOW", "command": "cd /Users/josh.nichols/pickleton/repos/gdev-wish/worktrees/wish-app-v1-cli-https/ 2>&1 && git status 2>&1 | head -10 && git log --oneline -3 2>&1"} +{"expect": "ALLOW", "command": "cd /Users/josh.nichols/pickleton/repos/pinwheel/worktrees/wish-app-v1-controller/ 2>&1 && git status 2>&1 | head -10 && echo \"---\" && git log --oneline -5 2>&1"} +{"expect": "ALLOW", "command": "git log --oneline --all -n 30 -- src/horizon.js src/render-horizon.js team.yml"} +{"expect": "ALLOW", "command": "git log --oneline --all -n 20 | head -25"} +{"expect": "ALLOW", "command": "git status --short && echo \"--- branch:\" && git rev-parse --abbrev-ref HEAD && echo \"--- worktree:\" && git rev-parse --show-toplevel"} +{"expect": "ALLOW", "command": "pt sessions unpark --from 9db4988b-97f4-4314-8ecd-305425c4894d --handoff /Users/josh.nichols/pickleton/docs/handoffs/2026-05-21-cristina-react-render-error-kafka-scope.md"} +{"expect": "ALLOW", "command": "ls /Users/josh.nichols/pickleton/repos/gusto-karafka/worktrees/ 2>/dev/null && echo \"---\" && ls /Users/josh.nichols/pickleton/repos/zenpayroll/worktrees/main/tmp/cristina-react-render-error/ 2>/dev/null"} +{"expect": "ALLOW", "command": "ls -lt /Users/josh.nichols/pickleton/docs/handoffs/ 2>/dev/null | head -15 && echo \"---\" && find /Users/josh.nichols/pickleton/repos/gusto-karafka/worktrees/devcontainer-scope-experiment -maxdepth 3 -name \"*.scope*\" -o -name \"scope-linux*\" -o -name \".devcontainer*\" 2>/dev/null | head -20"} +{"expect": "ALLOW", "command": "ls .scope/ 2>/dev/null && echo \"---\" && ls .devcontainer/ 2>/dev/null"} +{"expect": "ALLOW", "command": "ls .scope/linux/ 2>&1 && echo \"---\" && cat .scope/gusto-karafka.yaml 2>&1"} +{"expect": "ALLOW", "command": "cd /Users/josh.nichols/pickleton/repos/zenpayroll/worktrees/main && for f in tmp/cristina-react-render-error/proposed-known-errors/*.yaml; do echo \"=== $f ===\"; cat \"$f\"; echo; done"} +{"expect": "ALLOW", "command": "cd /Users/josh.nichols/pickleton/repos/gusto-scope/worktrees/main && ls src/opt/gusto/etc/scope/shared/known-errors/docker/ && echo \"---\" && ls src/opt/gusto/etc/scope/shared/known-errors/kafka/ && echo \"---bats---\" && ls test/known-errors/"} +{"expect": "ALLOW", "command": "cat src/opt/gusto/etc/scope/shared/known-errors/kafka/local-broker-transport-error.yaml && echo \"---KAFKA TXT---\" && cat src/opt/gusto/etc/scope/shared/known-errors/kafka/local-broker-transport-error.txt && echo \"---KAFKA BATS---\" && cat test/known-errors/kafka.bats"} +{"expect": "ALLOW", "command": "cat src/opt/gusto/etc/scope/shared/known-errors/docker/colima-corrupt-network.yaml && echo \"---DOCKER TXT---\" && cat src/opt/gusto/etc/scope/shared/known-errors/docker/colima-corrupt-network.txt && echo \"---DOCKER BATS---\" && cat test/known-errors/docker.bats && echo \"---HELPERS---\" && cat test/known-errors/known-error-helpers.bash"} +{"expect": "ALLOW", "command": "cat test/known-errors/karafka.bats && echo \"---\" && ls src/opt/gusto/etc/scope/shared/known-errors/karafka/ 2>&1"} +{"expect": "ALLOW", "command": "cd /Users/josh.nichols/pickleton && pt checkout gusto-scope -b colima-and-rdkafka-known-errors --from origin/main 2>&1 | tail -20"} +{"expect": "ALLOW", "command": "pt checkout --help 2>&1 | head -40"} +{"expect": "ALLOW", "command": "pt checkout gusto-scope colima-and-rdkafka-known-errors --create --base origin/main 2>&1 | tail -20"} +{"expect": "ALLOW", "command": "cd /Users/josh.nichols/pickleton/repos/zenpayroll/worktrees/main && grep -n 'docker\\.sock' tmp/cristina-react-render-error/backend-2026-05-20.txt | head -3 && echo \"---\" && grep -n 'Broken pipe' tmp/cristina-react-render-error/backend-2026-05-20.txt | head -3 && echo \"---\" && grep -n 'broker version' tmp/cristina-react-render-error/backend-2026-05-20.txt | head -3"} +{"expect": "ALLOW", "command": "grep -rn 'docker\\.sock\\|Broken pipe\\|broker version' tmp/cristina-react-render-error/*.log tmp/cristina-react-render-error/*.md 2>&1 | head -20"} From c93298af1862cae160844b32ada5835a2342be23 Mon Sep 17 00:00:00 2001 From: Josh Nichols Date: Fri, 5 Jun 2026 12:21:44 -0400 Subject: [PATCH 2/5] docs(claude): record why git/srb cannot be sandbox-allowlisted Capture the verified rationale in the hook header: git writes hit a hardcoded path deny (not overridable by allowWrite) and srb fails on LMDB's writable mmap (a syscall deny, not a path the cache dir is already writable inside cwd). Both ruled out allowlisting by testing, so unsandboxing is the only fix. Also makes the srb deny message accurate. Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/claude-sandbox-guard | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/bin/claude-sandbox-guard b/bin/claude-sandbox-guard index f347ad3..6e3b70c 100755 --- a/bin/claude-sandbox-guard +++ b/bin/claude-sandbox-guard @@ -5,14 +5,26 @@ # Some Bash commands reliably fail under Claude Code's command sandbox and have # to be retried with dangerouslyDisableSandbox=true. Eating that failure first # wastes a round-trip and surfaces a cryptic EPERM. This hook denies the -# sandboxed attempt up front with a clear instruction to retry unsandboxed, for -# the two classes that are safe to always run unsandboxed: +# sandboxed attempt up front with a clear instruction to retry unsandboxed. # -# - git WRITE subcommands (fail on .git/worktrees/.../index.lock and the -# hardcoded blocked-path set .claude/, .vscode/, .gitmodules inside pt -# worktrees). Read-only git (log/show/diff/status, worktree/stash list, ...) -# is left sandboxed. -# - srb / sorbet (always wants to write its mdb + rubocop cache). +# Only two classes are blocked, and both were verified to be unfixable by the +# sandbox's filesystem allowWrite list, so unsandboxing is the only option: +# +# - git WRITE subcommands. Fail on .git/worktrees/.../index.lock and the +# hardcoded blocked-path set (.claude/, .vscode/, .gitmodules) inside pt +# worktrees. Those blocked paths are NOT overridable by allowWrite (it's a +# hardcoded deny in the sandbox, not a config-driven one). Read-only git +# (log/show/diff/status, worktree/stash/submodule list, ...) is left +# sandboxed, so the common path is untouched. +# - srb / sorbet typecheck. Fails with `mdb_error="Operation not permitted"`. +# This is NOT a path deny: sorbet's --cache-dir (tmp/cache/sorbet) is inside +# cwd and already writable (a plain touch there succeeds sandboxed). Sorbet +# caches in LMDB, and the sandbox blocks LMDB's writable *mmap* of its data +# file at the syscall level (same class as Metal/qmd and incus). allowWrite +# only governs paths, so it can't help; the path is not the problem. +# +# In short: allowlisting was considered for both and ruled out by testing +# (2026-06-05). git => hardcoded path deny; srb => mmap-syscall deny. # # Motivation, frequencies, and the test fixture all come from pickletown # projects/transcript-mining/findings/2026-06-03-sandbox-friction.md @@ -104,7 +116,7 @@ while IFS= read -r seg || [ -n "$seg" ]; do [ -n "$seg" ] || continue if [[ "$seg" =~ ^(bundle[[:space:]]+exec[[:space:]]+)?(\./)?(bin/)?srb([[:space:]]|$) ]]; then - deny "sorbet (srb) writes its mdb / rubocop cache, which the sandbox blocks (it fails ~90% of the time sandboxed). Re-run this command with dangerouslyDisableSandbox: true." + deny "sorbet (srb) caches in LMDB, whose writable mmap the sandbox blocks at the syscall level (not a path issue allowWrite can fix). Re-run this command with dangerouslyDisableSandbox: true." fi if git_write_segment "$seg"; then From cb4cdb0087e7328658443bbfea473809ad1dbb95 Mon Sep 17 00:00:00 2001 From: Josh Nichols Date: Fri, 5 Jun 2026 13:27:10 -0400 Subject: [PATCH 3/5] docs(claude): correct srb sandbox-deny mechanism (not mmap) sandbox-probe disproved the writable-mmap theory (mmap + flock both succeed sandboxed). srb still needs unsandboxing, but the mechanism is a non-path syscall deny, not mmap; suspect LMDB's POSIX named semaphores. Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/claude-sandbox-guard | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/bin/claude-sandbox-guard b/bin/claude-sandbox-guard index 6e3b70c..e3b24f6 100755 --- a/bin/claude-sandbox-guard +++ b/bin/claude-sandbox-guard @@ -16,15 +16,18 @@ # hardcoded deny in the sandbox, not a config-driven one). Read-only git # (log/show/diff/status, worktree/stash/submodule list, ...) is left # sandboxed, so the common path is untouched. -# - srb / sorbet typecheck. Fails with `mdb_error="Operation not permitted"`. -# This is NOT a path deny: sorbet's --cache-dir (tmp/cache/sorbet) is inside -# cwd and already writable (a plain touch there succeeds sandboxed). Sorbet -# caches in LMDB, and the sandbox blocks LMDB's writable *mmap* of its data -# file at the syscall level (same class as Metal/qmd and incus). allowWrite -# only governs paths, so it can't help; the path is not the problem. +# - srb / sorbet typecheck. Fails with `mdb_error="Operation not permitted"` +# (LMDB). This is NOT a path deny: sorbet's --cache-dir (tmp/cache/sorbet) is +# inside cwd and already writable (a plain touch there succeeds sandboxed). +# The exact blocked syscall is unconfirmed: sandbox-probe (pickletown +# projects/sandbox-probe) showed it is NOT generic writable mmap and NOT file +# locking (both succeed sandboxed). Leading suspect is macOS LMDB's POSIX +# named semaphores (sem_open). Either way allowWrite governs only paths, so +# it can't help; the path is not the problem. # # In short: allowlisting was considered for both and ruled out by testing -# (2026-06-05). git => hardcoded path deny; srb => mmap-syscall deny. +# (2026-06-05). git => hardcoded path deny; srb => a non-path syscall deny +# (LMDB; mmap/flock disproven, mechanism still being pinned down). # # Motivation, frequencies, and the test fixture all come from pickletown # projects/transcript-mining/findings/2026-06-03-sandbox-friction.md @@ -116,7 +119,7 @@ while IFS= read -r seg || [ -n "$seg" ]; do [ -n "$seg" ] || continue if [[ "$seg" =~ ^(bundle[[:space:]]+exec[[:space:]]+)?(\./)?(bin/)?srb([[:space:]]|$) ]]; then - deny "sorbet (srb) caches in LMDB, whose writable mmap the sandbox blocks at the syscall level (not a path issue allowWrite can fix). Re-run this command with dangerouslyDisableSandbox: true." + deny "sorbet (srb) caches in LMDB and fails sandboxed with a non-path EPERM (the cache dir is already writable; allowWrite can't fix it). Re-run this command with dangerouslyDisableSandbox: true." fi if git_write_segment "$seg"; then From 2d13909e93f19c62a9d11bae5b4613b655d22659 Mon Sep 17 00:00:00 2001 From: Josh Nichols Date: Fri, 5 Jun 2026 14:12:50 -0400 Subject: [PATCH 4/5] fix(claude-sandbox-guard): correct srb mechanism to SysV semaphore deny srb's LMDB cache open fails sandboxed because LMDB on macOS uses SysV semaphores (MDB_USE_SYSV_SEM): the sandbox allows semget but denies semctl/semop with EPERM. Confirmed via pickletown projects/sandbox-probe (lmdb_replay.py replaying mdb_env_open). Replaces the earlier "named semaphores (sem_open)" suspicion in the comment and deny message; sem_open is not even linked into the sorbet binary. Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/claude-sandbox-guard | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/bin/claude-sandbox-guard b/bin/claude-sandbox-guard index e3b24f6..55157c7 100755 --- a/bin/claude-sandbox-guard +++ b/bin/claude-sandbox-guard @@ -19,15 +19,16 @@ # - srb / sorbet typecheck. Fails with `mdb_error="Operation not permitted"` # (LMDB). This is NOT a path deny: sorbet's --cache-dir (tmp/cache/sorbet) is # inside cwd and already writable (a plain touch there succeeds sandboxed). -# The exact blocked syscall is unconfirmed: sandbox-probe (pickletown -# projects/sandbox-probe) showed it is NOT generic writable mmap and NOT file -# locking (both succeed sandboxed). Leading suspect is macOS LMDB's POSIX -# named semaphores (sem_open). Either way allowWrite governs only paths, so -# it can't help; the path is not the problem. +# Confirmed blocked syscall (sandbox-probe, pickletown projects/sandbox-probe): +# LMDB on macOS is built with MDB_USE_SYSV_SEM, and the sandbox allows semget +# (creating the semaphore set) but DENIES semctl/semop on it with EPERM. So +# mdb_env_open creates the set then EPERMs initialising it. NOT writable mmap, +# NOT flock/fcntl, NOT sem_open (all ruled out). allowWrite governs only paths, +# so it can't help; the path is not the problem. # # In short: allowlisting was considered for both and ruled out by testing # (2026-06-05). git => hardcoded path deny; srb => a non-path syscall deny -# (LMDB; mmap/flock disproven, mechanism still being pinned down). +# (LMDB SysV semaphores: semctl/semop denied with EPERM). # # Motivation, frequencies, and the test fixture all come from pickletown # projects/transcript-mining/findings/2026-06-03-sandbox-friction.md @@ -119,7 +120,7 @@ while IFS= read -r seg || [ -n "$seg" ]; do [ -n "$seg" ] || continue if [[ "$seg" =~ ^(bundle[[:space:]]+exec[[:space:]]+)?(\./)?(bin/)?srb([[:space:]]|$) ]]; then - deny "sorbet (srb) caches in LMDB and fails sandboxed with a non-path EPERM (the cache dir is already writable; allowWrite can't fix it). Re-run this command with dangerouslyDisableSandbox: true." + deny "sorbet (srb) caches in LMDB, which uses SysV semaphores the sandbox denies (semctl/semop EPERM); the cache dir is already writable, so allowWrite can't fix it. Re-run this command with dangerouslyDisableSandbox: true." fi if git_write_segment "$seg"; then From cbc476a08955811614c967febbf4c68454c51f3e Mon Sep 17 00:00:00 2001 From: Josh Nichols Date: Fri, 5 Jun 2026 15:05:23 -0400 Subject: [PATCH 5/5] feat(claude-sandbox-guard): deny sandboxed ps/top, hint unsandbox ps and top are setuid-root; the sandbox denies exec of setuid/setgid binaries, so they EPERM sandboxed (bare `ps` is shimmed through rtk, which execs /bin/ps and hits the same deny). Both are read-only and safe unsandboxed, so they fit the existing deny-and-retry pattern. Matches bare, path-qualified, and rtk-wrapped forms; anchored so psql/topgrade/ pstree don't false-trigger. Only ps/top are listed (the tools agents actually run); other setuid binaries (su, crontab) are left to fail loudly rather than auto-unsandboxed. Mechanism confirmed via pickletown projects/sandbox-probe: the sysctl ps reads (KERN_PROC) is allowed sandboxed; the friction is purely setuid exec, which allowWrite can't touch. Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/claude-sandbox-guard | 26 +++++++++++++++++++++----- test/claude-sandbox-guard.bats | 12 ++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/bin/claude-sandbox-guard b/bin/claude-sandbox-guard index 55157c7..78531bc 100755 --- a/bin/claude-sandbox-guard +++ b/bin/claude-sandbox-guard @@ -7,8 +7,8 @@ # wastes a round-trip and surfaces a cryptic EPERM. This hook denies the # sandboxed attempt up front with a clear instruction to retry unsandboxed. # -# Only two classes are blocked, and both were verified to be unfixable by the -# sandbox's filesystem allowWrite list, so unsandboxing is the only option: +# Three classes are blocked, all verified to be unfixable by the sandbox's +# filesystem allowWrite list, so unsandboxing is the only option: # # - git WRITE subcommands. Fail on .git/worktrees/.../index.lock and the # hardcoded blocked-path set (.claude/, .vscode/, .gitmodules) inside pt @@ -25,10 +25,18 @@ # mdb_env_open creates the set then EPERMs initialising it. NOT writable mmap, # NOT flock/fcntl, NOT sem_open (all ruled out). allowWrite governs only paths, # so it can't help; the path is not the problem. +# - ps / top. The sandbox denies exec of setuid/setgid binaries (a privilege- +# escalation guard): /bin/ps and /usr/bin/top are setuid root, so they EPERM +# on exec. Bare `ps` is shimmed through rtk, which execs /bin/ps and hits the +# same deny ("Operation not permitted (os error 1)"). The sysctl ps reads +# (KERN_PROC) is actually allowed sandboxed; the friction is purely the setuid +# exec, which allowWrite can't touch. Both are read-only and safe unsandboxed. +# Only ps/top are listed (the tools agents actually run); other setuid binaries +# (su, crontab, ...) are left to fail loudly rather than auto-unsandboxed. # -# In short: allowlisting was considered for both and ruled out by testing -# (2026-06-05). git => hardcoded path deny; srb => a non-path syscall deny -# (LMDB SysV semaphores: semctl/semop denied with EPERM). +# In short: allowlisting was considered for all three and ruled out by testing +# (2026-06-05). git => hardcoded path deny; srb => a non-path syscall deny (LMDB +# SysV semaphores: semctl/semop EPERM); ps/top => setuid-exec deny. # # Motivation, frequencies, and the test fixture all come from pickletown # projects/transcript-mining/findings/2026-06-03-sandbox-friction.md @@ -123,6 +131,14 @@ while IFS= read -r seg || [ -n "$seg" ]; do deny "sorbet (srb) caches in LMDB, which uses SysV semaphores the sandbox denies (semctl/semop EPERM); the cache dir is already writable, so allowWrite can't fix it. Re-run this command with dangerouslyDisableSandbox: true." fi + # ps/top are setuid-root; the sandbox denies exec of setuid/setgid binaries, so + # they EPERM sandboxed (bare `ps` is shimmed through rtk, which execs /bin/ps and + # hits the same deny). Read-only and safe unsandboxed. Matches bare, path-qualified, + # and rtk-wrapped forms; anchored so psql/topology/etc. don't trip it. + if [[ "$seg" =~ ^(rtk[[:space:]]+)?(/usr/bin/|/bin/)?(ps|top)([[:space:]]|$) ]]; then + deny "ps/top are setuid-root binaries and the sandbox denies executing setuid/setgid binaries, so they fail sandboxed with 'Operation not permitted' (rtk wrapping ps hits the same deny). They are read-only and safe unsandboxed. Re-run this command with dangerouslyDisableSandbox: true." + fi + if git_write_segment "$seg"; then deny "Sandboxed git-write commands fail inside pt worktrees (.git/worktrees/.../index.lock and the blocked .claude/.vscode/.gitmodules paths). git writes are safe to run unsandboxed. Re-run this command with dangerouslyDisableSandbox: true." fi diff --git a/test/claude-sandbox-guard.bats b/test/claude-sandbox-guard.bats index dd074e2..ab253b1 100644 --- a/test/claude-sandbox-guard.bats +++ b/test/claude-sandbox-guard.bats @@ -51,6 +51,15 @@ assert_allow() { [ "$(decision_for "$1" "${2:-}")" = ALLOW ] || { echo "expected @test "bin/srb is denied" { assert_deny "bin/srb tc"; } @test "bundle exec srb is denied" { assert_deny "bundle exec srb tc"; } +# --- ps/top (setuid exec deny): should DENY ---------------------------------- + +@test "ps aux is denied" { assert_deny "ps aux"; } +@test "top is denied (bare)" { assert_deny "top"; } +@test "/bin/ps is denied" { assert_deny "/bin/ps -ef"; } +@test "/usr/bin/top is denied" { assert_deny "/usr/bin/top -l 1"; } +@test "rtk ps (explicit wrap) is denied" { assert_deny "rtk ps aux"; } +@test "cd then ps (compound) is denied" { assert_deny "cd /tmp && ps aux"; } + # --- reads / non-git / ambiguous list-forms: should ALLOW -------------------- @test "git log is allowed" { assert_allow "git log --oneline -5"; } @@ -65,6 +74,9 @@ assert_allow() { [ "$(decision_for "$1" "${2:-}")" = ALLOW ] || { echo "expected @test "plain ls is allowed" { assert_allow "ls -la"; } @test "echo is allowed" { assert_allow "echo hi"; } @test "rspec (not srb) is allowed" { assert_allow "bundle exec rspec"; } +@test "psql (not ps) is allowed" { assert_allow "psql -c 'select 1'"; } +@test "topgrade (not top) is allowed" { assert_allow "topgrade"; } +@test "pstree-like name is allowed" { assert_allow "pstree"; } @test "grep for the string git add is allowed" { assert_allow 'grep -rn "git add" .'; } @test "cd then git status is allowed" { assert_allow "cd /p && git status"; }