Skip to content

Commit 621cf69

Browse files
pterrorclaude
andcommitted
fix(hooks): install committed orchestrator hooks with portable paths
Installs inject-orchestrator-rules.sh (UserPromptSubmit), block-blocking-bash.sh + block-mainsession-exploration.sh (PreToolUse) and their dependency files into tooling/claude-hooks/, wired via ${CLAUDE_PROJECT_DIR} in .claude/settings.json. Replaces reliance on global ~/.claude/settings.json entries. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 669beaa commit 621cf69

9 files changed

Lines changed: 616 additions & 0 deletions

.claude/settings.json

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,35 @@
99
"command": "/home/me/git/rhizone/reincarnate/tooling/claude-hooks/post-history.sh"
1010
}
1111
]
12+
},
13+
{
14+
"matcher": "",
15+
"hooks": [
16+
{
17+
"type": "command",
18+
"command": "${CLAUDE_PROJECT_DIR}/tooling/claude-hooks/inject-orchestrator-rules.sh"
19+
}
20+
]
21+
}
22+
],
23+
"PreToolUse": [
24+
{
25+
"matcher": "Bash",
26+
"hooks": [
27+
{
28+
"type": "command",
29+
"command": "${CLAUDE_PROJECT_DIR}/tooling/claude-hooks/block-blocking-bash.sh"
30+
}
31+
]
32+
},
33+
{
34+
"matcher": "",
35+
"hooks": [
36+
{
37+
"type": "command",
38+
"command": "${CLAUDE_PROJECT_DIR}/tooling/claude-hooks/block-mainsession-exploration.sh"
39+
}
40+
]
1241
}
1342
]
1443
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
#!/usr/bin/env bash
2+
# PreToolUse hook for Bash. Denies commands that never return on their own
3+
# (follow/stream/watch). They hang the session until timeout.
4+
#
5+
# Claude can opt into a long-running command by passing run_in_background:true
6+
# on the Bash tool call — that bypasses the hang because output is streamed.
7+
#
8+
# Jq-free by design: the Claude Code harness doesn't always have jq on PATH
9+
# (on NixOS-from-flake setups it's only in the project devshell). We parse
10+
# the minimal slice we need (tool_input.command) with sed.
11+
12+
set -euo pipefail
13+
14+
input=$(cat)
15+
16+
# Flatten input to one line, then extract the value of "command" inside
17+
# "tool_input". The regex captures an escaped JSON string body:
18+
# (\\\\|\\"|[^"])* — escaped backslash, escaped quote, or any non-quote
19+
# This is enough for the harness's well-defined payload shape. If extraction
20+
# fails (no "command" found, malformed JSON, etc.), $cmd stays empty and the
21+
# hook is a no-op (fail-open).
22+
cmd=$(printf '%s' "$input" \
23+
| tr '\n' ' ' \
24+
| sed -nE 's/.*"command"[[:space:]]*:[[:space:]]*"((\\\\|\\"|[^"])*)".*/\1/p' \
25+
| head -1)
26+
27+
# Strip quoted regions and heredoc bodies from a scan copy so that
28+
# `echo "tail -f"` or `git commit -m "block tail -f"` don't trip the rules.
29+
# This is a 90% heuristic — ANSI-C $'...' quoting and escaped quotes inside
30+
# regular strings can still leak — but it covers the cases that bite.
31+
scan=$(printf '%s' "$cmd" \
32+
| sed -E "s/<<-?[[:space:]]*'?[A-Za-z_][A-Za-z0-9_]*'?.*$//" \
33+
| sed -E 's/"[^"]*"//g' \
34+
| sed -E "s/'[^']*'//g")
35+
36+
reason=""
37+
38+
# tail -f / -F / --follow (catches -f, -fn 100, -F, --follow=name)
39+
if printf '%s' "$scan" | grep -qE '(^|[[:space:];&|])tail\b[^|;&]*([[:space:]]-[a-zA-Z]*[fF][a-zA-Z]*\b|[[:space:]]--follow\b)'; then
40+
reason="tail follow mode"
41+
fi
42+
43+
# journalctl -f / --follow
44+
if [ -z "$reason" ] && printf '%s' "$scan" | grep -qE '(^|[[:space:];&|])journalctl\b[^|;&]*([[:space:]]-[a-zA-Z]*f[a-zA-Z]*\b|[[:space:]]--follow\b)'; then
45+
reason="journalctl follow mode"
46+
fi
47+
48+
# docker/podman/kubectl logs|attach -f / --follow
49+
if [ -z "$reason" ] && printf '%s' "$scan" | grep -qE '(^|[[:space:];&|])(docker|podman|kubectl)\b[^|;&]*\b(logs|attach)\b[^|;&]*([[:space:]]-[a-zA-Z]*f[a-zA-Z]*\b|[[:space:]]--follow\b)'; then
50+
reason="container logs/attach follow mode"
51+
fi
52+
53+
# watch (always blocks)
54+
if [ -z "$reason" ] && printf '%s' "$scan" | grep -qE '(^|[[:space:];&|])watch[[:space:]]'; then
55+
reason="watch"
56+
fi
57+
58+
# entr / fswatch / inotifywait without timeout
59+
if [ -z "$reason" ] && printf '%s' "$scan" | grep -qE '(^|[[:space:];&|])(entr|fswatch)\b'; then
60+
reason="filewatch (entr/fswatch)"
61+
fi
62+
if [ -z "$reason" ] && printf '%s' "$scan" | grep -qE '(^|[[:space:];&|])inotifywait\b' && ! printf '%s' "$scan" | grep -qE '[[:space:]](-t|--timeout)[[:space:]=]'; then
63+
reason="inotifywait without -t timeout"
64+
fi
65+
66+
# ping without -c (count) — finite usage requires -c N
67+
if [ -z "$reason" ] && printf '%s' "$scan" | grep -qE '(^|[[:space:];&|])ping\b' && ! printf '%s' "$scan" | grep -qE '[[:space:]]-c[[:space:]=]?[0-9]'; then
68+
reason="ping without -c N"
69+
fi
70+
71+
# nc / netcat in listen mode without timeout (-w)
72+
if [ -z "$reason" ] && printf '%s' "$scan" | grep -qE '(^|[[:space:];&|])(nc|netcat|ncat)\b[^|;&]*[[:space:]]-[a-zA-Z]*l\b' && ! printf '%s' "$scan" | grep -qE '[[:space:]]-w[[:space:]=]?[0-9]'; then
73+
reason="nc -l without -w timeout"
74+
fi
75+
76+
if [ -n "$reason" ]; then
77+
# Hand-build JSON. The reason text is constant (no shell-special chars),
78+
# so printf %s embedding is safe. We deliberately don't echo $cmd back —
79+
# it could contain quotes/newlines that would need JSON-escaping.
80+
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Refused: %s — these never return and hang the session. Use a snapshot form (--lines N, --since, -c N, --max-events), or pass run_in_background:true to the Bash tool if you actually want a streaming background process."}}\n' "$reason"
81+
fi
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
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"
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#!/usr/bin/env bash
2+
# UserPromptSubmit hook — inject orchestrator rules into the main session only.
3+
#
4+
# Subagents carry a top-level agent_id field in the hook stdin JSON.
5+
# If detected, exit silently (no additionalContext injected).
6+
# If absent (main session), cat orchestrator-rules.md to stdout so the harness
7+
# includes it as additionalContext for the prompt.
8+
#
9+
# No jq, python, or node — pure bash + sed/tr (matches exploration hook pattern).
10+
11+
set -euo pipefail
12+
13+
dir="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")"
14+
input=$(head -c $((1024 * 1024)))
15+
16+
# shellcheck source=lib/agent-id.sh
17+
source "$dir/lib/agent-id.sh"
18+
19+
if is_subagent "$input"; then
20+
exit 0 # subagent — emit nothing
21+
fi
22+
23+
# Main session — inject orchestrator rules as additionalContext
24+
cat "$dir/orchestrator-rules.md"
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
#!/usr/bin/env bash
2+
# lib/agent-id.sh — canonical source for subagent detection
3+
#
4+
# Provides: is_subagent <json>
5+
# Returns 0 (success) if the JSON has a TOP-LEVEL "agent_id" key → is a subagent.
6+
# Returns 1 if no top-level agent_id → is the main session.
7+
#
8+
# Algorithm (pure bash + sed/tr; no awk/jq/python):
9+
# 1. Drop escaped quotes (\") so remaining double-quotes are balanced string delimiters.
10+
# 2. Tag the KEY form "agent_id" <ws>* : with sentinel byte (\001) BEFORE blanking
11+
# strings. A string VALUE "agent_id" is followed by , or } (never :), so only
12+
# real keys get tagged.
13+
# 3. Blank every string's CONTENTS ("..." -> "") so structural chars inside strings vanish.
14+
# 4. Reduce to ONLY { } and the sentinel via tr -cd. This is O(structure), not O(payload).
15+
# 5. Walk the skeleton counting brace depth; sentinel at depth 1 → top-level key → subagent.
16+
#
17+
# Safe to source. No side effects beyond defining is_subagent.
18+
# NOTE: block-mainsession-exploration.sh contains a copy of this logic as has_top_level_agent_id.
19+
# If this function changes, update that inline copy (or refactor it to source this lib).
20+
21+
is_subagent() {
22+
local skeleton
23+
skeleton=$(printf '%s' "$1" \
24+
| sed 's/\\"//g' \
25+
| sed 's/"agent_id"\([[:space:]]*\):/\x01\1:/g' \
26+
| sed 's/"[^"]*"/""/g' \
27+
| tr -cd '{}\001')
28+
local i ch depth=0 n=${#skeleton}
29+
for (( i = 0; i < n; i++ )); do
30+
ch="${skeleton:i:1}"
31+
case "$ch" in
32+
'{') (( depth++ )) ;;
33+
'}') (( depth-- )) ;;
34+
$'\001') (( depth == 1 )) && return 0 ;;
35+
esac
36+
done
37+
return 1
38+
}

0 commit comments

Comments
 (0)