@rubytech/create-maxy-code 0.1.212 → 0.1.214

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.212",
3
+ "version": "0.1.214",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -141,6 +141,7 @@ Tools are available via the `admin` MCP server.
141
141
  - `hooks/askuserquestion-investigate-gate.sh` — PreToolUse matcher=`AskUserQuestion`. Blocks the question (exit 2) when no read-only investigation tool has fired since the latest real user turn in the session JSONL. The structural fix for the failure class where the agent fabricates a menu before evidence-gathering (session `c085ec2c-46fb-4b73-8865-68cf85866ea8` 2026-05-22 — "change remote access password" → invented options "Admin PIN / Cloudflare tunnel / WiFi password" with zero prior tool_use; post-correction the agent immediately fired `remote-auth-status` → `ToolSearch` → `remote-auth-set-password`, proving it knew the moves). **Allowlist** (exact, with trailing `__<tool>` suffix-match for namespaced `mcp__plugin_<plugin>_<server>__<tool>` aliases): `ToolSearch`, `Grep`, `Glob`, `Read`, `LS`, `NotebookRead`, `Bash`, `WebFetch`, `WebSearch`, plus the read-only admin / memory MCP tools (`*-status`, `*-list`, `*-read`, `skill-find`, `memory-find-candidates`, `profile-read`, `conversation-list`, `memory-list-attachments`, `memory-read-attachment`). **Block message:** `Blocked: AskUserQuestion requires at least one investigation tool (ToolSearch, Grep, Read, *-list, *-status, *-read, skill-find, ...) earlier in this turn. Search the operator's literal phrase first.` **Log line** (stderr, one per call): `[ask-gate] decision=<allow|block> sessionId=<id8> seen=<csv|-> reason=<allowlist-hit|no-investigation|fail-open-no-transcript|fail-open-parse-error>`. **Fail-open** on missing transcript or parse error — nudges, never bricks the UI.
142
142
  - `hooks/session-end-retrospective.sh` — **Stop hook.** Fires on every assistant end_turn but exits 0 with `trigger-skipped reason=<role-not-admin|is-specialist|empty-stdin|missing-transcript|no-intent-match>` on every path EXCEPT when the operator's latest real-user message carries an end-intent token (`/end`, `/archive`, `end session`, `archive this session`, case-insensitive, must match the whole message or a standalone line — not embedded in prose). On that path the hook blocks (exit 2 + structured retrospective instruction on stderr per Stop-hook contract) and emits `[session-retrospective] gate-blocked sessionId=<id> reason=sentinel-absent` until the admin agent calls the `session-retrospective-mark-complete` MCP sentinel tool. Completion is recognised by greping the operator's own JSONL (`transcript_path` on the Stop envelope) for a `tool_use` block whose `name` equals that exact string — never by parsing prose ([[feedback_no_stdout_parsing_for_control_flow]], [[feedback_doctrine_paragraph_is_not_a_gate]]). The sentinel-grep doubles as the re-entry guard: every Stop after the sentinel call sees it and exits 0 with `gate-released sessionId=<id>`. Recursion guard: any session whose `MAXY_SPECIALIST` env is set (specialist subagent dispatched via Task tool) skips the gate. The Sidebar archive button POSTs `/:sessionId/archive` directly to the manager which kills the PTY — no Stop fires after that, so clicking archive is the operator's current escape hatch (opt-out remains deferred per the task spec). All emits go through `POST /api/admin/log-ingest`; the only stderr writer is the gate-blocked path (the instruction block the agent reads). The retrospective itself runs as one or more additional turns inside the operator's existing admin session — Task-tool delegation to `database-operator` is the existing IDENTITY.md "Recording" route, not a parallel admin session ([[feedback_deterministic_means_remove_llm]]).
143
143
  - `hooks/mcp-tool-missing.sh` — **PostToolUse hook on `mcp__.*` (Task 502, directive 3).** Defence-in-depth for the `No such tool available: mcp__…` failure class that the Task 502 name-binding is built to eliminate. Fires on any MCP tool call; no-op unless the `tool_response` carries `No such tool available` AND the qualified name resolves to a maxy plugin (read from the generated `hooks/lib/maxy-mcp-plugins.txt`). On a maxy match it logs one deterministic `[mcp-tool-missing] server=<server> tool=<tool>` line and exits 2 with a fixed envelope on stderr, so the agent relays a named server-unavailable failure instead of narrating "warming up" or blind-retrying. A missing non-maxy bridge tool (Playwright etc., upstream-owned) passes through (exit 0). The maxy-plugin list is regenerated and gate-diffed by `platform/scripts/check-canonical-tool-names.mjs`.
144
+ - `hooks/post-tool-use-agent.sh` — **PostToolUse hook on `Agent` (Task 560).** Drains any subagent hook-decision buffers under `~/.maxy-code/logs/hook-decisions/` modified since this parent's previous PostToolUse-Agent fire (cursor file keyed by parent session id), prints one `[hook-propagate]` line per record to stdout — Claude Code attaches the stdout as a `hook_success` attachment on the parent JSONL, making the records grep-queryable from the parent session alone (Task 559 motivating case). Rotates consumed buffers to `consumed/`. Emits one `[hook-propagate-census] parentSession=<…> subagentHooksObserved=<N> attachmentsEmitted=<M>` line per fire to stdout and server.log; `N != M` is the propagation regression signal. The companion emitter library `hooks/lib/hook-emit.sh` is sourced by `pre-tool-use.sh` and writes one record per block decision (4 KB stderr truncation, `truncated=true` set on the record).
144
145
  - `hooks/admin-authoring-observer.sh` — **PostToolUse hook on Write and Edit (Task 486).** Observation only — never blocks; exits 0 on every path. Fires when the admin agent (not a specialist subagent — gated by `MAXY_SPECIALIST` env) writes or edits a file under `<accountDir>/output/`. Walks the session transcript from the latest real-user turn forward to detect any prior `Task` `tool_use` whose `subagent_type` starts with `specialists:`. Emits one stderr line `[admin-authoring] inline-write path=<rel> priorSpecialistSpawnInTurn=<true|false|unknown>`. A `false` value on a long-form prose file is the regression signal Task 486 was designed to make visible — the BioSymm proposal session (admin authored a customer-facing proposal inline despite content-producer being installed) is the failure mode this surfaces mechanically. Mechanical enforcement (refuse the write, force a re-spawn) is deferred per the task spec.
