@rubytech/create-maxy-code 0.1.212 → 0.1.216
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/.claude-plugin/plugin.json +1 -1
- package/payload/platform/plugins/admin/PLUGIN.md +5 -1
- 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 +173 -0
- package/payload/platform/plugins/admin/hooks/__tests__/pre-tool-use-admin-tool-gate.test.sh +107 -40
- 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 +114 -0
- package/payload/platform/plugins/admin/hooks/post-tool-use-agent.sh +136 -0
- package/payload/platform/plugins/admin/hooks/pre-tool-use.sh +127 -68
- package/payload/platform/plugins/admin/mcp/dist/__tests__/capabilities-here.test.d.ts +2 -0
- package/payload/platform/plugins/admin/mcp/dist/__tests__/capabilities-here.test.d.ts.map +1 -0
- package/payload/platform/plugins/admin/mcp/dist/__tests__/capabilities-here.test.js +130 -0
- package/payload/platform/plugins/admin/mcp/dist/__tests__/capabilities-here.test.js.map +1 -0
- package/payload/platform/plugins/admin/mcp/dist/index.js +63 -0
- package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/admin/mcp/dist/lib/catalogue-census.d.ts +14 -0
- package/payload/platform/plugins/admin/mcp/dist/lib/catalogue-census.d.ts.map +1 -0
- package/payload/platform/plugins/admin/mcp/dist/lib/catalogue-census.js +50 -0
- package/payload/platform/plugins/admin/mcp/dist/lib/catalogue-census.js.map +1 -0
- package/payload/platform/plugins/admin/mcp/dist/tools/capabilities-here.d.ts +28 -0
- package/payload/platform/plugins/admin/mcp/dist/tools/capabilities-here.d.ts.map +1 -0
- package/payload/platform/plugins/admin/mcp/dist/tools/capabilities-here.js +68 -0
- package/payload/platform/plugins/admin/mcp/dist/tools/capabilities-here.js.map +1 -0
- package/payload/platform/plugins/admin/skills/capabilities-here/SKILL.md +31 -0
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +30 -14
- package/payload/platform/plugins/docs/references/platform.md +19 -9
- package/payload/platform/plugins/docs/references/plugins-guide.md +2 -0
- package/payload/platform/scripts/check-architecture-skill-no-drift.mjs +89 -42
- package/payload/platform/scripts/check-roles-doc-completeness.mjs +96 -0
- package/payload/platform/scripts/check-specialist-tool-surface.mjs +1 -1
- package/payload/platform/scripts/setup-account.sh +6 -0
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +1 -0
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js +77 -0
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/index.js +30 -0
- package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/jsonl-tail.d.ts +18 -0
- package/payload/platform/services/claude-session-manager/dist/jsonl-tail.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/jsonl-tail.js +63 -0
- package/payload/platform/services/claude-session-manager/dist/jsonl-tail.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts +5 -0
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +29 -0
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/rc-script-spawn.d.ts +80 -0
- package/payload/platform/services/claude-session-manager/dist/rc-script-spawn.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/rc-script-spawn.js +306 -0
- package/payload/platform/services/claude-session-manager/dist/rc-script-spawn.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/specialist-drift.d.ts +9 -0
- package/payload/platform/services/claude-session-manager/dist/specialist-drift.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/specialist-drift.js +6 -2
- package/payload/platform/services/claude-session-manager/dist/specialist-drift.js.map +1 -1
- package/payload/platform/templates/agents/admin/IDENTITY.md +3 -1
- package/payload/platform/templates/specialists/agents/content-producer.md +1 -1
- package/payload/server/public/assets/AdminShell-CPWbJ_I8.js +1 -0
- package/payload/server/public/assets/{admin-DMo5DWKH.js → admin-DjGGOx2K.js} +1 -1
- package/payload/server/public/assets/{data-D6IQdm0R.js → data-WBEvtRV-.js} +1 -1
- package/payload/server/public/assets/{graph-PkZKVkWD.js → graph-DsOLsIPf.js} +1 -1
- package/payload/server/public/data.html +2 -2
- package/payload/server/public/graph.html +2 -2
- package/payload/server/public/index.html +2 -2
- package/payload/server/server.js +6 -0
- package/payload/server/public/assets/AdminShell-BcxEZnpu.js +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "admin",
|
|
3
|
-
"description": "Platform administration plugin. Provides system-status, public-hostname (deterministic Cloudflare public-URL resolver, single call returning the operator's canonical hostname so agents never guess property names on :CloudflareHostname nodes), publish-site (move an extracted static-site tree under <accountDir>/sites/<slug>/, emit one canonical path slug, and refresh llms.txt at the site root — typed refusal taxonomy replaces the prior skill-prose contract), brand-settings, account-manage, account-update, admin-add, admin-remove, admin-list, admin-update-pin, agent-list, agent-config-read, logs-read, plugin-read, skill-load (one-call resolve+read for SKILL.md by skill name, the canonical primitive for loading a named skill; plugin-read remains the reader for references/* and PLUGIN.md), store-skill (deterministic write counterpart to plugin-read; persists operator-authored skills as plugin files under the active account), session-reset, session-resume, wifi, action-approval tools (action-pending, action-approve, action-reject, action-edit), and session-retrospective-mark-complete (the deterministic sentinel the session-end Stop hook reads via JSONL grep to release the retrospective gate; admin calls it once per session after running the three-pass retrospective) for managing the Maxy platform.",
|
|
3
|
+
"description": "Platform administration plugin. Provides system-status, public-hostname (deterministic Cloudflare public-URL resolver, single call returning the operator's canonical hostname so agents never guess property names on :CloudflareHostname nodes), publish-site (move an extracted static-site tree under <accountDir>/sites/<slug>/, emit one canonical path slug, and refresh llms.txt at the site root — typed refusal taxonomy replaces the prior skill-prose contract), brand-settings, account-manage, capabilities-here (deterministic install-introspection — returns enabledPlugins, tier, models, installed platform + premium specialists, registered Claude Code agent types, and the brand block as a single JSON object; the canonical tool for grounding any claim about what is enabled on the current account), account-update, admin-add, admin-remove, admin-list, admin-update-pin, agent-list, agent-config-read, logs-read, plugin-read, skill-load (one-call resolve+read for SKILL.md by skill name, the canonical primitive for loading a named skill; plugin-read remains the reader for references/* and PLUGIN.md), store-skill (deterministic write counterpart to plugin-read; persists operator-authored skills as plugin files under the active account), session-reset, session-resume, wifi, action-approval tools (action-pending, action-approve, action-reject, action-edit), and session-retrospective-mark-complete (the deterministic sentinel the session-end Stop hook reads via JSONL grep to release the retrospective gate; admin calls it once per session after running the three-pass retrospective) for managing the Maxy platform.",
|
|
4
4
|
"version": "0.1.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Rubytech LLC"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: admin
|
|
3
3
|
surface: platform
|
|
4
|
-
description: "Platform administration plugin. Provides system-status, public-hostname (deterministic Cloudflare public-URL resolver, single call returning the operator's canonical hostname so agents never guess property names on :CloudflareHostname nodes), publish-site (move an extracted static-site tree under <accountDir>/sites/<slug>/, emit one canonical path slug, and refresh llms.txt at the site root — typed refusal taxonomy replaces the prior skill-prose contract), brand-settings, account-manage, account-update, admin-add, admin-remove, admin-list, admin-update-pin, agent-list, agent-config-read, logs-read, plugin-read, skill-load (one-call resolve+read for SKILL.md by skill name, the canonical primitive for loading a named skill; plugin-read remains the reader for references/* and PLUGIN.md), store-skill (deterministic write counterpart to plugin-read; persists operator-authored skills as plugin files under the active account), session-reset, session-resume, wifi, action-approval tools (action-pending, action-approve, action-reject, action-edit), and session-retrospective-mark-complete (the deterministic sentinel the session-end Stop hook reads via JSONL grep to release the retrospective gate; admin calls it once per session after running the three-pass retrospective) for managing the Maxy platform."
|
|
4
|
+
description: "Platform administration plugin. Provides system-status, public-hostname (deterministic Cloudflare public-URL resolver, single call returning the operator's canonical hostname so agents never guess property names on :CloudflareHostname nodes), publish-site (move an extracted static-site tree under <accountDir>/sites/<slug>/, emit one canonical path slug, and refresh llms.txt at the site root — typed refusal taxonomy replaces the prior skill-prose contract), brand-settings, account-manage, capabilities-here (deterministic install-introspection — returns enabledPlugins, tier, models, installed platform + premium specialists, registered Claude Code agent types, and the brand block as a single JSON object; the canonical tool for grounding any claim about what is enabled on the current account), account-update, admin-add, admin-remove, admin-list, admin-update-pin, agent-list, agent-config-read, logs-read, plugin-read, skill-load (one-call resolve+read for SKILL.md by skill name, the canonical primitive for loading a named skill; plugin-read remains the reader for references/* and PLUGIN.md), store-skill (deterministic write counterpart to plugin-read; persists operator-authored skills as plugin files under the active account), session-reset, session-resume, wifi, action-approval tools (action-pending, action-approve, action-reject, action-edit), and session-retrospective-mark-complete (the deterministic sentinel the session-end Stop hook reads via JSONL grep to release the retrospective gate; admin calls it once per session after running the three-pass retrospective) for managing the Maxy platform."
|
|
5
5
|
tools:
|
|
6
6
|
- name: system-status
|
|
7
7
|
publicAllowlist: false
|
|
@@ -18,6 +18,9 @@ tools:
|
|
|
18
18
|
- name: account-manage
|
|
19
19
|
publicAllowlist: false
|
|
20
20
|
adminAllowlist: false
|
|
21
|
+
- name: capabilities-here
|
|
22
|
+
publicAllowlist: false
|
|
23
|
+
adminAllowlist: false
|
|
21
24
|
- name: account-update
|
|
22
25
|
publicAllowlist: false
|
|
23
26
|
adminAllowlist: false
|
|
@@ -141,6 +144,7 @@ Tools are available via the `admin` MCP server.
|
|
|
141
144
|
- `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
145
|
- `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
146
|
- `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`.
|
|
147
|
+
- `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
148
|
- `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
149
|
- `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
150
|
|
|
@@ -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,173 @@
|
|
|
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
|
+
# Task 563 — concurrency hardening.
|
|
76
|
+
|
|
77
|
+
# Concurrent emitter + drainer on the same buffer must not drop records.
|
|
78
|
+
# Spawn an emitter that writes 50 records in a tight loop while two
|
|
79
|
+
# drainer fires race against it. Union of attachments must equal 50.
|
|
80
|
+
LIB="$(cd "$(dirname "$0")/.." && pwd)/lib/hook-emit.sh"
|
|
81
|
+
[[ -f "$LIB" ]] || { fail "lib missing"; }
|
|
82
|
+
CONCUR_TMP=$(mktemp -d)
|
|
83
|
+
export MAXY_HOOK_BUFFER_DIR="$CONCUR_TMP/buffers"
|
|
84
|
+
export MAXY_HOOK_SERVER_LOG="$CONCUR_TMP/server.log"
|
|
85
|
+
mkdir -p "$MAXY_HOOK_BUFFER_DIR"
|
|
86
|
+
PARENT_C="parent-CONC"
|
|
87
|
+
SUB_C="sub-CONC"
|
|
88
|
+
INPUT_C=$(python3 -c '
|
|
89
|
+
import json, sys
|
|
90
|
+
print(json.dumps({"hook_event_name":"PostToolUse","tool_name":"Agent",
|
|
91
|
+
"session_id":sys.argv[1]}, separators=(",",":")))
|
|
92
|
+
' "$PARENT_C")
|
|
93
|
+
|
|
94
|
+
# Emitter: 50 records as fast as possible.
|
|
95
|
+
EMIT_OUT="$CONCUR_TMP/emit.log"
|
|
96
|
+
(
|
|
97
|
+
# shellcheck disable=SC1090
|
|
98
|
+
source "$LIB"
|
|
99
|
+
for i in $(seq 1 50); do
|
|
100
|
+
hook_emit_decision "$SUB_C" "h" "Bash" "block" "r$i" "x" 2 1
|
|
101
|
+
done
|
|
102
|
+
echo "done" >"$EMIT_OUT"
|
|
103
|
+
) &
|
|
104
|
+
EMIT_PID=$!
|
|
105
|
+
|
|
106
|
+
# Two drainer fires racing the emitter.
|
|
107
|
+
DRAIN1="$CONCUR_TMP/drain1.out"
|
|
108
|
+
DRAIN2="$CONCUR_TMP/drain2.out"
|
|
109
|
+
(printf '%s' "$INPUT_C" | bash "$HOOK" >"$DRAIN1" 2>/dev/null) &
|
|
110
|
+
D1=$!
|
|
111
|
+
(printf '%s' "$INPUT_C" | bash "$HOOK" >"$DRAIN2" 2>/dev/null) &
|
|
112
|
+
D2=$!
|
|
113
|
+
wait "$EMIT_PID" "$D1" "$D2"
|
|
114
|
+
|
|
115
|
+
# Drain any leftovers after emitter+drainers settled.
|
|
116
|
+
DRAIN3="$CONCUR_TMP/drain3.out"
|
|
117
|
+
printf '%s' "$INPUT_C" | bash "$HOOK" >"$DRAIN3" 2>/dev/null
|
|
118
|
+
# A second sweep catches buffer files created after drainer 3 started.
|
|
119
|
+
DRAIN4="$CONCUR_TMP/drain4.out"
|
|
120
|
+
printf '%s' "$INPUT_C" | bash "$HOOK" >"$DRAIN4" 2>/dev/null
|
|
121
|
+
|
|
122
|
+
TOTAL_ATTACHED=$(cat "$DRAIN1" "$DRAIN2" "$DRAIN3" "$DRAIN4" | grep -c '^\[hook-propagate\] {' || true)
|
|
123
|
+
UNIQUE_R=$(cat "$DRAIN1" "$DRAIN2" "$DRAIN3" "$DRAIN4" | grep '^\[hook-propagate\] {' | \
|
|
124
|
+
python3 -c 'import json,sys
|
|
125
|
+
seen=set()
|
|
126
|
+
for line in sys.stdin:
|
|
127
|
+
rec=line.split(" ",1)[1]
|
|
128
|
+
d=json.loads(rec)
|
|
129
|
+
seen.add(d["reason"])
|
|
130
|
+
print(len(seen))')
|
|
131
|
+
[[ "$TOTAL_ATTACHED" -eq 50 ]] && pass "concurrent: 50 records attached, no duplicates" \
|
|
132
|
+
|| fail "concurrent: expected 50 attached, got $TOTAL_ATTACHED"
|
|
133
|
+
[[ "$UNIQUE_R" -eq 50 ]] && pass "concurrent: 50 unique records, no drops" \
|
|
134
|
+
|| fail "concurrent: expected 50 unique reasons, got $UNIQUE_R"
|
|
135
|
+
|
|
136
|
+
# No cursor file should exist (cursor mechanism removed).
|
|
137
|
+
[[ -z "$(ls "$MAXY_HOOK_BUFFER_DIR"/.*.cursor 2>/dev/null)" ]] \
|
|
138
|
+
&& pass "cursor mechanism removed (no .cursor files)" \
|
|
139
|
+
|| fail "cursor file unexpectedly present: $(ls "$MAXY_HOOK_BUFFER_DIR"/.*.cursor 2>/dev/null)"
|
|
140
|
+
|
|
141
|
+
rm -rf "$CONCUR_TMP"
|
|
142
|
+
|
|
143
|
+
# Unknown-parent fallback emits observability line and does not corrupt
|
|
144
|
+
# state across concurrent fires.
|
|
145
|
+
UNK_TMP=$(mktemp -d)
|
|
146
|
+
export MAXY_HOOK_BUFFER_DIR="$UNK_TMP/buffers"
|
|
147
|
+
export MAXY_HOOK_SERVER_LOG="$UNK_TMP/server.log"
|
|
148
|
+
mkdir -p "$MAXY_HOOK_BUFFER_DIR"
|
|
149
|
+
# Stdin with no session_id at all.
|
|
150
|
+
BAD_INPUT='{"hook_event_name":"PostToolUse","tool_name":"Agent"}'
|
|
151
|
+
UNK1="$UNK_TMP/unk1.out"
|
|
152
|
+
UNK2="$UNK_TMP/unk2.out"
|
|
153
|
+
(printf '%s' "$BAD_INPUT" | bash "$HOOK" >"$UNK1" 2>/dev/null) &
|
|
154
|
+
U1=$!
|
|
155
|
+
(printf '%s' "$BAD_INPUT" | bash "$HOOK" >"$UNK2" 2>/dev/null) &
|
|
156
|
+
U2=$!
|
|
157
|
+
wait "$U1" "$U2"
|
|
158
|
+
|
|
159
|
+
grep -q 'parentSession=unknown reason=session-id-missing' "$UNK1" "$UNK2" \
|
|
160
|
+
&& pass "unknown parent emits observability line" \
|
|
161
|
+
|| fail "unknown parent missing observability line: $(cat "$UNK1" "$UNK2")"
|
|
162
|
+
grep -q 'parentSession=unknown reason=session-id-missing' "$MAXY_HOOK_SERVER_LOG" \
|
|
163
|
+
&& pass "unknown parent line logged to server.log" \
|
|
164
|
+
|| fail "server.log missing unknown-parent line"
|
|
165
|
+
# No .unknown.cursor file — cursor mechanism is gone.
|
|
166
|
+
[[ ! -e "$MAXY_HOOK_BUFFER_DIR/.unknown.cursor" ]] \
|
|
167
|
+
&& pass "no shared .unknown.cursor file" \
|
|
168
|
+
|| fail ".unknown.cursor unexpectedly present"
|
|
169
|
+
|
|
170
|
+
rm -rf "$UNK_TMP"
|
|
171
|
+
|
|
172
|
+
echo "----"; echo "$PASS pass, $FAIL fail"
|
|
173
|
+
[[ "$FAIL" -eq 0 ]]
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
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
|
-
# WebSearch are refused with the dispatch hint;
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
4
|
+
# WebSearch are refused with the dispatch hint; subagents pass through.
|
|
5
|
+
#
|
|
6
|
+
# Task 559 — discriminator is `parent_tool_use_id` from the Claude Code
|
|
7
|
+
# hook input. Non-empty → in-window Task subagent (allow).
|
|
8
|
+
#
|
|
9
|
+
# Task 566 — discriminator extended to `$MAXY_SPECIALIST` for maxy MCP
|
|
10
|
+
# `Agent`-spawned specialists (separate top-level `claude rc` processes
|
|
11
|
+
# with no `parent_tool_use_id` but `MAXY_SPECIALIST=<name>` on env, set
|
|
12
|
+
# by pty-spawner.ts:1601). Calls with neither marker AND no
|
|
13
|
+
# `MAXY_SESSION_ROLE=admin` fail closed with `reason=missing-marker` so a
|
|
14
|
+
# future spawn shape cannot silently regress.
|
|
9
15
|
|
|
10
16
|
set -u
|
|
11
17
|
|
|
@@ -27,26 +33,40 @@ PASS=0; FAIL=0
|
|
|
27
33
|
pass() { PASS=$((PASS+1)); echo "PASS: $1"; }
|
|
28
34
|
fail() { FAIL=$((FAIL+1)); echo "FAIL: $1" >&2; }
|
|
29
35
|
|
|
30
|
-
|
|
31
|
-
|
|
36
|
+
# The hook receives the parent session's transcript_path even on subagent
|
|
37
|
+
# calls in Claude Code 2.1.x. The fixture mirrors production.
|
|
38
|
+
SESSION_TRANSCRIPT="/tmp/sess/44283ae1-2b82-4bd7-b8af-99a11141ed4b.jsonl"
|
|
39
|
+
SUBAGENT_PARENT_ID="toolu_01QFC4W32wFUyHy6WJMNjHf6"
|
|
32
40
|
|
|
41
|
+
# run <tool> <parent_id> <expected_rc> <expected_pattern> <label>
|
|
42
|
+
# Env knobs (default mirror production admin spawn):
|
|
43
|
+
# MAXY_SESSION_ROLE_OVERRIDE — defaults to "admin"; set empty to drop the role
|
|
44
|
+
# MAXY_SPECIALIST_OVERRIDE — defaults to empty; set to a name to simulate
|
|
45
|
+
# an MCP-Agent-spawned specialist
|
|
33
46
|
run() {
|
|
34
|
-
local tool="$1"
|
|
47
|
+
local tool="$1" parent_id="$2" expected_rc="$3" expected_pattern="$4" label="$5"
|
|
48
|
+
local role="${MAXY_SESSION_ROLE_OVERRIDE-admin}"
|
|
49
|
+
local specialist="${MAXY_SPECIALIST_OVERRIDE-}"
|
|
35
50
|
local input_json
|
|
36
|
-
|
|
37
|
-
input_json=$(python3 -c '
|
|
51
|
+
input_json=$(python3 -c '
|
|
38
52
|
import json, sys
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
53
|
+
payload = {
|
|
54
|
+
"hook_event_name": "PreToolUse",
|
|
55
|
+
"tool_name": sys.argv[1],
|
|
56
|
+
"tool_input": {"file_path": "/tmp/x", "content": "x", "command": "echo hi"},
|
|
57
|
+
"transcript_path": sys.argv[2],
|
|
58
|
+
}
|
|
59
|
+
parent = sys.argv[3]
|
|
60
|
+
if parent:
|
|
61
|
+
payload["parent_tool_use_id"] = parent
|
|
62
|
+
print(json.dumps(payload, separators=(",", ":")))
|
|
63
|
+
' "$tool" "$SESSION_TRANSCRIPT" "$parent_id")
|
|
47
64
|
local stdout_file; stdout_file=$(mktemp); TMPFILES+=("$stdout_file")
|
|
48
65
|
local stderr_file; stderr_file=$(mktemp); TMPFILES+=("$stderr_file")
|
|
49
|
-
printf '%s' "$input_json" |
|
|
66
|
+
printf '%s' "$input_json" | \
|
|
67
|
+
env -u MAXY_SESSION_ROLE -u MAXY_SPECIALIST \
|
|
68
|
+
MAXY_SESSION_ROLE="$role" MAXY_SPECIALIST="$specialist" \
|
|
69
|
+
bash "$HOOK" admin >"$stdout_file" 2>"$stderr_file"
|
|
50
70
|
local rc=$?
|
|
51
71
|
if [[ "$rc" -ne "$expected_rc" ]]; then
|
|
52
72
|
fail "$label: expected exit $expected_rc, got $rc. Stderr: $(cat "$stderr_file")"
|
|
@@ -59,39 +79,86 @@ print(json.dumps({"hook_event_name":"PreToolUse","tool_name":sys.argv[1],"tool_i
|
|
|
59
79
|
pass "$label"
|
|
60
80
|
}
|
|
61
81
|
|
|
62
|
-
# ── Admin-direct refusals
|
|
63
|
-
run Write "
|
|
82
|
+
# ── Admin-direct refusals (no parent_tool_use_id, no MAXY_SPECIALIST, role=admin) ───
|
|
83
|
+
run Write "" 2 '\[admin-tool-gate\] role=admin parent_tool_use_id=- specialist=- session_role=admin tool=Write decision=block.*owner=content-producer' \
|
|
64
84
|
"Test 1: admin-direct Write rejected with content-producer dispatch hint"
|
|
65
|
-
run Edit "
|
|
85
|
+
run Edit "" 2 '\[admin-tool-gate\] role=admin .*tool=Edit decision=block.*owner=content-producer' \
|
|
66
86
|
"Test 2: admin-direct Edit rejected"
|
|
67
|
-
run MultiEdit "
|
|
87
|
+
run MultiEdit "" 2 '\[admin-tool-gate\] role=admin .*tool=MultiEdit decision=block.*owner=content-producer' \
|
|
68
88
|
"Test 3: admin-direct MultiEdit rejected"
|
|
69
|
-
run Bash "
|
|
89
|
+
run Bash "" 2 '\[admin-tool-gate\] role=admin .*tool=Bash decision=block' \
|
|
70
90
|
"Test 4: admin-direct Bash rejected"
|
|
71
|
-
run WebFetch "
|
|
91
|
+
run WebFetch "" 2 '\[admin-tool-gate\] role=admin .*tool=WebFetch decision=block.*owner=research-assistant' \
|
|
72
92
|
"Test 5: admin-direct WebFetch rejected"
|
|
73
|
-
run WebSearch "
|
|
93
|
+
run WebSearch "" 2 '\[admin-tool-gate\] role=admin .*tool=WebSearch decision=block.*owner=research-assistant' \
|
|
74
94
|
"Test 6: admin-direct WebSearch rejected"
|
|
75
95
|
|
|
76
|
-
# ── In-window subagent passthrough (
|
|
77
|
-
run Write "$
|
|
96
|
+
# ── In-window subagent passthrough (parent_tool_use_id non-empty) ───────
|
|
97
|
+
run Write "$SUBAGENT_PARENT_ID" 0 "\[admin-tool-gate\] role=subagent parent_tool_use_id=${SUBAGENT_PARENT_ID} .*tool=Write decision=allow" \
|
|
78
98
|
"Test 7: subagent Write passes through"
|
|
79
|
-
run Edit "$
|
|
99
|
+
run Edit "$SUBAGENT_PARENT_ID" 0 '\[admin-tool-gate\] role=subagent .*tool=Edit decision=allow' \
|
|
80
100
|
"Test 8: subagent Edit passes through"
|
|
81
|
-
run Bash "$
|
|
82
|
-
"Test 9: subagent Bash passes through"
|
|
83
|
-
run WebFetch "$
|
|
101
|
+
run Bash "$SUBAGENT_PARENT_ID" 0 '\[admin-tool-gate\] role=subagent .*tool=Bash decision=allow' \
|
|
102
|
+
"Test 9: subagent Bash passes through (Task 559 regression — coding-assistant git pull)"
|
|
103
|
+
run WebFetch "$SUBAGENT_PARENT_ID" 0 '\[admin-tool-gate\] role=subagent .*tool=WebFetch decision=allow' \
|
|
84
104
|
"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"
|
|
105
|
+
run MultiEdit "$SUBAGENT_PARENT_ID" 0 '\[admin-tool-gate\] role=subagent .*tool=MultiEdit decision=allow' \
|
|
106
|
+
"Test 11: subagent MultiEdit passes through"
|
|
91
107
|
|
|
92
108
|
# ── Unrelated tools untouched ───────────────────────────────────────────
|
|
93
|
-
run Read "
|
|
94
|
-
"Test
|
|
109
|
+
run Read "" 0 '' \
|
|
110
|
+
"Test 12: admin-direct Read passes (Read is admin-keep-list)"
|
|
111
|
+
|
|
112
|
+
# ── Task 560: admin-direct block emits propagation record ──────────────
|
|
113
|
+
# Admin-direct path: MAXY_SESSION_ROLE=admin, no MAXY_SPECIALIST, no parent.
|
|
114
|
+
PROP_TMPDIR=$(mktemp -d); TMPFILES+=("$PROP_TMPDIR")
|
|
115
|
+
export MAXY_HOOK_BUFFER_DIR="$PROP_TMPDIR/buffers"
|
|
116
|
+
export MAXY_HOOK_SERVER_LOG="$PROP_TMPDIR/server.log"
|
|
117
|
+
SESSION="task560-test-sess"
|
|
118
|
+
PROP_INPUT=$(python3 -c '
|
|
119
|
+
import json, sys
|
|
120
|
+
print(json.dumps({"hook_event_name":"PreToolUse","tool_name":"Bash",
|
|
121
|
+
"tool_input":{"command":"ls"},"transcript_path":sys.argv[1],
|
|
122
|
+
"session_id":sys.argv[2]}, separators=(",",":")))
|
|
123
|
+
' "$SESSION_TRANSCRIPT" "$SESSION")
|
|
124
|
+
printf '%s' "$PROP_INPUT" | \
|
|
125
|
+
env -u MAXY_SPECIALIST MAXY_SESSION_ROLE=admin \
|
|
126
|
+
bash "$HOOK" admin >/dev/null 2>/dev/null
|
|
127
|
+
BUF="$MAXY_HOOK_BUFFER_DIR/$SESSION.jsonl"
|
|
128
|
+
if [[ -f "$BUF" ]] && grep -q '"hookName":"admin-tool-gate"' "$BUF" \
|
|
129
|
+
&& grep -q '"decision":"block"' "$BUF" \
|
|
130
|
+
&& grep -q '"reason":"orchestration-only"' "$BUF"; then
|
|
131
|
+
pass "Test 13b: admin-direct Bash block emits Task 560 propagation record"
|
|
132
|
+
else
|
|
133
|
+
fail "Test 13b: propagation record missing/malformed — $(cat "$BUF" 2>/dev/null || echo '<no buffer>')"
|
|
134
|
+
fi
|
|
135
|
+
unset MAXY_HOOK_BUFFER_DIR MAXY_HOOK_SERVER_LOG
|
|
136
|
+
|
|
137
|
+
# ── Task 566: maxy-MCP-Agent-spawned specialist passthrough ────────────
|
|
138
|
+
# MCP-Agent specialists are separate top-level claude rc processes with no
|
|
139
|
+
# parent_tool_use_id; pty-spawner stamps MAXY_SPECIALIST=<name> on the env.
|
|
140
|
+
MAXY_SPECIALIST_OVERRIDE=content-producer \
|
|
141
|
+
run Edit "" 0 '\[admin-tool-gate\] role=subagent .*specialist=content-producer .*tool=Edit decision=allow source=mcp-specialist' \
|
|
142
|
+
"Test 14: mcp-specialist Edit passes through (Task 566)"
|
|
143
|
+
|
|
144
|
+
MAXY_SPECIALIST_OVERRIDE=coding-assistant \
|
|
145
|
+
run Bash "" 0 '\[admin-tool-gate\] role=subagent .*specialist=coding-assistant .*tool=Bash decision=allow source=mcp-specialist' \
|
|
146
|
+
"Test 15: mcp-specialist Bash passes through (Task 559 regression class)"
|
|
147
|
+
|
|
148
|
+
MAXY_SPECIALIST_OVERRIDE=research-assistant \
|
|
149
|
+
run WebFetch "" 0 '\[admin-tool-gate\] role=subagent .*specialist=research-assistant .*tool=WebFetch decision=allow source=mcp-specialist' \
|
|
150
|
+
"Test 16: mcp-specialist WebFetch passes through"
|
|
151
|
+
|
|
152
|
+
# ── Task 566: missing-marker fail-closed ───────────────────────────────
|
|
153
|
+
# No parent_tool_use_id, no MAXY_SPECIALIST, MAXY_SESSION_ROLE not 'admin'.
|
|
154
|
+
MAXY_SESSION_ROLE_OVERRIDE="" MAXY_SPECIALIST_OVERRIDE="" \
|
|
155
|
+
run Edit "" 2 '\[admin-tool-gate\] role=unknown .*tool=Edit decision=block reason=missing-marker' \
|
|
156
|
+
"Test 17: missing-marker fail-closed (no positive role marker)"
|
|
157
|
+
|
|
158
|
+
# ── Task 566: combined parent_tool_use_id + MAXY_SPECIALIST is also subagent ──
|
|
159
|
+
MAXY_SPECIALIST_OVERRIDE=database-operator \
|
|
160
|
+
run Bash "$SUBAGENT_PARENT_ID" 0 '\[admin-tool-gate\] role=subagent .*specialist=database-operator .*tool=Bash decision=allow source=task-subagent\+mcp-specialist' \
|
|
161
|
+
"Test 18: combined parent + specialist resolves as task-subagent+mcp-specialist"
|
|
95
162
|
|
|
96
163
|
echo
|
|
97
164
|
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=$?
|