#!/usr/bin/env sh # PreToolUse hook for the Bash tool (wired up in .claude/settings.json). # Reads the tool call as JSON on stdin and blocks (exit 2, reason on stderr) # every command form that Claude Code can't trace under # blockReadsOutsideWorkingDirectories, and so would prompt Arcadia even in # bypass mode: cd, loops, subshells and substitutions, heredocs, paths outside # the repo, PATH changes. It also blocks python, in-place sed/perl, and rm/cp # (aliased to `-i` in the agent shell). Each reason names the allowed # alternative. See docs/tasks/2026-09-30/15-agent-autonomy-config.md; the cases # it must get right live in scripts/guard-cases.txt (run scripts/test-guard.sh). # # Quote handling: patterns are tested on the command with the contents of # '...' and "..." blanked, so text inside quotes (commit messages, grep # patterns) never trips them. $(, backticks and $HOME still expand inside # "...", so those are also checked with only '...' blanked. Limitations: # backslash escapes are dropped before scanning, which approximates \" inside # "..." but misreads a backslash just before a closing ' ; paths inside quotes # aren't checked (Claude Code's own outside-read check still applies to them). memo=docs/memories/feedback/no-permission-prompting-commands.md deny() { echo "Blocked by scripts/guard-bash.sh: $1. See $memo." >&2 exit 2 } command -v jq >/dev/null 2>&1 || deny "guard-bash: jq unavailable" cmd=$(jq -r '.tool_input.command // empty') || deny "guard-bash: jq unavailable" [ -n "$cmd" ] || deny "guard-bash: jq unavailable" proj=${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "$0")/.." && pwd)} # Newlines separate commands, so scan the command as one line with ';' for them. flat=$(printf '%s' "$cmd" | tr '\n' ';' | sed -E 's/\\.//g') # Both kinds of quoted text blanked (leftmost quote wins, so "it's" is one string). bare=$(printf '%s' "$flat" | sed -E "s/'[^']*'|\"[^\"]*\"/''/g") # Only single-quoted text blanked: what the shell still expands. expanding=$(printf '%s' "$flat" | sed -E "s/'[^']*'|(\"[^\"]*\")/\\1/g") # A command word: after a separator or a keyword/wrapper, past any VAR=value. cs='(^|[;&|(){}!]|(^|[[:space:]])(then|do|else|elif|if|exec|command|builtin|nocorrect|noglob|nohup|time|xargs|sudo|env|timeout[[:space:]]+[0-9.]+[smhd]?)[[:space:]])[[:space:]]*([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+)*' end='([[:space:]]|$|[;&|)<>])' anydir='([^[:space:];&|()<>]*/)?' bare_has() { printf '%s\n' "$bare" | grep -Eq "$1"; } expanding_has() { printf '%s\n' "$expanding" | grep -Eq "$1"; } bare_has "${cs}(cd|pushd|popd)${end}" && deny "never cd; the shell is at the repo root, so use absolute or repo-relative paths" bare_has "${cs}(for|while|until|select)${end}" && deny "no shell loops; run one command per call, or put the loop in a checked-in script under scripts/" expanding_has '\$\(|`' && deny "no \$(...) or backticks; run the inner command as its own call and use its output" bare_has '[<>]\(' && deny "no <(...) or >(...) process substitution; run each command as its own call" bare_has "${cs}\\(" && deny "no ( ... ) subshells; run the commands as separate calls" bare_has '<<' && deny "no heredocs or here-strings; write file contents with the Write tool" bare_has "${cs}${anydir}python[0-9.]*${end}" && deny "no python; use Read/Edit/Write for files" bare_has "${cs}${anydir}(sed|perl)([[:space:]][^;&|]*)?[[:space:]](-[a-zA-Z]*i[^[:space:]]*|--in-place)([=[:space:]]|$)" && deny "no in-place sed/perl; make the change with the Edit tool" bare_has "${cs}${anydir}rm${end}" && deny "no rm (it's aliased to rm -i here); delete with scripts/rm.sh " bare_has "${cs}${anydir}cp${end}" && deny "no cp (it's aliased to cp -i here); copy with Read then Write" bare_has '(^|[[:space:]=:<>])~' && deny "no ~ paths; stay inside the repo with repo-relative paths" expanding_has '\$HOME([^A-Za-z0-9_]|$)|\$\{HOME\}' && deny "no \$HOME; stay inside the repo with repo-relative paths" bare_has '(^|[[:space:]/=:<>])\.\.(/|[[:space:];&|)]|$)' && deny "no .. path segments; use repo-relative paths from the repo root" bare_has '(^|[^A-Za-z0-9_])PATH=|export[[:space:]]+PATH([[:space:]]|$)' && deny "don't change PATH; run npm through scripts/npm.sh" set -f # the path words below must not glob for p in $(printf '%s\n' "$bare" | grep -oE '(^|[[:space:]=:<>])/[^[:space:];&|()<>]*' | sed -E 's/^[[:space:]=:<>]//'); do case "$p" in /dev/null | "$proj" | "$proj"/*) ;; *) deny "no absolute paths outside the repo ($p); use repo-relative paths" ;; esac done exit 0