|
| 1 | +#!/usr/bin/env bash |
| 2 | +# PreToolUse hook — main session is a pure orchestrator. |
| 3 | +# |
| 4 | +# Architecture: bash parameter-expansion split on "tool_input" separates |
| 5 | +# harness-controlled fields (tool_name, agent_id) from model-controlled |
| 6 | +# content (tool_input.command). No JSON depth-walker; no brace counters. |
| 7 | +# |
| 8 | +# Subagent detection: a genuine TOP-LEVEL agent_id key (brace-depth 1) → bypass. |
| 9 | +# Order-independent and immune to the literal text "agent_id" inside tool_input |
| 10 | +# (see has_top_level_agent_id). tool_name is still extracted only from $prefix. |
| 11 | +# |
| 12 | +# Bash allowlist: every semicolon/&&/||/pipe/newline-separated segment must |
| 13 | +# start with an allowed (verb, subverb, ...) tuple. Forbidden constructs |
| 14 | +# ($( ` ${ eval source dot-source) are rejected before tokenizing. |
| 15 | +# |
| 16 | +# Awk scripts live in lib/ next to this file for independent testability. |
| 17 | +# No python, jq, node, perl, ruby, nix-shell, or compiled binaries. |
| 18 | + |
| 19 | +set -euo pipefail |
| 20 | + |
| 21 | +dir="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" |
| 22 | +input=$(head -c $((1024 * 1024))) |
| 23 | + |
| 24 | +# ── debug log ──────────────────────────────────────────────────────────────── |
| 25 | +if [[ "${CLAUDE_HOOK_DEBUG:-}" == "1" ]]; then |
| 26 | + state_dir="${CLAUDE_HOOK_STATE_DIR:-/tmp/claude-state}" |
| 27 | + mkdir -p "$state_dir" |
| 28 | + debug_log="$state_dir/hook-input.debug.log" |
| 29 | + touch "$debug_log" |
| 30 | + chmod 600 "$debug_log" |
| 31 | + printf '\n=== %s ===\n' "$(date -Iseconds)" >> "$debug_log" |
| 32 | + printf '%s\n' "$input" >> "$debug_log" |
| 33 | + # Trim to 2000 lines |
| 34 | + tmp_log=$(mktemp) |
| 35 | + tail -n 2000 "$debug_log" > "$tmp_log" && mv "$tmp_log" "$debug_log" |
| 36 | + chmod 600 "$debug_log" |
| 37 | +fi |
| 38 | + |
| 39 | +# ── denial helper ───────────────────────────────────────────────────────────── |
| 40 | +DENY_MSG="Main session is orchestrator only. Allowed: Agent/Task*/AskUserQuestion/EnterPlanMode/ExitPlanMode/SendUserFile/Skill/ToolSearch/ScheduleWakeup/Workflow; Bash limited to git commit, git push, git status, git log --oneline (no chaining, no command substitution, no eval/source). Delegate everything else to a subagent." |
| 41 | + |
| 42 | +deny() { |
| 43 | + local tool_name="$1" |
| 44 | + local extra="${2:-}" |
| 45 | + local reason="$DENY_MSG Denied tool: $tool_name." |
| 46 | + if [[ -n "$extra" ]]; then |
| 47 | + reason="$reason $extra" |
| 48 | + fi |
| 49 | + # JSON-escape the reason: \, then ", then tab/CR/LF via tr placeholders |
| 50 | + # Use awk to handle all control-char substitutions safely |
| 51 | + local escaped |
| 52 | + escaped=$(printf '%s' "$reason" | awk ' |
| 53 | + { |
| 54 | + gsub(/\\/, "\\\\") |
| 55 | + gsub(/"/, "\\\"") |
| 56 | + gsub(/\t/, "\\t") |
| 57 | + gsub(/\r/, "\\r") |
| 58 | + # awk RS splits on \n; print adds \n between records but not in ORS |
| 59 | + printf "%s\\n", $0 |
| 60 | + } |
| 61 | + ' | sed '$ s/\\n$//') |
| 62 | + printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' "$escaped" |
| 63 | + exit 0 |
| 64 | +} |
| 65 | + |
| 66 | +# ── split on "tool_input" ───────────────────────────────────────────────────── |
| 67 | +# prefix = everything before the first occurrence of "tool_input" |
| 68 | +# rest = everything after "tool_input": |
| 69 | +prefix="${input%%\"tool_input\"*}" |
| 70 | +rest="${input#*\"tool_input\":}" |
| 71 | + |
| 72 | +# ── extract tool_name (only from prefix) ───────────────────────────────────── |
| 73 | +tool_name=$(printf '%s' "$prefix" | grep -oE '"tool_name"\s*:\s*"[^"]*"' | head -1 | grep -oE '"[^"]*"$' | tr -d '"' || true) |
| 74 | +if [[ -z "$tool_name" ]]; then |
| 75 | + exit 0 # no tool_name — fail open |
| 76 | +fi |
| 77 | + |
| 78 | +# ── subagent detection: TOP-LEVEL agent_id present → bypass ────────────────── |
| 79 | +# JSON field order is NOT guaranteed: agent_id may be serialized before OR |
| 80 | +# after tool_input. The old code only searched $prefix (text before |
| 81 | +# "tool_input"), so when the harness emitted agent_id after tool_input the |
| 82 | +# bypass silently missed and a subagent's Read fell through to deny. |
| 83 | +# |
| 84 | +# We must also NOT be fooled by the literal text "agent_id" appearing INSIDE |
| 85 | +# tool_input (e.g. inside a subagent prompt that discusses agent_id). Only a |
| 86 | +# genuine TOP-LEVEL "agent_id" key counts — i.e. one at JSON brace-depth 1. |
| 87 | +# |
| 88 | +# Technique (pure bash + grep/sed/tr; no awk/jq/python on this path): |
| 89 | +# 1. Drop escaped quotes (\") so remaining double-quotes are balanced |
| 90 | +# string delimiters. |
| 91 | +# 2. Tag the KEY form "agent_id" <ws>* : with a sentinel byte (\001) |
| 92 | +# BEFORE blanking strings. A string VALUE "agent_id" is followed by , or |
| 93 | +# } (never :), so only real keys get tagged. |
| 94 | +# 3. Blank every string's CONTENTS ("..." -> "") so no structural-looking |
| 95 | +# char ({ } and stray text) survives inside string data. This kills the |
| 96 | +# false positive: braces/colons/"agent_id" text inside a prompt vanish. |
| 97 | +# 4. Reduce to ONLY { } and the sentinel via `tr -cd`. The result is a tiny |
| 98 | +# structural skeleton regardless of payload size, so the depth scan below |
| 99 | +# is O(structure), not O(payload) — a 1 MiB prompt costs <40 ms. |
| 100 | +# 5. Walk the skeleton counting brace depth; a sentinel seen at depth 1 is a |
| 101 | +# top-level agent_id key → subagent. |
| 102 | +has_top_level_agent_id() { |
| 103 | + local skeleton |
| 104 | + skeleton=$(printf '%s' "$1" \ |
| 105 | + | sed 's/\\"//g' \ |
| 106 | + | sed 's/"agent_id"\([[:space:]]*\):/\x01\1:/g' \ |
| 107 | + | sed 's/"[^"]*"/""/g' \ |
| 108 | + | tr -cd '{}\001') |
| 109 | + local i ch depth=0 n=${#skeleton} |
| 110 | + for (( i = 0; i < n; i++ )); do |
| 111 | + ch="${skeleton:i:1}" |
| 112 | + case "$ch" in |
| 113 | + '{') (( depth++ )) ;; |
| 114 | + '}') (( depth-- )) ;; |
| 115 | + $'\001') (( depth == 1 )) && return 0 ;; |
| 116 | + esac |
| 117 | + done |
| 118 | + return 1 |
| 119 | +} |
| 120 | + |
| 121 | +if has_top_level_agent_id "$input"; then |
| 122 | + exit 0 # subagent — pass unconditionally |
| 123 | +fi |
| 124 | + |
| 125 | +# ── orchestration tools (always allowed) ───────────────────────────────────── |
| 126 | +case "$tool_name" in |
| 127 | + Agent|Task|TaskCreate|TaskUpdate|TaskList|TaskGet|TaskOutput|TaskStop|\ |
| 128 | + AskUserQuestion|EnterPlanMode|ExitPlanMode|SendUserFile|Skill|ToolSearch|ScheduleWakeup|Workflow) |
| 129 | + exit 0 |
| 130 | + ;; |
| 131 | +esac |
| 132 | + |
| 133 | +# ── mutation tools (never allowed in main) ─────────────────────────────────── |
| 134 | +case "$tool_name" in |
| 135 | + Edit|Write|NotebookEdit) |
| 136 | + deny "$tool_name" |
| 137 | + ;; |
| 138 | +esac |
| 139 | + |
| 140 | +# ── Bash (limited allowlist) ────────────────────────────────────────────────── |
| 141 | +if [[ "$tool_name" == "Bash" ]]; then |
| 142 | + |
| 143 | + # Extract raw (JSON-encoded) command string from $rest via awk state machine |
| 144 | + cmd_raw=$(printf '%s' "$rest" | awk -f "$dir/lib/extract-command.awk") |
| 145 | + |
| 146 | + # Reject empty command |
| 147 | + if [[ -z "$cmd_raw" ]]; then |
| 148 | + deny "$tool_name" "Empty command." |
| 149 | + fi |
| 150 | + |
| 151 | + # JSON-decode the command string via awk. |
| 152 | + # Order of substitutions (to avoid double-decoding): |
| 153 | + # 1. \\ → placeholder (chr(1)) first |
| 154 | + # 2. \" → " |
| 155 | + # 3. \n → newline |
| 156 | + # 4. \t → tab |
| 157 | + # 5. \r → CR |
| 158 | + # 6. \b → backspace |
| 159 | + # 7. \f → form-feed |
| 160 | + # 8. \/ → / |
| 161 | + # 9. placeholder → \ |
| 162 | + # If \uXXXX appears, deny conservatively (no Unicode support needed for git cmds). |
| 163 | + if printf '%s' "$cmd_raw" | grep -qE '\\u[0-9a-fA-F]{4}'; then |
| 164 | + deny "$tool_name" "Command contains \\uXXXX escape — denied conservatively." |
| 165 | + fi |
| 166 | + |
| 167 | + command=$(printf '%s' "$cmd_raw" | awk ' |
| 168 | + BEGIN { RS = ""; ORS = "" } |
| 169 | + { |
| 170 | + gsub(/\\\\/, "\001") |
| 171 | + gsub(/\\"/, "\"") |
| 172 | + gsub(/\\n/, "\n") |
| 173 | + gsub(/\\t/, "\t") |
| 174 | + gsub(/\\r/, "\r") |
| 175 | + gsub(/\\b/, "\010") |
| 176 | + gsub(/\\f/, "\014") |
| 177 | + gsub(/\\\//, "/") |
| 178 | + gsub(/\001/, "\\") |
| 179 | + printf "%s", $0 |
| 180 | + } |
| 181 | + ') |
| 182 | + |
| 183 | + # Forbidden constructs — check decoded command (conservative: includes inside quotes) |
| 184 | + if printf '%s' "$command" | grep -qF '$(' ; then |
| 185 | + deny "$tool_name" "Forbidden construct: \$( in command." |
| 186 | + fi |
| 187 | + if printf '%s' "$command" | grep -qF '`' ; then |
| 188 | + deny "$tool_name" "Forbidden construct: backtick in command." |
| 189 | + fi |
| 190 | + if printf '%s' "$command" | grep -qF '${' ; then |
| 191 | + deny "$tool_name" "Forbidden construct: \${ in command." |
| 192 | + fi |
| 193 | + if printf '%s' "$command" | grep -qE '(^|[[:space:];&|])eval([[:space:];&|]|$)' ; then |
| 194 | + deny "$tool_name" "Forbidden construct: eval in command." |
| 195 | + fi |
| 196 | + if printf '%s' "$command" | grep -qE '(^|[[:space:];&|])source([[:space:];&|]|$)' ; then |
| 197 | + deny "$tool_name" "Forbidden construct: source in command." |
| 198 | + fi |
| 199 | + if printf '%s' "$command" | grep -qE '(^|[[:space:];&|])\.([[:space:]/~]|$)' ; then |
| 200 | + deny "$tool_name" "Forbidden construct: dot-source in command." |
| 201 | + fi |
| 202 | + |
| 203 | + # Tokenize and allowlist-check each segment |
| 204 | + result=$(printf '%s' "$command" | awk -f "$dir/lib/tokenize-bash.awk") |
| 205 | + if [[ "$result" != "OK" ]]; then |
| 206 | + deny "$tool_name" "$result" |
| 207 | + fi |
| 208 | + |
| 209 | + exit 0 |
| 210 | +fi |
| 211 | + |
| 212 | +# ── everything else (Read, Grep, Glob, NotebookRead, …) ────────────────────── |
| 213 | +deny "$tool_name" |
0 commit comments