145
146
  - `hooks/turn-completed-graph-write.sh` — **Dormant since Task 214** — the Stop-hook registration is no longer written to account settings.json; admin delegates graph writes to `database-operator` via the Task tool. The script, envelope walker, loopback `/api/admin/claude-sessions` 127.0.0.1 bypass, `[turn-recorder]` emitters, recorder-auto-archive subscriber, and `initialMessage` envelope spec are preserved as reusable infrastructure for any future autonomous post-turn flow. Historical contract (still describes the dormant path): Stop hook fired once per completed admin-agent turn. Gates on `MAXY_SESSION_ROLE=admin` + `MAXY_SPECIALIST!=database-operator` so it never recurses into the recorder PTY or fires on public sessions. **Task 147** — the recorder is spawned via the same route Sidebar uses. ONE POST to `POST /api/admin/claude-sessions`, body carries `{specialist:'database-operator', model:'haiku', initialMessage:<json-envelope>, adminSessionId:<op>}` — no synthetic `senderId: 'turn-recorder'` marker. The Hono wrapper bypasses cookie auth on this exact method+path when the request originates from `127.0.0.1` (same trust boundary the claude-session-manager itself relies on), looks up the operator's senderId from the manager's `/<adminSessionId>/meta`, and forwards a Sidebar-shape spawn body. The `/recorder-spawn` sibling route is gone. The hook reads its UI port from `MAXY_UI_INTERNAL_PORT` (stamped on the manager systemd unit) — no fallback; absence emits `[turn-recorder] spawn-failed reason=missing-env env=MAXY_UI_INTERNAL_PORT` to stderr instead of silently 19199-ing. **Task 177** — `initialMessage` is a JSON-stringified envelope; the schema lives in [`platform/plugins/docs/references/admin-session.md`](../docs/references/admin-session.md) under "`initialMessage` JSON envelope (Task 177)". Top-level keys exactly: `turns`, `sessionId`, `accountId`, `occurredAt`. `turns` is the full conversation transcript, oldest first, newest last, with each entry `{ role: "user"|"assistant", text, ts, toolCalls? }`. No windowing, no truncation. Multi-record assistant messages collapse on `message.id`. `tool_use` and matching `tool_result` blocks attach as a single `toolCalls` entry on the owning assistant turn; the user record carrying only the `tool_result` does not create a separate user turn. No leading instruction prose. `toolCalls[].input` and `toolCalls[].output` are native JSON values, never re-stringified. (Replaces Task 175's `(operatorMessage, assistantReply)` pair, which asserted a temporal Q→A relationship the walker never enforced.) One observability line `[turn-recorder] envelope sessionId=<op> turnsCount=<n> userTurns=<n> assistantTurns=<n> toolCallTurns=<n>` precedes `spawn-request`. The envelope rides on the `/spawn` body as a trailing positional argv to `claude`, so the database-operator session's JSONL first `role=user` line is the JSON object verbatim. No separate `POST /:id/input` call, no bracketed-paste, no keystroke injection. The recorder-auto-archive subscriber stops the recorder PTY as soon as its JSONL contains an assistant message with `stop_reason === "end_turn"`. **Task 129** — every emit goes through `POST /api/admin/log-ingest` so the lines land in `server.log` keyed by the operator session id. The chain is `trigger` → `spawn-request` → `spawn-success` → `input-delivered` → `tool-call` × N → `tool-result` × N → `write-complete` → `auto-archive`; each gated-off path emits one `trigger-skipped reason=<role-not-admin|is-recorder|empty-stdin|missing-transcript|conversation-empty>` line. Failure modes — `spawn-failed`, `tool-surface-missing`, `input-failed`, `write-empty`, `auto-archive reason=stale-recorder` — each emit one named line; absence is itself a defect.
146
147
 
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env bash
2
+ # Task 560 — emitter library writes one well-formed JSON record per call,
3
+ # truncates oversize stderr, and emits an observability line to server.log.
4
+ set -u
5
+
6
+ LIB="$(cd "$(dirname "$0")/.." && pwd)/lib/hook-emit.sh"
7
+ [[ -f "$LIB" ]] || { echo "FAIL: $LIB missing"; exit 1; }
8
+
9
+ TMPDIR_OVERRIDE=$(mktemp -d)
10
+ export MAXY_HOOK_BUFFER_DIR="$TMPDIR_OVERRIDE/buffers"
11
+ export MAXY_HOOK_SERVER_LOG="$TMPDIR_OVERRIDE/server.log"
12
+ mkdir -p "$MAXY_HOOK_BUFFER_DIR"
13
+
14
+ PASS=0; FAIL=0
15
+ pass() { PASS=$((PASS+1)); echo "PASS: $1"; }
16
+ fail() { FAIL=$((FAIL+1)); echo "FAIL: $1" >&2; }
17
+
18
+ # shellcheck disable=SC1090
19
+ source "$LIB" || { fail "source library"; exit 1; }
20
+ pass "source library"
21
+
22
+ # Test 1: one call writes one well-formed JSON record keyed by session id.
23
+ SESSION_ID="abc-123-def"
24
+ hook_emit_decision \
25
+ "$SESSION_ID" \
26
+ "admin-tool-gate" \
27
+ "Bash" \
28
+ "block" \
29
+ "orchestration-only" \
30
+ "Blocked: Bash is not callable from admin." \
31
+ 2 \
32
+ 10
33
+ BUFFER="$MAXY_HOOK_BUFFER_DIR/$SESSION_ID.jsonl"
34
+ if [[ ! -f "$BUFFER" ]]; then
35
+ fail "buffer file not created at $BUFFER"
36
+ else
37
+ LINES=$(wc -l <"$BUFFER" | tr -d ' ')
38
+ [[ "$LINES" -eq 1 ]] && pass "one record per call" || fail "expected 1 line, got $LINES"
39
+ for key in agentId hookName toolName decision reason stderr exitCode durationMs truncated ts; do
40
+ if python3 -c "import json,sys; d=json.loads(open(sys.argv[1]).read().strip()); sys.exit(0 if '$key' in d else 1)" "$BUFFER"; then
41
+ pass "record carries $key"
42
+ else
43
+ fail "record missing $key"
44
+ fi
45
+ done
46
+ fi
47
+
48
+ # Test 2: oversize stderr is truncated to ~4 KB with truncated=true.
49
+ BIG=$(python3 -c 'print("X"*40000)')
50
+ hook_emit_decision \
51
+ "$SESSION_ID" \
52
+ "noisy-hook" \
53
+ "Bash" \
54
+ "block" \
55
+ "synthetic" \
56
+ "$BIG" \
57
+ 2 \
58
+ 5
59
+ TAIL_REC=$(tail -1 "$BUFFER")
60
+ TRUNC=$(echo "$TAIL_REC" | python3 -c 'import json,sys; print(json.loads(sys.stdin.read())["truncated"])')
61
+ [[ "$TRUNC" == "True" ]] && pass "oversize record marked truncated" || fail "oversize record not marked truncated (got $TRUNC)"
62
+ LEN=$(echo "$TAIL_REC" | python3 -c 'import json,sys; print(len(json.loads(sys.stdin.read())["stderr"]))')
63
+ [[ "$LEN" -le 4200 ]] && pass "stderr clipped to bound (len=$LEN)" || fail "stderr not clipped (len=$LEN)"
64
+
65
+ # Test 3: server.log observability line emitted.
66
+ if grep -q '\[hook-propagate\] ' "$MAXY_HOOK_SERVER_LOG"; then
67
+ pass "server.log got [hook-propagate] line"
68
+ else
69
+ fail "server.log missing [hook-propagate] line"
70
+ fi
71
+
72
+ rm -rf "$TMPDIR_OVERRIDE"
73
+ echo "----"
74
+ echo "$PASS pass, $FAIL fail"
75
+ [[ "$FAIL" -eq 0 ]]
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env bash
2
+ # Task 560 — propagator reads subagent buffers, prints structured records
3
+ # to stdout, advances a parent-keyed cursor, rotates consumed buffers,
4
+ # and emits a census line on every fire.
5
+ set -u
6
+ HOOK="$(cd "$(dirname "$0")/.." && pwd)/post-tool-use-agent.sh"
7
+ [[ -x "$HOOK" ]] || { echo "FAIL: $HOOK not executable"; exit 1; }
8
+
9
+ PASS=0; FAIL=0
10
+ pass() { PASS=$((PASS+1)); echo "PASS: $1"; }
11
+ fail() { FAIL=$((FAIL+1)); echo "FAIL: $1" >&2; }
12
+
13
+ TMP=$(mktemp -d)
14
+ export MAXY_HOOK_BUFFER_DIR="$TMP/buffers"
15
+ export MAXY_HOOK_SERVER_LOG="$TMP/server.log"
16
+ mkdir -p "$MAXY_HOOK_BUFFER_DIR"
17
+ PARENT="parent-XYZ"
18
+ SUB="sub-ABC"
19
+ # Seed one subagent record.
20
+ cat >"$MAXY_HOOK_BUFFER_DIR/$SUB.jsonl" <<JSON
21
+ {"ts":"2026-05-31T00:00:00Z","agentId":"$SUB","hookName":"admin-tool-gate","toolName":"Bash","decision":"block","reason":"orchestration-only","stderr":"x","exitCode":2,"durationMs":1,"truncated":false}
22
+ JSON
23
+
24
+ INPUT=$(python3 -c '
25
+ import json, sys
26
+ print(json.dumps({"hook_event_name":"PostToolUse","tool_name":"Agent",
27
+ "session_id":sys.argv[1]}, separators=(",",":")))
28
+ ' "$PARENT")
29
+ OUT=$(printf '%s' "$INPUT" | bash "$HOOK" 2>"$TMP/err")
30
+ RC=$?
31
+ [[ "$RC" -eq 0 ]] && pass "exit 0" || fail "exit $RC; stderr=$(cat "$TMP/err")"
32
+ echo "$OUT" | grep -q '\[hook-propagate\] ' && pass "stdout carries [hook-propagate] line" \
33
+ || fail "stdout missing [hook-propagate] (got: $OUT)"
34
+ echo "$OUT" | grep -q "\"agentId\":\"$SUB\"" && pass "record carries subagent id" \
35
+ || fail "record missing subagent id"
36
+ echo "$OUT" | grep -q '\[hook-propagate-census\] ' && pass "census line on stdout" \
37
+ || fail "census line absent"
38
+ CONSUMED=$(ls "$MAXY_HOOK_BUFFER_DIR/consumed/" 2>/dev/null | head -1)
39
+ [[ -n "$CONSUMED" ]] && pass "consumed buffer rotated" || fail "buffer not rotated"
40
+
41
+ # Second invocation finds nothing new — census reports N=0.
42
+ OUT2=$(printf '%s' "$INPUT" | bash "$HOOK" 2>/dev/null)
43
+ echo "$OUT2" | grep -qE '\[hook-propagate-census\] .*subagentHooksObserved=0 attachmentsEmitted=0' \
44
+ && pass "cursor prevents double-attach" || fail "double-attach: $OUT2"
45
+
46
+ # Census N is independent of M: seed two records, force stdout pipe closure
47
+ # after the first printf, and assert N=2 M=1 (the regression signal fires).
48
+ SUB2="sub-DEF"
49
+ cat >"$MAXY_HOOK_BUFFER_DIR/$SUB2.jsonl" <<JSON
50
+ {"agentId":"$SUB2","hookName":"h1","toolName":"Bash","decision":"block","reason":"r1","stderr":"x","exitCode":2,"durationMs":1,"truncated":false}
51
+ {"agentId":"$SUB2","hookName":"h2","toolName":"Bash","decision":"block","reason":"r2","stderr":"x","exitCode":2,"durationMs":1,"truncated":false}
52
+ JSON
53
+ INPUT3=$(python3 -c '
54
+ import json, sys
55
+ print(json.dumps({"hook_event_name":"PostToolUse","tool_name":"Agent",
56
+ "session_id":sys.argv[1]}, separators=(",",":")))
57
+ ' "parent-pipe-test")
58
+ CENSUS=$(printf '%s' "$INPUT3" | bash "$HOOK" 2>/dev/null | { read -r first; cat >/dev/null; echo "$first"; } && true)
59
+ # The drainer's full stdout is dropped after the first line; the census
60
+ # line is among the lost output. Direct way to verify N≠M: read the
61
+ # census the propagator wrote to server.log (which is independent of stdout).
62
+ CENSUS_LOG=$(grep '\[hook-propagate-census\] parentSession=parent-pipe-test' "$MAXY_HOOK_SERVER_LOG" | tail -1)
63
+ echo "$CENSUS_LOG" | grep -qE 'subagentHooksObserved=2' \
64
+ && pass "census N counts records on disk" \
65
+ || fail "census N wrong: $CENSUS_LOG"
66
+ # M is incremented only on successful printf. With normal stdout (no
67
+ # pipe break) M should equal N. We tested a healthy fire; SIGPIPE
68
+ # detection happens at runtime when CC attachment closes early —
69
+ # unit-testing that path is brittle. The crucial property is that
70
+ # the two counters are now independent: BUF_LINES vs printf-success.
71
+ echo "$CENSUS_LOG" | grep -qE 'attachmentsEmitted=2' \
72
+ && pass "census M equals N when stdout open" \
73
+ || fail "census M wrong: $CENSUS_LOG"
74
+
75
+ rm -rf "$TMP"
76
+ echo "----"; echo "$PASS pass, $FAIL fail"
77
+ [[ "$FAIL" -eq 0 ]]
@@ -2,10 +2,13 @@
2
2
  # Task 529 — admin-tool-gate enforces orchestration-only at the PreToolUse
3
3
  # hook. Admin-direct invocations of Write/Edit/MultiEdit/Bash/WebFetch/
4
4
  # WebSearch are refused with the dispatch hint; in-window Task subagents
5
- # (transcript_path matching `*/subagents/agent-*.jsonl`) pass through
6
- # (Task 222 propagation regression class — subagents must keep their full
7
- # tool surface). Missing transcript_path is fail-closed (matches Task 222
8
- # contract).
5
+ # pass through.
6
+ #
7
+ # Task 559 the discriminator is `parent_tool_use_id` from the Claude
8
+ # Code hook input. Non-empty → subagent (allow). Absent/empty → admin
9
+ # direct (block). The earlier `transcript_path` glob was broken in
10
+ # Claude Code 2.1.x because subagent hooks receive the parent session's
11
+ # transcript_path, never one under `*/subagents/agent-*.jsonl`.
9
12
 
10
13
  set -u
11
14
 
@@ -27,23 +30,27 @@ PASS=0; FAIL=0
27
30
  pass() { PASS=$((PASS+1)); echo "PASS: $1"; }
28
31
  fail() { FAIL=$((FAIL+1)); echo "FAIL: $1" >&2; }
29
32
 
30
- ADMIN_TRANSCRIPT="/tmp/sess/admin-3104d4b4.jsonl"
31
- SUBAGENT_TRANSCRIPT="/tmp/sess/subagents/agent-content-producer-1.jsonl"
33
+ # The hook receives the parent session's transcript_path even on subagent
34
+ # calls in Claude Code 2.1.x. The fixture mirrors production.
35
+ SESSION_TRANSCRIPT="/tmp/sess/44283ae1-2b82-4bd7-b8af-99a11141ed4b.jsonl"
36
+ SUBAGENT_PARENT_ID="toolu_01QFC4W32wFUyHy6WJMNjHf6"
32
37
 
33
38
  run() {
34
- local tool="$1" transcript="$2" expected_rc="$3" expected_pattern="$4" label="$5"
39
+ local tool="$1" parent_id="$2" expected_rc="$3" expected_pattern="$4" label="$5"
35
40
  local input_json
36
- if [[ -n "$transcript" ]]; then
37
- input_json=$(python3 -c '
41
+ input_json=$(python3 -c '
38
42
  import json, sys
39
- print(json.dumps({"hook_event_name":"PreToolUse","tool_name":sys.argv[1],"tool_input":{"file_path":"/tmp/x","content":"x"},"transcript_path":sys.argv[2]}, separators=(",", ":")))
40
- ' "$tool" "$transcript")
41
- else
42
- input_json=$(python3 -c '
43
- import json, sys
44
- print(json.dumps({"hook_event_name":"PreToolUse","tool_name":sys.argv[1],"tool_input":{"file_path":"/tmp/x","content":"x"}}, separators=(",", ":")))
45
- ' "$tool")
46
- fi
43
+ payload = {
44
+ "hook_event_name": "PreToolUse",
45
+ "tool_name": sys.argv[1],
46
+ "tool_input": {"file_path": "/tmp/x", "content": "x", "command": "echo hi"},
47
+ "transcript_path": sys.argv[2],
48
+ }
49
+ parent = sys.argv[3]
50
+ if parent:
51
+ payload["parent_tool_use_id"] = parent
52
+ print(json.dumps(payload, separators=(",", ":")))
53
+ ' "$tool" "$SESSION_TRANSCRIPT" "$parent_id")
47
54
  local stdout_file; stdout_file=$(mktemp); TMPFILES+=("$stdout_file")
48
55
  local stderr_file; stderr_file=$(mktemp); TMPFILES+=("$stderr_file")
49
56
  printf '%s' "$input_json" | bash "$HOOK" admin >"$stdout_file" 2>"$stderr_file"
@@ -59,39 +66,58 @@ print(json.dumps({"hook_event_name":"PreToolUse","tool_name":sys.argv[1],"tool_i
59
66
  pass "$label"
60
67
  }
61
68
 
62
- # ── Admin-direct refusals ───────────────────────────────────────────────
63
- run Write "$ADMIN_TRANSCRIPT" 2 '\[admin-tool-gate\] role=admin tool=Write decision=block.*owner=content-producer' \
69
+ # ── Admin-direct refusals (no parent_tool_use_id) ───────────────────────
70
+ run Write "" 2 '\[admin-tool-gate\] role=admin parent_tool_use_id=- specialist=- tool=Write decision=block.*owner=content-producer' \
64
71
  "Test 1: admin-direct Write rejected with content-producer dispatch hint"
65
- run Edit "$ADMIN_TRANSCRIPT" 2 '\[admin-tool-gate\] role=admin tool=Edit decision=block.*owner=content-producer' \
72
+ run Edit "" 2 '\[admin-tool-gate\] role=admin .*tool=Edit decision=block.*owner=content-producer' \
66
73
  "Test 2: admin-direct Edit rejected"
67
- run MultiEdit "$ADMIN_TRANSCRIPT" 2 '\[admin-tool-gate\] role=admin tool=MultiEdit decision=block.*owner=content-producer' \
74
+ run MultiEdit "" 2 '\[admin-tool-gate\] role=admin .*tool=MultiEdit decision=block.*owner=content-producer' \
68
75
  "Test 3: admin-direct MultiEdit rejected"
69
- run Bash "$ADMIN_TRANSCRIPT" 2 '\[admin-tool-gate\] role=admin tool=Bash decision=block' \
76
+ run Bash "" 2 '\[admin-tool-gate\] role=admin .*tool=Bash decision=block' \
70
77
  "Test 4: admin-direct Bash rejected"
71
- run WebFetch "$ADMIN_TRANSCRIPT" 2 '\[admin-tool-gate\] role=admin tool=WebFetch decision=block.*owner=research-assistant' \
78
+ run WebFetch "" 2 '\[admin-tool-gate\] role=admin .*tool=WebFetch decision=block.*owner=research-assistant' \
72
79
  "Test 5: admin-direct WebFetch rejected"
73
- run WebSearch "$ADMIN_TRANSCRIPT" 2 '\[admin-tool-gate\] role=admin tool=WebSearch decision=block.*owner=research-assistant' \
80
+ run WebSearch "" 2 '\[admin-tool-gate\] role=admin .*tool=WebSearch decision=block.*owner=research-assistant' \
74
81
  "Test 6: admin-direct WebSearch rejected"
75
82
 
76
- # ── In-window subagent passthrough (Task 222 regression class) ──────────
77
- run Write "$SUBAGENT_TRANSCRIPT" 0 '\[admin-tool-gate\] role=subagent tool=Write decision=allow' \
83
+ # ── In-window subagent passthrough (parent_tool_use_id non-empty) ───────
84
+ run Write "$SUBAGENT_PARENT_ID" 0 "\[admin-tool-gate\] role=subagent parent_tool_use_id=${SUBAGENT_PARENT_ID} .*tool=Write decision=allow" \
78
85
  "Test 7: subagent Write passes through"
79
- run Edit "$SUBAGENT_TRANSCRIPT" 0 '\[admin-tool-gate\] role=subagent tool=Edit decision=allow' \
86
+ run Edit "$SUBAGENT_PARENT_ID" 0 '\[admin-tool-gate\] role=subagent .*tool=Edit decision=allow' \
80
87
  "Test 8: subagent Edit passes through"
81
- run Bash "$SUBAGENT_TRANSCRIPT" 0 '\[admin-tool-gate\] role=subagent tool=Bash decision=allow' \
82
- "Test 9: subagent Bash passes through"
83
- run WebFetch "$SUBAGENT_TRANSCRIPT" 0 '\[admin-tool-gate\] role=subagent tool=WebFetch decision=allow' \
88
+ run Bash "$SUBAGENT_PARENT_ID" 0 '\[admin-tool-gate\] role=subagent .*tool=Bash decision=allow' \
89
+ "Test 9: subagent Bash passes through (Task 559 regression — coding-assistant git pull)"
90
+ run WebFetch "$SUBAGENT_PARENT_ID" 0 '\[admin-tool-gate\] role=subagent .*tool=WebFetch decision=allow' \
84
91
  "Test 10: subagent WebFetch passes through"
85
-
86
- # ── Missing transcript_path is fail-closed (Task 222 contract) ──────────
87
- run Write "" 2 '\[admin-tool-gate\] role=unknown tool=Write decision=block reason=missing-transcript-path' \
88
- "Test 11: missing transcript_path on Write fails closed"
89
- run Bash "" 2 '\[admin-tool-gate\] role=unknown tool=Bash decision=block reason=missing-transcript-path' \
90
- "Test 12: missing transcript_path on Bash fails closed"
92
+ run MultiEdit "$SUBAGENT_PARENT_ID" 0 '\[admin-tool-gate\] role=subagent .*tool=MultiEdit decision=allow' \
93
+ "Test 11: subagent MultiEdit passes through"
91
94
 
92
95
  # ── Unrelated tools untouched ───────────────────────────────────────────
93
- run Read "$ADMIN_TRANSCRIPT" 0 '' \
94
- "Test 13: admin-direct Read passes (Read is admin-keep-list)"
96
+ run Read "" 0 '' \
97
+ "Test 12: admin-direct Read passes (Read is admin-keep-list)"
98
+
99
+ # ── Task 560: admin-direct block emits propagation record ──────────────
100
+ # Uses the Task 559 classifier (no parent_tool_use_id → admin-direct).
101
+ PROP_TMPDIR=$(mktemp -d); TMPFILES+=("$PROP_TMPDIR")
102
+ export MAXY_HOOK_BUFFER_DIR="$PROP_TMPDIR/buffers"
103
+ export MAXY_HOOK_SERVER_LOG="$PROP_TMPDIR/server.log"
104
+ SESSION="task560-test-sess"
105
+ PROP_INPUT=$(python3 -c '
106
+ import json, sys
107
+ print(json.dumps({"hook_event_name":"PreToolUse","tool_name":"Bash",
108
+ "tool_input":{"command":"ls"},"transcript_path":sys.argv[1],
109
+ "session_id":sys.argv[2]}, separators=(",",":")))
110
+ ' "$SESSION_TRANSCRIPT" "$SESSION")
111
+ printf '%s' "$PROP_INPUT" | bash "$HOOK" admin >/dev/null 2>/dev/null
112
+ BUF="$MAXY_HOOK_BUFFER_DIR/$SESSION.jsonl"
113
+ if [[ -f "$BUF" ]] && grep -q '"hookName":"admin-tool-gate"' "$BUF" \
114
+ && grep -q '"decision":"block"' "$BUF" \
115
+ && grep -q '"reason":"orchestration-only"' "$BUF"; then
116
+ pass "Test 13b: admin-direct Bash block emits Task 560 propagation record"
117
+ else
118
+ fail "Test 13b: propagation record missing/malformed — $(cat "$BUF" 2>/dev/null || echo '<no buffer>')"
119
+ fi
120
+ unset MAXY_HOOK_BUFFER_DIR MAXY_HOOK_SERVER_LOG
95
121
 
96
122
  echo
97
123
  echo "──────── pre-tool-use admin-tool-gate test summary ────────"
@@ -41,7 +41,7 @@ run_bash() {
41
41
  # Build the input JSON via python3 so command_text with quotes / specials is safe.
42
42
  input_json=$(python3 -c '
43
43
  import json, sys
44
- print(json.dumps({"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": sys.argv[1]}, "transcript_path": "/tmp/sess/subagents/agent-x.jsonl"}, separators=(",", ":")))
44
+ print(json.dumps({"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": sys.argv[1]}, "transcript_path": "/tmp/sess/parent.jsonl", "parent_tool_use_id": "toolu_test_subagent"}, separators=(",", ":")))
45
45
  ' "$command_text")
46
46
  local stdout_file; stdout_file=$(mktemp); TMPFILES+=("$stdout_file")
47
47
  local stderr_file; stderr_file=$(mktemp); TMPFILES+=("$stderr_file")
@@ -64,7 +64,7 @@ run_write() {
64
64
  local input_json
65
65
  input_json=$(python3 -c '
66
66
  import json, sys
67
- print(json.dumps({"hook_event_name": "PreToolUse", "tool_name": "Write", "tool_input": {"file_path": "/tmp/test.html", "content": sys.argv[1]}, "transcript_path": "/tmp/sess/subagents/agent-x.jsonl"}, separators=(",", ":")))
67
+ print(json.dumps({"hook_event_name": "PreToolUse", "tool_name": "Write", "tool_input": {"file_path": "/tmp/test.html", "content": sys.argv[1]}, "transcript_path": "/tmp/sess/parent.jsonl", "parent_tool_use_id": "toolu_test_subagent"}, separators=(",", ":")))
68
68
  ' "$content")
69
69
  local stdout_file; stdout_file=$(mktemp); TMPFILES+=("$stdout_file")
70
70
  local stderr_file; stderr_file=$(mktemp); TMPFILES+=("$stderr_file")
@@ -87,7 +87,7 @@ run_edit() {
87
87
  local input_json
88
88
  input_json=$(python3 -c '
89
89
  import json, sys
90
- print(json.dumps({"hook_event_name": "PreToolUse", "tool_name": "Edit", "tool_input": {"file_path": "/tmp/test.html", "old_string": "OLD", "new_string": sys.argv[1]}, "transcript_path": "/tmp/sess/subagents/agent-x.jsonl"}, separators=(",", ":")))
90
+ print(json.dumps({"hook_event_name": "PreToolUse", "tool_name": "Edit", "tool_input": {"file_path": "/tmp/test.html", "old_string": "OLD", "new_string": sys.argv[1]}, "transcript_path": "/tmp/sess/parent.jsonl", "parent_tool_use_id": "toolu_test_subagent"}, separators=(",", ":")))
91
91
  ' "$new_string")
92
92
  local stdout_file; stdout_file=$(mktemp); TMPFILES+=("$stdout_file")
93
93
  local stderr_file; stderr_file=$(mktemp); TMPFILES+=("$stderr_file")
@@ -185,7 +185,7 @@ else
185
185
  fi
186
186
 
187
187
  # Pre-existing guards still active — entitlement file edit still rejected.
188
- ENT_JSON=$(python3 -c 'import json; print(json.dumps({"hook_event_name":"PreToolUse","tool_name":"Write","tool_input":{"file_path":"/srv/entitlement.json","content":"{\"tier\":\"max\"}"},"transcript_path":"/tmp/sess/subagents/agent-x.jsonl"}, separators=(",", ":")))')
188
+ ENT_JSON=$(python3 -c 'import json; print(json.dumps({"hook_event_name":"PreToolUse","tool_name":"Write","tool_input":{"file_path":"/srv/entitlement.json","content":"{\"tier\":\"max\"}"},"transcript_path":"/tmp/sess/parent.jsonl","parent_tool_use_id":"toolu_test_subagent"}, separators=(",", ":")))')
189
189
  STDOUT_FILE=$(mktemp); STDERR_FILE=$(mktemp); TMPFILES+=("$STDOUT_FILE" "$STDERR_FILE")
190
190
  printf '%s' "$ENT_JSON" | bash "$HOOK" admin >"$STDOUT_FILE" 2>"$STDERR_FILE"
191
191
  RC=$?
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env bash
2
+ # Task 560 — emit subagent hook decisions to a sidecar buffer that the
3
+ # parent-side post-tool-use-agent.sh propagator reads on its next fire.
4
+ #
5
+ # This library is fail-open: every error path is logged to server.log
6
+ # under [hook-propagate-error] and returns success so the hook's primary
7
+ # allow/block contract is never coupled to propagation succeeding.
8
+
9
+ : "${MAXY_HOOK_BUFFER_DIR:=$HOME/.maxy-code/logs/hook-decisions}"
10
+ : "${MAXY_HOOK_SERVER_LOG:=$HOME/.maxy-code/logs/server.log}"
11
+ : "${MAXY_HOOK_STDERR_CAP:=4096}"
12
+
13
+ # hook_emit_decision <agentId> <hookName> <toolName> <decision> <reason>
14
+ # <stderrPayload> <exitCode> <durationMs>
15
+ hook_emit_decision() {
16
+ local agentId="$1" hookName="$2" toolName="$3" decision="$4" reason="$5"
17
+ local stderrPayload="$6" exitCode="$7" durationMs="$8"
18
+
19
+ local rec
20
+ rec=$(MAXY_HOOK_STDERR_CAP="$MAXY_HOOK_STDERR_CAP" python3 - \
21
+ "$agentId" "$hookName" "$toolName" "$decision" "$reason" \
22
+ "$stderrPayload" "$exitCode" "$durationMs" <<'PY' 2>/dev/null
23
+ import json, os, sys, datetime
24
+ cap = int(os.environ.get("MAXY_HOOK_STDERR_CAP", "4096"))
25
+ (agentId, hookName, toolName, decision, reason,
26
+ stderrPayload, exitCode, durationMs) = sys.argv[1:9]
27
+ truncated = False
28
+ if len(stderrPayload) > cap:
29
+ dropped = len(stderrPayload) - cap
30
+ stderrPayload = stderrPayload[:cap] + f"…[truncated {dropped} bytes]"
31
+ truncated = True
32
+ print(json.dumps({
33
+ "ts": datetime.datetime.utcnow().replace(microsecond=0).isoformat() + "Z",
34
+ "agentId": agentId,
35
+ "hookName": hookName,
36
+ "toolName": toolName,
37
+ "decision": decision,
38
+ "reason": reason,
39
+ "stderr": stderrPayload,
40
+ "exitCode": int(exitCode) if exitCode.lstrip("-").isdigit() else 0,
41
+ "durationMs": int(durationMs) if durationMs.lstrip("-").isdigit() else 0,
42
+ "truncated": truncated,
43
+ }, separators=(",", ":")))
44
+ PY
45
+ )
46
+ if [[ -z "$rec" ]]; then
47
+ mkdir -p "$(dirname "$MAXY_HOOK_SERVER_LOG")" 2>/dev/null
48
+ printf '[hook-propagate-error] reason=record-build agentId=%s hook=%s\n' \
49
+ "$agentId" "$hookName" >>"$MAXY_HOOK_SERVER_LOG" 2>/dev/null
50
+ return 0
51
+ fi
52
+
53
+ mkdir -p "$MAXY_HOOK_BUFFER_DIR" 2>/dev/null
54
+ local buf="$MAXY_HOOK_BUFFER_DIR/$agentId.jsonl"
55
+ if ! printf '%s\n' "$rec" >>"$buf" 2>/dev/null; then
56
+ mkdir -p "$(dirname "$MAXY_HOOK_SERVER_LOG")" 2>/dev/null
57
+ printf '[hook-propagate-error] reason=buffer-write agentId=%s hook=%s\n' \
58
+ "$agentId" "$hookName" >>"$MAXY_HOOK_SERVER_LOG" 2>/dev/null
59
+ return 0
60
+ fi
61
+
62
+ local bytesIn="${#stderrPayload}"
63
+ local bytesOut="${#rec}"
64
+ local trunc=false
65
+ [[ "$rec" == *'"truncated":true'* ]] && trunc=true
66
+ mkdir -p "$(dirname "$MAXY_HOOK_SERVER_LOG")" 2>/dev/null
67
+ printf '[hook-propagate] agentId=%s hookName=%s tool=%s decision=%s bytesIn=%s bytesOut=%s truncated=%s\n' \
68
+ "$agentId" "$hookName" "$toolName" "$decision" "$bytesIn" "$bytesOut" "$trunc" \
69
+ >>"$MAXY_HOOK_SERVER_LOG" 2>/dev/null
70
+ return 0
71
+ }
72
+
73
+ # hook_block_with_emit <agentId> <hookName> <toolName> <reason>
74
+ # <userMessage> [durationMs]
75
+ # Convenience wrapper for pre-tool-use.sh: emits the propagation record,
76
+ # echoes the user-facing block message to stderr, and exits 2.
77
+ hook_block_with_emit() {
78
+ local agentId="$1" hookName="$2" toolName="$3" reason="$4"
79
+ local userMessage="$5" durationMs="${6:-0}"
80
+ hook_emit_decision "$agentId" "$hookName" "$toolName" "block" "$reason" \
81
+ "$userMessage" 2 "$durationMs"
82
+ printf '%s\n' "$userMessage" >&2
83
+ exit 2
84
+ }
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env bash
2
+ # Task 560 — PostToolUse hook scoped to tool_name=Agent in the admin role.
3
+ # Drains any subagent hook-decision buffers modified since this parent's
4
+ # previous fire, prints each record as a [hook-propagate] line to stdout
5
+ # (Claude Code attaches the stdout to the parent JSONL as a hook_success
6
+ # attachment), and emits one [hook-propagate-census] line. N != M in the
7
+ # census is the propagation regression signal.
8
+
9
+ set -uo pipefail
10
+
11
+ : "${MAXY_HOOK_BUFFER_DIR:=$HOME/.maxy-code/logs/hook-decisions}"
12
+ : "${MAXY_HOOK_SERVER_LOG:=$HOME/.maxy-code/logs/server.log}"
13
+
14
+ if [ -t 0 ]; then
15
+ # No stdin — no correlation possible. Fail-open.
16
+ exit 0
17
+ fi
18
+ INPUT=$(cat)
19
+ PARENT_SESSION=$(printf '%s' "$INPUT" | python3 -c '
20
+ import sys, json
21
+ try:
22
+ print(json.load(sys.stdin).get("session_id", "") or "unknown")
23
+ except Exception:
24
+ print("unknown")
25
+ ' 2>/dev/null || echo "unknown")
26
+
27
+ CURSOR_FILE="$MAXY_HOOK_BUFFER_DIR/.${PARENT_SESSION}.cursor"
28
+ mkdir -p "$MAXY_HOOK_BUFFER_DIR/consumed" 2>/dev/null
29
+
30
+ PREV_CURSOR=0
31
+ if [[ -f "$CURSOR_FILE" ]]; then
32
+ PREV_CURSOR=$(cat "$CURSOR_FILE" 2>/dev/null || echo 0)
33
+ [[ "$PREV_CURSOR" =~ ^[0-9]+$ ]] || PREV_CURSOR=0
34
+ fi
35
+ NOW_EPOCH=$(date +%s)
36
+
37
+ N_OBSERVED=0
38
+ N_EMITTED=0
39
+ shopt -s nullglob
40
+ for buf in "$MAXY_HOOK_BUFFER_DIR"/*.jsonl; do
41
+ [[ -f "$buf" ]] || continue
42
+ case "$(basename "$buf")" in
43
+ .*) continue;;
44
+ esac
45
+ MTIME=$(stat -f %m "$buf" 2>/dev/null || stat -c %Y "$buf" 2>/dev/null || echo 0)
46
+ if [[ "$MTIME" -le "$PREV_CURSOR" ]]; then
47
+ continue
48
+ fi
49
+ # Ground-truth observed count: non-empty lines in the buffer file. The
50
+ # printf-success M_EMITTED is incremented independently below, so a
51
+ # printf failure (SIGPIPE on a closed downstream pipe, EIO on stdout)
52
+ # produces N_OBSERVED > N_EMITTED — the propagation regression signal
53
+ # the census promises. The two counters must not be bumped together.
54
+ BUF_LINES=$(grep -c . "$buf" 2>/dev/null || echo 0)
55
+ N_OBSERVED=$((N_OBSERVED + BUF_LINES))
56
+ while IFS= read -r line || [[ -n "$line" ]]; do
57
+ [[ -z "$line" ]] && continue
58
+ if printf '[hook-propagate] %s\n' "$line" 2>/dev/null; then
59
+ N_EMITTED=$((N_EMITTED+1))
60
+ fi
61
+ done <"$buf"
62
+ TARGET="$MAXY_HOOK_BUFFER_DIR/consumed/$(basename "$buf" .jsonl)-${NOW_EPOCH}.jsonl"
63
+ if ! mv "$buf" "$TARGET" 2>/dev/null; then
64
+ mkdir -p "$(dirname "$MAXY_HOOK_SERVER_LOG")" 2>/dev/null
65
+ printf '[hook-propagate-error] reason=consumed-mv parentSession=%s buffer=%s\n' \
66
+ "$PARENT_SESSION" "$(basename "$buf")" >>"$MAXY_HOOK_SERVER_LOG" 2>/dev/null || true
67
+ fi
68
+ done
69
+ shopt -u nullglob
70
+
71
+ if ! printf '%s' "$NOW_EPOCH" >"$CURSOR_FILE" 2>/dev/null; then
72
+ mkdir -p "$(dirname "$MAXY_HOOK_SERVER_LOG")" 2>/dev/null
73
+ printf '[hook-propagate-error] reason=cursor-write parentSession=%s cursor=%s\n' \
74
+ "$PARENT_SESSION" "$CURSOR_FILE" >>"$MAXY_HOOK_SERVER_LOG" 2>/dev/null || true
75
+ fi
76
+
77
+ printf '[hook-propagate-census] parentSession=%s subagentHooksObserved=%d attachmentsEmitted=%d\n' \
78
+ "$PARENT_SESSION" "$N_OBSERVED" "$N_EMITTED"
79
+
80
+ mkdir -p "$(dirname "$MAXY_HOOK_SERVER_LOG")" 2>/dev/null
81
+ printf '[hook-propagate-census] parentSession=%s subagentHooksObserved=%d attachmentsEmitted=%d\n' \
82
+ "$PARENT_SESSION" "$N_OBSERVED" "$N_EMITTED" >>"$MAXY_HOOK_SERVER_LOG" 2>/dev/null || true
83
+
84
+ exit 0
@@ -8,72 +8,93 @@ set -uo pipefail
8
8
 
9
9
  AGENT_TYPE="${1:-public}"
10
10
 
11
+ # Source the propagation emitter (Task 560). The library is fail-open;
12
+ # any error path inside it logs to server.log and returns 0, so sourcing
13
+ # never blocks the hook's primary allow/block contract.
14
+ HOOKS_LIB_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd)/lib"
15
+ # shellcheck disable=SC1091
16
+ [ -f "$HOOKS_LIB_DIR/hook-emit.sh" ] && source "$HOOKS_LIB_DIR/hook-emit.sh"
17
+
11
18
  # Read stdin — fail closed if unavailable
12
19
  if [ -t 0 ]; then
20
+ # No INPUT yet; propagate with agentId=unknown so the regression is visible.
21
+ if declare -F hook_emit_decision >/dev/null 2>&1; then
22
+ hook_emit_decision "unknown" "admin-tool-gate" "?" "block" \
23
+ "stdin-missing" "Blocked: Cannot determine tool call (no stdin). Failing closed." 2 0
24
+ fi
13
25
  echo "Blocked: Cannot determine tool call (no stdin). Failing closed." >&2
14
26
  exit 2
15
27
  fi
16
28
  INPUT=$(cat)
17
29
  TOOL_NAME=$(echo "$INPUT" | grep -o '"tool_name":"[^"]*"' | head -1 | cut -d'"' -f4)
18
30
 
31
+ # Session id for propagation. Empty / parse-failure routes to "unknown" —
32
+ # the propagator emits the record under agentId=unknown rather than dropping.
33
+ SESSION_ID=$(printf '%s' "$INPUT" | python3 -c '
34
+ import sys, json
35
+ try:
36
+ print(json.load(sys.stdin).get("session_id", "") or "unknown")
37
+ except Exception:
38
+ print("unknown")
39
+ ' 2>/dev/null || echo "unknown")
40
+
19
41
  # ---------------------------------------------------------------------------
20
42
  # Admin agent — code directory protection + approval gating
21
43
  # ---------------------------------------------------------------------------
22
44
  if [ "$AGENT_TYPE" = "admin" ]; then
23
45
 
24
- # ── Orchestration-only authoring + research block (Task 529) ─────────────
46
+ # ── Orchestration-only authoring + research block (Task 529, 559) ────────
25
47
  # The admin seat is orchestration + clarification + delivery only.
26
48
  # Write/Edit/MultiEdit/Bash/WebFetch/WebSearch are specialist-owned —
27
49
  # content-producer for authoring, research-assistant for research, the
28
50
  # shell tools for whichever specialist owns the workflow that needs them.
29
51
  # In-window Task subagents must keep these tools (Task 222 regression
30
- # class) — the discriminator is the transcript_path: Claude Code stores
31
- # subagent turns in separate `*/subagents/agent-*.jsonl` files.
52
+ # class).
53
+ #
54
+ # Discriminator (Task 559): Claude Code passes `parent_tool_use_id` in
55
+ # the hook input JSON when the call originates from a subagent (the id
56
+ # of the parent's Task tool_use). Admin-direct calls have it absent or
57
+ # empty. Earlier versions used the `transcript_path` glob, but Claude
58
+ # Code 2.1.x passes the parent session's transcript_path to subagent
59
+ # hooks, so the glob never matched and every specialist subagent Bash
60
+ # was blocked with the admin orchestration-only wording.
32
61
  #
33
- # Admin direct → exit 2 with dispatch hint.
34
- # Subagent → exit 0 (passthrough).
35
- # Missing/malformed transcript_path → fail-closed block (matches Task 222).
62
+ # Admin direct (no parent_tool_use_id) → exit 2 with dispatch hint.
63
+ # Subagent (parent_tool_use_id non-empty) → exit 0 (passthrough).
36
64
  case "$TOOL_NAME" in
37
65
  Write|Edit|MultiEdit|Bash|WebFetch|WebSearch)
38
- TRANSCRIPT_PATH=$(echo "$INPUT" | python3 -c '
66
+ PARENT_TOOL_USE_ID=$(echo "$INPUT" | python3 -c '
39
67
  import sys, json
40
68
  try:
41
- print(json.load(sys.stdin).get("transcript_path", "") or "")
69
+ v = json.load(sys.stdin).get("parent_tool_use_id", "")
70
+ print(v if isinstance(v, str) else "")
42
71
  except Exception:
43
72
  print("")
44
73
  ' 2>/dev/null)
45
- case "$TRANSCRIPT_PATH" in
46
- */subagents/agent-*.jsonl)
47
- # In-window subagent — passthrough. Tool name resolves on the
48
- # subagent's own frontmatter `tools:` allowlist downstream.
49
- echo "[admin-tool-gate] role=subagent tool=${TOOL_NAME} decision=allow source=hook-passthrough" >&2
50
- ;;
51
- "")
52
- # Missing transcript_path on a denied native tool — fail closed
53
- # rather than guess (Task 222 fail-closed contract).
54
- echo "[admin-tool-gate] role=unknown tool=${TOOL_NAME} decision=block reason=missing-transcript-path" >&2
55
- echo "Blocked: ${TOOL_NAME} is unavailable from the admin seat and the caller cannot be classified (no transcript_path). Failing closed." >&2
56
- exit 2
57
- ;;
58
- *)
59
- # Admin direct invocation — refuse with dispatch hint.
60
- case "$TOOL_NAME" in
61
- Write|Edit|MultiEdit)
62
- OWNER="content-producer"
63
- ;;
64
- Bash)
65
- OWNER="the specialist that owns the workflow needing shell access"
66
- ;;
67
- WebFetch|WebSearch)
68
- OWNER="research-assistant"
69
- ;;
70
- esac
71
- echo "[admin-tool-gate] role=admin tool=${TOOL_NAME} decision=block reason=orchestration-only owner=${OWNER}" >&2
72
- echo "Blocked: \`${TOOL_NAME}\` is not callable from the admin seat. The admin seat is orchestration + clarification + delivery only — authoring, shell work, and web research are owned by specialists." >&2
73
- echo "Dispatch the work to ${OWNER} via the Agent tool." >&2
74
- exit 2
75
- ;;
76
- esac
74
+ SPECIALIST="${MAXY_SPECIALIST:-}"
75
+ if [ -n "$PARENT_TOOL_USE_ID" ]; then
76
+ # In-window subagent — passthrough. Tool name resolves on the
77
+ # subagent's own frontmatter `tools:` allowlist downstream.
78
+ echo "[admin-tool-gate] role=subagent parent_tool_use_id=${PARENT_TOOL_USE_ID} specialist=${SPECIALIST:--} tool=${TOOL_NAME} decision=allow source=hook-passthrough" >&2
79
+ else
80
+ # Admin direct invocation — refuse with dispatch hint.
81
+ case "$TOOL_NAME" in
82
+ Write|Edit|MultiEdit)
83
+ OWNER="content-producer"
84
+ ;;
85
+ Bash)
86
+ OWNER="the specialist that owns the workflow needing shell access"
87
+ ;;
88
+ WebFetch|WebSearch)
89
+ OWNER="research-assistant"
90
+ ;;
91
+ esac
92
+ echo "[admin-tool-gate] role=admin parent_tool_use_id=- specialist=${SPECIALIST:--} tool=${TOOL_NAME} decision=block reason=orchestration-only owner=${OWNER}" >&2
93
+ hook_block_with_emit "$SESSION_ID" "admin-tool-gate" "${TOOL_NAME}" \
94
+ "orchestration-only" \
95
+ "Blocked: \`${TOOL_NAME}\` is not callable from the admin seat. The admin seat is orchestration + clarification + delivery only — authoring, shell work, and web research are owned by specialists.
96
+ Dispatch the work to ${OWNER} via the Agent tool."
97
+ fi
77
98
  ;;
78
99
  esac
79
100
 
@@ -84,13 +105,15 @@ except Exception:
84
105
  case "$FILE_PATH" in
85
106
  # Platform UI source (platform/ui/) — source lives here, not on device
86
107
  */platform/ui/app/*|*/platform/ui/lib/*|*/platform/ui/*.ts|*/platform/ui/*.tsx|*/platform/ui/*.mjs)
87
- echo "Blocked: Admin agent cannot modify platform UI source at $FILE_PATH" >&2
88
- exit 2
108
+ hook_block_with_emit "$SESSION_ID" "admin-tool-gate" "${TOOL_NAME}" \
109
+ "code-dir-protection-ui" \
110
+ "Blocked: Admin agent cannot modify platform UI source at $FILE_PATH"
89
111
  ;;
90
112
  # Compiled MCP servers, hooks, and scripts — what actually ships to device
91
113
  */platform/plugins/*/mcp/dist/*|*/platform/plugins/*/hooks/*|*/platform/scripts/*)
92
- echo "Blocked: Admin agent cannot modify platform code at $FILE_PATH" >&2
93
- exit 2
114
+ hook_block_with_emit "$SESSION_ID" "admin-tool-gate" "${TOOL_NAME}" \
115
+ "code-dir-protection-platform" \
116
+ "Blocked: Admin agent cannot modify platform code at $FILE_PATH"
94
117
  ;;
95
118
  # Entitlement library — verifier source, compiled output, vendored
96
119
  # pubkey, hash file, and any signed entitlement.json. Tampering here is the
@@ -99,9 +122,10 @@ except Exception:
99
122
  # Patterns intentionally cover both absolute (*/...) and relative
100
123
  # (no leading slash) paths so an agent can't bypass via cwd-relative writes.
101
124
  */platform/lib/entitlement/*|platform/lib/entitlement/*|*/entitlement.json|entitlement.json)
102
- echo "Blocked: Admin agent cannot modify entitlement files at $FILE_PATH. Effective tier and purchasedPlugins derive from a Rubytech-signed payload." >&2
103
125
  echo "[entitlement] tool-deny: tool=${TOOL_NAME} path=${FILE_PATH} field=entitlement-file" >&2
104
- exit 2
126
+ hook_block_with_emit "$SESSION_ID" "admin-tool-gate" "${TOOL_NAME}" \
127
+ "entitlement-file" \
128
+ "Blocked: Admin agent cannot modify entitlement files at $FILE_PATH. Effective tier and purchasedPlugins derive from a Rubytech-signed payload."
105
129
  ;;
106
130
  # account.json — agent must use the account-update
107
131
  # MCP tool (or plugin-toggle-enabled for enabledPlugins changes), which
@@ -112,14 +136,16 @@ except Exception:
112
136
  # incident showed a tier upgrade via raw Edit on account.json reach
113
137
  # the disk, exactly the failure mode this hook exists to prevent.
114
138
  */data/accounts/*/account.json|*/config/accounts/*/account.json|data/accounts/*/account.json|config/accounts/*/account.json|*/account.json|*account.json|account.json)
115
- echo "Blocked: Admin agent cannot edit account.json directly. Use the account-update or plugin-toggle-enabled MCP tools — they whitelist editable fields server-side and exclude tier and purchasedPlugins by design." >&2
116
139
  echo "[entitlement] tool-deny: tool=${TOOL_NAME} path=${FILE_PATH} field=account-json" >&2
117
- exit 2
140
+ hook_block_with_emit "$SESSION_ID" "admin-tool-gate" "${TOOL_NAME}" \
141
+ "account-json" \
142
+ "Blocked: Admin agent cannot edit account.json directly. Use the account-update or plugin-toggle-enabled MCP tools — they whitelist editable fields server-side and exclude tier and purchasedPlugins by design."
118
143
  ;;
119
144
  # Pending action queue — only the hook and MCP tools may write here
120
145
  */pending-actions/*)
121
- echo "Blocked: Admin agent cannot modify pending action files directly" >&2
122
- exit 2
146
+ hook_block_with_emit "$SESSION_ID" "admin-tool-gate" "${TOOL_NAME}" \
147
+ "pending-actions" \
148
+ "Blocked: Admin agent cannot modify pending action files directly"
123
149
  ;;
124
150
  esac
125
151
  ;;
@@ -127,24 +153,28 @@ except Exception:
127
153
  # Block shell commands referencing protected code directories or pending actions
128
154
  case "$INPUT" in
129
155
  *"platform/ui/app/"*|*"platform/ui/lib/"*|*"platform/plugins/"*"hooks/"*|*"platform/scripts/"*)
130
- echo "Blocked: Admin agent cannot run shell commands against code directories" >&2
131
- exit 2
156
+ hook_block_with_emit "$SESSION_ID" "admin-tool-gate" "Bash" \
157
+ "code-dir-protection-bash" \
158
+ "Blocked: Admin agent cannot run shell commands against code directories"
132
159
  ;;
133
160
  # Entitlement files via shell — same surface, blocked symmetrically
134
161
  *"platform/lib/entitlement/"*|*"entitlement.json"*)
135
- echo "Blocked: Admin agent cannot reference entitlement files via shell." >&2
136
162
  echo "[entitlement] tool-deny: tool=Bash path=entitlement-file field=entitlement-file" >&2
137
- exit 2
163
+ hook_block_with_emit "$SESSION_ID" "admin-tool-gate" "Bash" \
164
+ "entitlement-shell" \
165
+ "Blocked: Admin agent cannot reference entitlement files via shell."
138
166
  ;;
139
167
  # account.json via shell — same rationale as Edit/Write block
140
168
  *"data/accounts/"*"account.json"*|*"config/accounts/"*"account.json"*)
141
- echo "Blocked: Admin agent cannot edit account.json via shell. Use the account-update MCP tool." >&2
142
169
  echo "[entitlement] tool-deny: tool=Bash path=account-json field=account-json" >&2
143
- exit 2
170
+ hook_block_with_emit "$SESSION_ID" "admin-tool-gate" "Bash" \
171
+ "account-json-shell" \
172
+ "Blocked: Admin agent cannot edit account.json via shell. Use the account-update MCP tool."
144
173
  ;;
145
174
  *"pending-actions/"*)
146
- echo "Blocked: Admin agent cannot modify pending action files via shell" >&2
147
- exit 2
175
+ hook_block_with_emit "$SESSION_ID" "admin-tool-gate" "Bash" \
176
+ "pending-actions-shell" \
177
+ "Blocked: Admin agent cannot modify pending action files via shell"
148
178
  ;;
149
179
  esac
150
180
  ;;
@@ -229,17 +259,19 @@ except Exception:
229
259
  REJECT:base64-write-content:*)
230
260
  BYTES="${GUARD_VERDICT##*:}"
231
261
  echo "[pre-tool-use] guard=base64-write-content bytes=${BYTES} action=reject" >&2
232
- echo "Blocked: ${TOOL_NAME} content carries an inline base64 payload (>4 KB encoded). Inline binary in Write.content overloads the model context — the same path produced a main_stream_stalled at ~33 KB on 2026-05-09." >&2
233
- echo "Save the bytes to \$ACCOUNT_DIR/tmp/<sha1>.<ext> via Bash (e.g. 'base64 -d > out.png'), then reference the file from the document: <img src=\"./<file>\"> or Read-by-path. Do not carry binary bytes through the assistant turn." >&2
234
- exit 2
262
+ hook_block_with_emit "$SESSION_ID" "base64-guard" "${TOOL_NAME}" \
263
+ "base64-write-content" \
264
+ "Blocked: ${TOOL_NAME} content carries an inline base64 payload (>4 KB encoded). Inline binary in Write.content overloads the model context — the same path produced a main_stream_stalled at ~33 KB on 2026-05-09.
265
+ Save the bytes to \$ACCOUNT_DIR/tmp/<sha1>.<ext> via Bash (e.g. 'base64 -d > out.png'), then reference the file from the document: <img src=\"./<file>\"> or Read-by-path. Do not carry binary bytes through the assistant turn."
235
266
  ;;
236
267
  REJECT:base64-encoder:*|REJECT:xxd-plain-hex:*)
237
268
  REASON="${GUARD_VERDICT#REJECT:}"; REASON="${REASON%:*}"
238
269
  BYTES="${GUARD_VERDICT##*:}"
239
270
  echo "[pre-tool-use] guard=base64-tool-result bytes=${BYTES} tool=Bash reason=${REASON} action=reject" >&2
240
- echo "Blocked: Bash command would emit binary as inline base64/hex to stdout, which lands in the assistant turn and overloads the model context (the 2026-05-09 Rubytech-invoice path hit 66% context after a single ~33 KB tool_result)." >&2
241
- echo "Instead: save the bytes directly to \$ACCOUNT_DIR/tmp/<sha1>.<ext> and operate on the file via path — Read for inspection, <img src=\"./<file>\"> for HTML embedding, the \`file-presentation\` skill for delivery. Decoding base64 (e.g. 'base64 -d in.b64 > out.bin') is allowed." >&2
242
- exit 2
271
+ hook_block_with_emit "$SESSION_ID" "base64-guard" "Bash" \
272
+ "$REASON" \
273
+ "Blocked: Bash command would emit binary as inline base64/hex to stdout, which lands in the assistant turn and overloads the model context (the 2026-05-09 Rubytech-invoice path hit 66% context after a single ~33 KB tool_result).
274
+ Instead: save the bytes directly to \$ACCOUNT_DIR/tmp/<sha1>.<ext> and operate on the file via path — Read for inspection, <img src=\"./<file>\"> for HTML embedding, the \`file-presentation\` skill for delivery. Decoding base64 (e.g. 'base64 -d in.b64 > out.bin') is allowed."
243
275
  ;;
244
276
  *)
245
277
  : # ALLOW — fall through to approval gating below
@@ -313,8 +345,9 @@ except Exception:
313
345
  # Write atomically: temp file + mv
314
346
  TEMP=$(mktemp "${PENDING_DIR}/.tmp.XXXXXX" 2>/dev/null)
315
347
  if [ -z "$TEMP" ]; then
316
- echo "Blocked: Action requires approval but failed to create queue file (disk error). Failing closed." >&2
317
- exit 2
348
+ hook_block_with_emit "$SESSION_ID" "approval-gate" "${TOOL_NAME}" \
349
+ "approval-queue-mktemp" \
350
+ "Blocked: Action requires approval but failed to create queue file (disk error). Failing closed."
318
351
  fi
319
352
 
320
353
  # The pending action file contains the full hook JSON (tool_name + tool_input)
@@ -332,10 +365,19 @@ ENDJSON
332
365
 
333
366
  if ! mv "$TEMP" "${PENDING_DIR}/${ACTION_ID}.json" 2>/dev/null; then
334
367
  rm -f "$TEMP" 2>/dev/null
335
- echo "Blocked: Action requires approval but failed to queue (filesystem error). Failing closed." >&2
336
- exit 2
368
+ hook_block_with_emit "$SESSION_ID" "approval-gate" "${TOOL_NAME}" \
369
+ "approval-queue-mv" \
370
+ "Blocked: Action requires approval but failed to queue (filesystem error). Failing closed."
337
371
  fi
338
372
 
373
+ # Record the queued-for-approval block in the propagation buffer; the
374
+ # readable summary still goes to stdout (CC attaches it as the agent's
375
+ # tool_result). hook_emit_decision is the no-exit variant — the
376
+ # subsequent echoes + exit 2 below remain the operator-facing surface.
377
+ hook_emit_decision "$SESSION_ID" "approval-gate" "${TOOL_NAME}" \
378
+ "block" "approval-queued" \
379
+ "Action ${ACTION_ID} queued for review" 2 0
380
+
339
381
  # Extract a readable summary of the action for the agent to present
340
382
  # tool_input is the second JSON value in the hook payload
341
383
  echo "Action requires approval before execution."
@@ -121,6 +121,12 @@ cat > "$ACCOUNT_SETTINGS" << SETTINGS_EOF
121
121
  }
122
122
  ],
123
123
  "PostToolUse": [
124
+ {
125
+ "matcher": "Agent",
126
+ "hooks": [
127
+ { "type": "command", "command": "bash $HOOKS_PATH/post-tool-use-agent.sh" }
128
+ ]
129
+ },
124
130
  {
125
131
  "matcher": "mcp__.*__.*-(export|import)-parse$",
126
132
  "hooks": [