Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions .claude/hooks/delegation-guard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@
# PreToolUse (matcher: Write|Edit|MultiEdit)
# Prevents the root orchestrator from directly writing .rs implementation
# files — forces dispatch to a subagent. Infrastructure files under
# .agents/, .claude/, docs/, and .github/ are always allowed.
# .agents/, .claude/, docs/, and .github/ (relative to the repo root)
# are always allowed.
#
# Subagent context is detected from the .agent_type field of the
# PreToolUse input — Claude Code populates it for every tool call
# originating inside a subagent. The role file written by
# role-marker.sh at SessionStart is consulted as a secondary signal
# (e.g. for harnesses that propagate role through the file rather
# than the per-call payload).

set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
Expand All @@ -11,9 +19,14 @@ source "$SCRIPT_DIR/_lib.sh"
input="$(hook_read_input)"
hook_init "$input"
file_path="$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty' 2>/dev/null || true)"
agent_type="$(printf '%s' "$input" | jq -r '.agent_type // empty' 2>/dev/null || true)"
role="$(hook_read_role)"

# Subagents always allowed to write.
# Subagents always allowed to write. Primary signal is .agent_type
# from the hook input itself; fallback is the role file.
if [ -n "$agent_type" ]; then
hook_allow
fi
case "$role" in
subagent:*) hook_allow ;;
esac
Expand All @@ -24,9 +37,13 @@ case "$file_path" in
*) hook_allow ;;
esac

