#!/bin/bashset -euo pipefailHERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd || true)HOOK="$HERE/../hooks/pretooluse.sh"if [ ! -f "$HOOK" ]; then echo "fatal: no ../hooks/pretooluse.sh beside this script" >&2; exit 1; fiif ! command -v jq >/dev/null 2>&1; then echo "fatal: jq is required and not installed" >&2; exit 1; fiSTRICT=0KEEP=0REPO=""TARGETS=()while [ $# -gt 0 ]; do case "$1" in --strict) STRICT=1;; --keep) KEEP=1;; --repo) shift; REPO=${1:-}; if [ -z "$REPO" ]; then echo "fatal: --repo needs a name" >&2; exit 1; fi;; -h|--help) sed -n '2,15p' "$0"; exit 0;; -*) echo "fatal: unknown flag $1" >&2; exit 1;; *) TARGETS+=("$1");; esac shiftdoneif ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo "fatal: not a git repository" >&2; exit 1; ficd "$(git rev-parse --show-toplevel)"SELF=$(pwd)ROOT="$SELF"if [ -n "$REPO" ]; then ROOT=$(find "$HOME/Developer" -maxdepth 3 -type d -name "$REPO" -print -quit 2>/dev/null || true) if [ -z "$ROOT" ]; then echo "fatal: no repo named '$REPO' under ~/Developer" >&2; exit 1; fifiMANAGED="/Library/Application Support/ClaudeCode/managed-settings.json"STACK=()STACK_NAMES=()for pair in "managed:$MANAGED" "local:$ROOT/.claude/settings.local.json" \ "project:$ROOT/.claude/settings.json" "user:$HOME/.claude/settings.json"; do name=${pair%%:*} path=${pair#*:} if [ -f "$path" ]; then STACK+=("$path"); STACK_NAMES+=("$name"); fidoneif [ ${#STACK[@]} -eq 0 ]; then echo "fatal: no settings files found for $ROOT" >&2; exit 1; fiif [ ${#TARGETS[@]} -eq 0 ]; then while IFS= read -r found; do TARGETS+=("$found"); done < <( find "$SELF/AGENTS" -name '*.sh' -type f 2>/dev/null | sort )fiif [ ${#TARGETS[@]} -eq 0 ]; then echo "fatal: no scripts to test" >&2; exit 1; fiTMPROOT="$SELF/tmp"TMPTAG=$(basename "${BASH_SOURCE[0]}" .sh)mkdir -p "$TMPROOT"FINDINGS=$(mktemp "$TMPROOT/$TMPTAG-findings.XXXXXX")SCRATCH=$(mktemp -d "$TMPROOT/$TMPTAG-scratch.XXXXXX")cleanup() { st=$?; if [ "$KEEP" -eq 0 ] && [ "$st" -eq 0 ]; then rm -rf "$FINDINGS" "$SCRATCH"; fi; }trap cleanup EXITerr() { printf 'ERROR|%s|%s|%s|%s\n' "$1" "$2" "$3" "$4" >> "$FINDINGS"; }warn() { printf 'WARN|%s|%s|%s|%s\n' "$1" "$2" "$3" "$4" >> "$FINDINGS"; }merge() { local filter=$1 file for file in "${STACK[@]}"; do jq -r "${filter}[]? // empty" "$file" 2>/dev/null || true done | sort -u}merge '.permissions.deny' > "$SCRATCH/deny"merge '.permissions.ask' > "$SCRATCH/ask"merge '.permissions.allow' > "$SCRATCH/allow"merge '.sandbox.filesystem.allowWrite' > "$SCRATCH/allowwrite"merge '.sandbox.filesystem.denyRead' > "$SCRATCH/denyread"merge '.sandbox.network.allowedDomains' > "$SCRATCH/domains"merge '.sandbox.excludedCommands' > "$SCRATCH/excluded"for i in "${!STACK[@]}"; do if ! jq empty "${STACK[$i]}" >/dev/null 2>&1; then err "${STACK_NAMES[$i]}" settings "${STACK[$i]}" "does not parse as json; these rules never load" fidoneORIGIN_HOST=$(git -C "$ROOT" remote get-url origin 2>/dev/null \ | sed -E 's#^[a-z]+://##; s#^[^@/]*@##; s#^([^/:]*)[:/].*#\1#' || true)if [ -z "$ORIGIN_HOST" ]; then ORIGIN_HOST="the origin host"; fiT1_PASS=0; T1_ASK=0; T1_FAIL=0hook_verdict() { local cmd=$1 out out=$(jq -n --arg c "$cmd" '{tool_input:{command:$c}}' | bash "$HOOK" 2>/dev/null || true) if [ -z "$out" ]; then printf 'silent'; else printf 'deny'; fi}DENY_RULES=(); ASK_RULES=(); ALLOW_RULES=()load_rules() { local list=$1 rule pattern while IFS= read -r rule; do case "$rule" in Bash\(*\)) ;; *) continue;; esac pattern=${rule#Bash(} pattern=${pattern%)} case "$list" in deny) DENY_RULES+=("$pattern");; ask) ASK_RULES+=("$pattern");; allow) ALLOW_RULES+=("$pattern");; esac done < "$SCRATCH/$list"}load_rules deny; load_rules ask; load_rules allowhits_deny() { local p; if [ ${#DENY_RULES[@]} -eq 0 ]; then return 1; fi for p in "${DENY_RULES[@]}"; do case "$1" in $p) return 0;; esac; done; return 1}hits_ask() { local p; if [ ${#ASK_RULES[@]} -eq 0 ]; then return 1; fi for p in "${ASK_RULES[@]}"; do case "$1" in $p) return 0;; esac; done; return 1}hits_allow() { local p; if [ ${#ALLOW_RULES[@]} -eq 0 ]; then return 1; fi for p in "${ALLOW_RULES[@]}"; do case "$1" in $p) return 0;; esac; done; return 1}tier1() { local script=$1 rel call rel=${script#"$SELF"/} call="$rel" if [ "$(hook_verdict "$call")" = "deny" ]; then err "$rel" invocation "$call" "the hook blocks this script from running at all" T1_FAIL=$((T1_FAIL + 1)); return fi if hits_deny "$call"; then err "$rel" invocation "$call" "a deny rule refuses the script; no allow can override it" T1_FAIL=$((T1_FAIL + 1)); return fi if hits_ask "$call"; then warn "$rel" invocation "$call" "an ask rule prompts every run, even sandboxed" T1_ASK=$((T1_ASK + 1)); return fi if hits_allow "$call"; then T1_PASS=$((T1_PASS + 1)); return fi warn "$rel" invocation "$call" "no allow rule names it, so every run prompts; add one in project" T1_ASK=$((T1_ASK + 1))}T2_SEEN=0internals_of() { local file=$1 sed -E 's/^[[:space:]]*#.*$//' "$file" \ | sed -E 's/[[:space:]]#[[:space:]].*$//' \ | tr ';|&' '\n\n\n' \ | sed -E 's/\$\(/\n/g; s/`/\n/g' \ | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//' \ | sed -E 's/^(if|then|else|elif|fi|for|while|do|done|case|esac|!|not)[[:space:]]+//' \ | grep -vE '^$' || true}base_of() { local cmd=$1 first second first=$(printf '%s' "$cmd" | awk '{print $1}') second=$(printf '%s' "$cmd" | awk '{print $2}') case "$first" in git|gh|npm|yarn|pnpm|bun|docker|kubectl|aws|terraform|prisma) case "$second" in -*|"") printf '%s' "$first";; *) printf '%s %s' "$first" "$second";; esac;; *) printf '%s' "$first";; esac}NETWORK='^(gh|curl|wget|npx|npm|pnpm|yarn)$|^git (fetch|push|pull|clone|ls-remote|remote|submodule)$'WRITERS='^(rm|cp|mv|tee|mkdir|touch|chmod|chown|ln|install|rsync|dd)$'outside_paths() { local line=$1 token for token in $line; do case "$token" in /*|~/*) printf '%s\n' "$token";; esac done printf '%s' "$line" | grep -oE '>>?[[:space:]]*[^[:space:]]+' 2>/dev/null \ | sed -E 's/^>>?[[:space:]]*//' | grep -E '^(/|~/)' || true}covered_by() { local list=$1 path=$2 entry stem while IFS= read -r entry; do if [ -z "$entry" ]; then continue; fi stem=${entry%/**} stem=${stem%\*} case "$path" in "$stem"*) return 0;; esac done < "$SCRATCH/$list" return 1}tier2() { local script=$1 rel line base path host rel=${script#"$SELF"/} while IFS= read -r line; do if [ -z "$line" ]; then continue; fi base=$(base_of "$line") case "$base" in ''|[A-Z_]*=*|\[*|echo|printf|return|exit|local|shift|set|trap|read) continue;; esac T2_SEEN=$((T2_SEEN + 1)) if hits_deny "$line" || hits_deny "$base"; then err "$rel" bypass "$base" "a deny names this, but an internal call is not a tool call" continue fi if grep -qE "^$(printf '%s' "$base" | awk '{print $1}') ?\*?$" "$SCRATCH/excluded" 2>/dev/null; then warn "$rel" excluded "$base" "runs unsandboxed via excludedCommands; only the hook remains" continue fi if printf '%s' "$base" | grep -qE "$NETWORK"; then host="$ORIGIN_HOST" case "$base" in npm*|pnpm*|yarn*|npx*) host="registry.npmjs.org";; esac if ! grep -qxF "$host" "$SCRATCH/domains" 2>/dev/null; then err "$rel" domain "$base" "reaches $host, unlisted; add it to allowedDomains in project" fi fi case "$line" in */*|*~*) ;; *) continue;; esac if printf '%s' "$base" | grep -qE "$WRITERS"; then while IFS= read -r path; do if [ -z "$path" ]; then continue; fi case "$path" in "$SELF"/*|./*) continue;; esac if ! covered_by allowwrite "$path"; then err "$rel" filesystem "$base" "writes $path outside cwd; add it to allowWrite in user" fi done < <(outside_paths "$line") fi while IFS= read -r path; do if [ -z "$path" ]; then continue; fi if covered_by denyread "$path"; then warn "$rel" read "$base" "reads $path, which denyRead blocks for sandboxed bash" fi done < <(outside_paths "$line") done < <(internals_of "$script")}for target in "${TARGETS[@]}"; do if [ ! -f "$target" ]; then warn "$target" missing "" "no such file; skipped"; continue; fi tier1 "$target" tier2 "$target"doneERRORS=$(sort -u "$FINDINGS" | grep -c "^ERROR|" 2>/dev/null || true)WARNINGS=$(sort -u "$FINDINGS" | grep -c '^WARN|' 2>/dev/null || true)ERRORS=${ERRORS:-0}WARNINGS=${WARNINGS:-0}cat <<EOF=== scopes.sh workflow tester ===repo: $ROOTstack: ${STACK_NAMES[*]}origin: $ORIGIN_HOSTscripts: ${#TARGETS[@]}invocations: $T1_PASS allowed, $T1_ASK prompting, $T1_FAIL refusedinternals: $T2_SEEN inspected, none of which cross a permission gateerrors: $ERRORSwarnings: $WARNINGS--- findings ---EOFif [ "$ERRORS" -eq 0 ] && [ "$WARNINGS" -eq 0 ]; then echo "none — every script runs, and every internal stays inside the merged boundary"else sort -u -t'|' -k2,2 -k3,3 -k4,4 "$FINDINGS" \ | awk -F'|' '{ printf "%-5s %-34s %-11s %-22s %s\n", $1, $2, $3, substr($4, 1, 20), $5 }'ficat <<'EOF'--- what this tester cannot tell you ---- it reads scripts, it never runs them, so a command built at runtime is invisible to it- a quoted path and a variable path look the same here; expanded values are not resolved- it names one host per call from the repo's origin, so a second remote is not modelled- managed is only in the stack if it is installed; a missing ceiling reads as a permissive one- the sandbox decides at the kernel, so treat every filesystem verdict as a prediction to test- an internal reported clean is clean against these files, not against the ones that ship=================================EOFif [ "$ERRORS" -gt 0 ]; then exit 1; fiif [ "$STRICT" -eq 1 ] && [ "$WARNINGS" -gt 0 ]; then exit 1; fiexit 0