@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 +1 -1
- package/payload/platform/plugins/admin/PLUGIN.md +1 -0
- package/payload/platform/plugins/admin/hooks/__tests__/hook-emit.test.sh +75 -0
- package/payload/platform/plugins/admin/hooks/__tests__/post-tool-use-agent.test.sh +77 -0
- package/payload/platform/plugins/admin/hooks/__tests__/pre-tool-use-admin-tool-gate.test.sh +64 -38
- package/payload/platform/plugins/admin/hooks/__tests__/pre-tool-use-base64-guard.test.sh +4 -4
- package/payload/platform/plugins/admin/hooks/lib/hook-emit.sh +84 -0
- package/payload/platform/plugins/admin/hooks/post-tool-use-agent.sh +84 -0
- package/payload/platform/plugins/admin/hooks/pre-tool-use.sh +110 -68
- package/payload/platform/scripts/setup-account.sh +6 -0
package/package.json
CHANGED
|
@@ -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
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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
|
-
|
|
31
|
-
|
|
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"
|
|
39
|
+
local tool="$1" parent_id="$2" expected_rc="$3" expected_pattern="$4" label="$5"
|
|
35
40
|
local input_json
|
|
36
|
-
|
|
37
|
-
input_json=$(python3 -c '
|
|
41
|
+
input_json=$(python3 -c '
|
|
38
42
|
import json, sys
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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 "
|
|
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 "
|
|
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 "
|
|
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 "
|
|
76
|
+
run Bash "" 2 '\[admin-tool-gate\] role=admin .*tool=Bash decision=block' \
|
|
70
77
|
"Test 4: admin-direct Bash rejected"
|
|
71
|
-
run WebFetch "
|
|
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 "
|
|
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 (
|
|
77
|
-
run Write "$
|
|
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 "$
|
|
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 "$
|
|
82
|
-
"Test 9: subagent Bash passes through"
|
|
83
|
-
run WebFetch "$
|
|
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
|
-
|
|
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 "
|
|
94
|
-
"Test
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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)
|
|
31
|
-
#
|
|
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
|
-
|
|
66
|
+
PARENT_TOOL_USE_ID=$(echo "$INPUT" | python3 -c '
|
|
39
67
|
import sys, json
|
|
40
68
|
try:
|
|
41
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
317
|
-
|
|
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
|
-
|
|
336
|
-
|
|
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": [
|