Something went wrong. Try again.
A mental bridge between a hacker and their LLM(s) of choice.
Something went wrong. Try again.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586#!/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 <repo-relative paths>"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 globfor 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" ;; esacdone
exit 0