# Infra exceptions: never block these even from root.
case "$file_path" in
*.claude/*|*.agents/*|*/docs/*|*/.github/*) hook_allow ;;
# Infra exceptions: never block these even from root. Match against
# the repo-relative path so a worktree at .claude/worktrees/<name>/
# does not accidentally match the .claude/ infra prefix on every
# crate file inside that worktree.
rel_path="${file_path#"$HOOK_CWD/"}"
case "$rel_path" in
.claude/*|.agents/*|docs/*|.github/*) hook_allow ;;
esac

if [ "$role" = "root" ] || [ -z "$role" ]; then
Expand Down
22 changes: 12 additions & 10 deletions .claude/hooks/role-marker.sh
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
#!/usr/bin/env bash
# SessionStart hook
# Detects root vs subagent session and writes role into state dir.
# Consumed by delegation-guard.sh.
# Writes role=root into the per-session state file for the root harness.
#
# Subagents do NOT fire SessionStart in Claude Code — they share the
# parent's session. Subagent context is detected at PreToolUse time
# from the .agent_type field of the hook input (Claude Code populates
# it for every tool call originating from a subagent). See
# delegation-guard.sh for the consumer.
#
# This hook therefore only ever runs in a root context and only
# writes role=root. It exists so save-session.sh has a stable place
# to read the role from when summarising the session on Stop.

set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/_lib.sh"

input="$(hook_read_input)"
hook_init "$input"
agent_type="$(printf '%s' "$input" | jq -r '.agent_type // empty' 2>/dev/null || true)"

if [ -n "$agent_type" ]; then
role="subagent:$agent_type"
else
role="root"
fi

mkdir -p "$HOOK_STATE_DIR"
printf '%s' "$role" > "$HOOK_STATE_DIR/${HOOK_SESSION_ID}.role"
printf '%s' "root" > "$HOOK_STATE_DIR/${HOOK_SESSION_ID}.role"

exit 0
139 changes: 139 additions & 0 deletions .claude/hooks/tests/test_delegation_guard_subagent.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
#!/usr/bin/env bash
# End-to-end test for the delegation-guard subagent detection.
#
# Regression test for the bug where role-marker.sh tried to detect
# subagent vs root from the SessionStart payload's .agent_type field,
# but Claude Code never populates that field at SessionStart (subagents
# share the parent's session and don't fire SessionStart at all). The
# role file therefore always said "root", and delegation-guard.sh
# blocked every subagent's .rs edit because it could not tell the
# subagent apart from the root orchestrator.
#
# The fix: delegation-guard.sh reads .agent_type / .agent_id from the
# PreToolUse hook input itself, where Claude Code DOES populate the
# subagent identity. The role file is now only an additional signal
# for backwards compat.

set -euo pipefail

HOOKS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REPO_ROOT="$(cd "$HOOKS_DIR/../.." && pwd)"
STATE_DIR="$REPO_ROOT/.claude/state"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT

cleanup_state() {
local sid="$1"
rm -f "$STATE_DIR/${sid}.role" 2>/dev/null || true
}

fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}

pass() {
printf 'PASS: %s\n' "$1"
}

session_id="sess-deleg-$$"
cleanup_state "$session_id"

# --- Step 1: SessionStart on the root harness writes role=root.
# Claude Code never sends agent_type at SessionStart — that field is
# only present in PreToolUse / PostToolUse payloads when a subagent
# made the call.
session_start_input="$(jq -n \
--arg sid "$session_id" \
--arg cwd "$REPO_ROOT" \
'{session_id: $sid, cwd: $cwd, hook_event_name: "SessionStart"}')"

env -u CLAUDE_SESSION_ID \
CLAUDE_PROJECT_DIR="$REPO_ROOT" \
bash "$HOOKS_DIR/role-marker.sh" <<<"$session_start_input" >/dev/null

role_file="$STATE_DIR/${session_id}.role"
[ -f "$role_file" ] || fail "role-marker did not write $role_file"
role="$(cat "$role_file")"
[ "$role" = "root" ] || fail "expected role=root at SessionStart, got role=$role"
pass "role-marker writes role=root at SessionStart (no agent_type in payload)"

# --- Step 2: PreToolUse for a Rust file from the root context (no
# agent_type in input) MUST be blocked.
root_edit_input="$(jq -n \
--arg sid "$session_id" \
--arg cwd "$REPO_ROOT" \
--arg fp "$REPO_ROOT/crates/plumb-core/src/lib.rs" \
'{session_id: $sid, cwd: $cwd, hook_event_name: "PreToolUse", tool_name: "Write", tool_input: {file_path: $fp, content: "x"}}')"

guard_output="$(env -u CLAUDE_SESSION_ID \
CLAUDE_PROJECT_DIR="$REPO_ROOT" \
bash "$HOOKS_DIR/delegation-guard.sh" <<<"$root_edit_input")"
decision="$(printf '%s' "$guard_output" | jq -r '.decision // empty' 2>/dev/null || true)"
[ "$decision" = "block" ] || fail "guard should BLOCK root .rs edit, got: $guard_output"
pass "guard blocks root orchestrator from editing crates/*/src/*.rs"

# --- Step 3: PreToolUse for the same Rust file from a subagent context
# (agent_type populated by Claude Code) MUST be allowed.
sub_edit_input="$(jq -n \
--arg sid "$session_id" \
--arg cwd "$REPO_ROOT" \
--arg fp "$REPO_ROOT/crates/plumb-core/src/lib.rs" \
'{session_id: $sid, cwd: $cwd, hook_event_name: "PreToolUse", tool_name: "Write", tool_input: {file_path: $fp, content: "x"}, agent_id: "abc123", agent_type: "01-implementer"}')"

guard_output="$(env -u CLAUDE_SESSION_ID \
CLAUDE_PROJECT_DIR="$REPO_ROOT" \
bash "$HOOKS_DIR/delegation-guard.sh" <<<"$sub_edit_input")"
decision="$(printf '%s' "$guard_output" | jq -r '.decision // empty' 2>/dev/null || true)"
[ "$decision" = "allow" ] || fail "guard should ALLOW subagent .rs edit (agent_type=01-implementer), got: $guard_output"
pass "guard allows subagent (agent_type populated) to edit crates/*/src/*.rs"

# --- Step 4: Non-Rust files always allowed, even from root.
root_md_input="$(jq -n \
--arg sid "$session_id" \
--arg cwd "$REPO_ROOT" \
--arg fp "$REPO_ROOT/README.md" \
'{session_id: $sid, cwd: $cwd, hook_event_name: "PreToolUse", tool_name: "Write", tool_input: {file_path: $fp, content: "x"}}')"

guard_output="$(env -u CLAUDE_SESSION_ID \
CLAUDE_PROJECT_DIR="$REPO_ROOT" \
bash "$HOOKS_DIR/delegation-guard.sh" <<<"$root_md_input")"
decision="$(printf '%s' "$guard_output" | jq -r '.decision // empty' 2>/dev/null || true)"
[ "$decision" = "allow" ] || fail "guard should ALLOW root edit of non-Rust file, got: $guard_output"
pass "guard allows root edit of non-Rust files"

# --- Step 5: Infra path exceptions (.claude/, .agents/, docs/, .github/)
# always allowed even from root.
for path in ".claude/hooks/role-marker.sh" ".agents/rules/foo.md" "docs/src/intro.md" ".github/workflows/ci.yml"; do
infra_input="$(jq -n \
--arg sid "$session_id" \
--arg cwd "$REPO_ROOT" \
--arg fp "$REPO_ROOT/$path" \
'{session_id: $sid, cwd: $cwd, hook_event_name: "PreToolUse", tool_name: "Edit", tool_input: {file_path: $fp, content: "x"}}')"
guard_output="$(env -u CLAUDE_SESSION_ID \
CLAUDE_PROJECT_DIR="$REPO_ROOT" \
bash "$HOOKS_DIR/delegation-guard.sh" <<<"$infra_input")"
decision="$(printf '%s' "$guard_output" | jq -r '.decision // empty' 2>/dev/null || true)"
[ "$decision" = "allow" ] || fail "guard should ALLOW root infra edit ($path), got: $guard_output"
done
pass "guard allows root edits of infra files (.claude/, .agents/, docs/, .github/)"

# --- Step 6: Backwards-compat — if a role file exists with
# subagent:<name>, guard still allows. This preserves the existing
# escape hatch for harnesses that DO populate agent_type at
# SessionStart (future Claude Code versions) or for manual override.
echo "subagent:01-implementer" > "$role_file"
sub_via_role_input="$(jq -n \
--arg sid "$session_id" \
--arg cwd "$REPO_ROOT" \
--arg fp "$REPO_ROOT/crates/plumb-core/src/lib.rs" \
'{session_id: $sid, cwd: $cwd, hook_event_name: "PreToolUse", tool_name: "Write", tool_input: {file_path: $fp, content: "x"}}')"
guard_output="$(env -u CLAUDE_SESSION_ID \
CLAUDE_PROJECT_DIR="$REPO_ROOT" \
bash "$HOOKS_DIR/delegation-guard.sh" <<<"$sub_via_role_input")"
decision="$(printf '%s' "$guard_output" | jq -r '.decision // empty' 2>/dev/null || true)"
[ "$decision" = "allow" ] || fail "guard should ALLOW when role file says subagent:*, got: $guard_output"
pass "guard still honors role-file subagent marker for backwards compat"

cleanup_state "$session_id"
pass "all delegation-guard subagent tests passed"
Loading