Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .config/prettier/ignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions bin/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
147 changes: 147 additions & 0 deletions bin/claude-sandbox-guard
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
#!/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.
#
# 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
# 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"`
# (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).
# 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.
# - 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 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
#
# 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) 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
done < <(printf '%s' "$cmd" | tr ';|&()\n' '\n')

exit 0
8 changes: 4 additions & 4 deletions claude/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<event>` 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**

Expand Down
24 changes: 24 additions & 0 deletions claude/roles/base.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,30 @@
// session logs
"showThinkingSummaries": true,

// Hooks. claudeconfig.sh concatenates hooks.<event> 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",
Expand Down
24 changes: 21 additions & 3 deletions claudeconfig.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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')

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
111 changes: 111 additions & 0 deletions test/claude-sandbox-guard.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
#!/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 <command> [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 <path> 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"; }

# --- 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"; }
@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 "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"; }

# --- 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 ]
}
Loading
Loading