@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.
Files changed (67) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/.claude-plugin/plugin.json +1 -1
  3. package/payload/platform/plugins/admin/PLUGIN.md +5 -1
  4. package/payload/platform/plugins/admin/hooks/__tests__/hook-emit.test.sh +75 -0
  5. package/payload/platform/plugins/admin/hooks/__tests__/post-tool-use-agent.test.sh +173 -0
  6. package/payload/platform/plugins/admin/hooks/__tests__/pre-tool-use-admin-tool-gate.test.sh +107 -40
  7. package/payload/platform/plugins/admin/hooks/__tests__/pre-tool-use-base64-guard.test.sh +4 -4
  8. package/payload/platform/plugins/admin/hooks/lib/hook-emit.sh +114 -0
  9. package/payload/platform/plugins/admin/hooks/post-tool-use-agent.sh +136 -0
  10. package/payload/platform/plugins/admin/hooks/pre-tool-use.sh +127 -68
  11. package/payload/platform/plugins/admin/mcp/dist/__tests__/capabilities-here.test.d.ts +2 -0
  12. package/payload/platform/plugins/admin/mcp/dist/__tests__/capabilities-here.test.d.ts.map +1 -0
  13. package/payload/platform/plugins/admin/mcp/dist/__tests__/capabilities-here.test.js +130 -0
  14. package/payload/platform/plugins/admin/mcp/dist/__tests__/capabilities-here.test.js.map +1 -0
  15. package/payload/platform/plugins/admin/mcp/dist/index.js +63 -0
  16. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  17. package/payload/platform/plugins/admin/mcp/dist/lib/catalogue-census.d.ts +14 -0
  18. package/payload/platform/plugins/admin/mcp/dist/lib/catalogue-census.d.ts.map +1 -0
  19. package/payload/platform/plugins/admin/mcp/dist/lib/catalogue-census.js +50 -0
  20. package/payload/platform/plugins/admin/mcp/dist/lib/catalogue-census.js.map +1 -0
  21. package/payload/platform/plugins/admin/mcp/dist/tools/capabilities-here.d.ts +28 -0
  22. package/payload/platform/plugins/admin/mcp/dist/tools/capabilities-here.d.ts.map +1 -0
  23. package/payload/platform/plugins/admin/mcp/dist/tools/capabilities-here.js +68 -0
  24. package/payload/platform/plugins/admin/mcp/dist/tools/capabilities-here.js.map +1 -0
  25. package/payload/platform/plugins/admin/skills/capabilities-here/SKILL.md +31 -0
  26. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +30 -14
  27. package/payload/platform/plugins/docs/references/platform.md +19 -9
  28. package/payload/platform/plugins/docs/references/plugins-guide.md +2 -0
  29. package/payload/platform/scripts/check-architecture-skill-no-drift.mjs +89 -42
  30. package/payload/platform/scripts/check-roles-doc-completeness.mjs +96 -0
  31. package/payload/platform/scripts/check-specialist-tool-surface.mjs +1 -1
  32. package/payload/platform/scripts/setup-account.sh +6 -0
  33. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
  34. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +1 -0
  35. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
  36. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  37. package/payload/platform/services/claude-session-manager/dist/http-server.js +77 -0
  38. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  39. package/payload/platform/services/claude-session-manager/dist/index.js +30 -0
  40. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  41. package/payload/platform/services/claude-session-manager/dist/jsonl-tail.d.ts +18 -0
  42. package/payload/platform/services/claude-session-manager/dist/jsonl-tail.d.ts.map +1 -1
  43. package/payload/platform/services/claude-session-manager/dist/jsonl-tail.js +63 -0
  44. package/payload/platform/services/claude-session-manager/dist/jsonl-tail.js.map +1 -1
  45. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts +5 -0
  46. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
  47. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +29 -0
  48. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
  49. package/payload/platform/services/claude-session-manager/dist/rc-script-spawn.d.ts +80 -0
  50. package/payload/platform/services/claude-session-manager/dist/rc-script-spawn.d.ts.map +1 -0
  51. package/payload/platform/services/claude-session-manager/dist/rc-script-spawn.js +306 -0
  52. package/payload/platform/services/claude-session-manager/dist/rc-script-spawn.js.map +1 -0
  53. package/payload/platform/services/claude-session-manager/dist/specialist-drift.d.ts +9 -0
  54. package/payload/platform/services/claude-session-manager/dist/specialist-drift.d.ts.map +1 -1
  55. package/payload/platform/services/claude-session-manager/dist/specialist-drift.js +6 -2
  56. package/payload/platform/services/claude-session-manager/dist/specialist-drift.js.map +1 -1
  57. package/payload/platform/templates/agents/admin/IDENTITY.md +3 -1
  58. package/payload/platform/templates/specialists/agents/content-producer.md +1 -1
  59. package/payload/server/public/assets/AdminShell-CPWbJ_I8.js +1 -0
  60. package/payload/server/public/assets/{admin-DMo5DWKH.js → admin-DjGGOx2K.js} +1 -1
  61. package/payload/server/public/assets/{data-D6IQdm0R.js → data-WBEvtRV-.js} +1 -1
  62. package/payload/server/public/assets/{graph-PkZKVkWD.js → graph-DsOLsIPf.js} +1 -1
  63. package/payload/server/public/data.html +2 -2
  64. package/payload/server/public/graph.html +2 -2
  65. package/payload/server/public/index.html +2 -2
  66. package/payload/server/server.js +6 -0
  67. package/payload/server/public/assets/AdminShell-BcxEZnpu.js +0 -1
@@ -0,0 +1,114 @@
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
+ # Task 563 — mkdir-based mutex serialises emitter append vs drainer rename.
13
+ # 100 attempts × 10ms = ~1s ceiling. Portable across Linux (Pi) and macOS
14
+ # (tests) without flock(1).
15
+ : "${MAXY_HOOK_LOCK_ATTEMPTS:=100}"
16
+ : "${MAXY_HOOK_LOCK_SLEEP:=0.01}"
17
+
18
+ # _hook_acquire_lock <lockdir> — returns 0 on success, 1 on timeout.
19
+ _hook_acquire_lock() {
20
+ local lockdir="$1" i=0
21
+ while ! mkdir "$lockdir" 2>/dev/null; do
22
+ i=$((i+1))
23
+ [[ "$i" -ge "$MAXY_HOOK_LOCK_ATTEMPTS" ]] && return 1
24
+ sleep "$MAXY_HOOK_LOCK_SLEEP" 2>/dev/null || true
25
+ done
26
+ return 0
27
+ }
28
+
29
+ # _hook_release_lock <lockdir>
30
+ _hook_release_lock() {
31
+ rmdir "$1" 2>/dev/null || true
32
+ }
33
+
34
+ # hook_emit_decision <agentId> <hookName> <toolName> <decision> <reason>
35
+ # <stderrPayload> <exitCode> <durationMs>
36
+ hook_emit_decision() {
37
+ local agentId="$1" hookName="$2" toolName="$3" decision="$4" reason="$5"
38
+ local stderrPayload="$6" exitCode="$7" durationMs="$8"
39
+
40
+ local rec
41
+ rec=$(MAXY_HOOK_STDERR_CAP="$MAXY_HOOK_STDERR_CAP" python3 - \
42
+ "$agentId" "$hookName" "$toolName" "$decision" "$reason" \
43
+ "$stderrPayload" "$exitCode" "$durationMs" <<'PY' 2>/dev/null
44
+ import json, os, sys, datetime
45
+ cap = int(os.environ.get("MAXY_HOOK_STDERR_CAP", "4096"))
46
+ (agentId, hookName, toolName, decision, reason,
47
+ stderrPayload, exitCode, durationMs) = sys.argv[1:9]
48
+ truncated = False
49
+ if len(stderrPayload) > cap:
50
+ dropped = len(stderrPayload) - cap
51
+ stderrPayload = stderrPayload[:cap] + f"…[truncated {dropped} bytes]"
52
+ truncated = True
53
+ print(json.dumps({
54
+ "ts": datetime.datetime.utcnow().replace(microsecond=0).isoformat() + "Z",
55
+ "agentId": agentId,
56
+ "hookName": hookName,
57
+ "toolName": toolName,
58
+ "decision": decision,
59
+ "reason": reason,
60
+ "stderr": stderrPayload,
61
+ "exitCode": int(exitCode) if exitCode.lstrip("-").isdigit() else 0,
62
+ "durationMs": int(durationMs) if durationMs.lstrip("-").isdigit() else 0,
63
+ "truncated": truncated,
64
+ }, separators=(",", ":")))
65
+ PY
66
+ )
67
+ if [[ -z "$rec" ]]; then
68
+ mkdir -p "$(dirname "$MAXY_HOOK_SERVER_LOG")" 2>/dev/null
69
+ printf '[hook-propagate-error] reason=record-build agentId=%s hook=%s\n' \
70
+ "$agentId" "$hookName" >>"$MAXY_HOOK_SERVER_LOG" 2>/dev/null
71
+ return 0
72
+ fi
73
+
74
+ mkdir -p "$MAXY_HOOK_BUFFER_DIR" 2>/dev/null
75
+ local buf="$MAXY_HOOK_BUFFER_DIR/$agentId.jsonl"
76
+ local lock="${buf}.lock"
77
+ if ! _hook_acquire_lock "$lock"; then
78
+ mkdir -p "$(dirname "$MAXY_HOOK_SERVER_LOG")" 2>/dev/null
79
+ printf '[hook-propagate-error] reason=lock-timeout agentId=%s hook=%s\n' \
80
+ "$agentId" "$hookName" >>"$MAXY_HOOK_SERVER_LOG" 2>/dev/null
81
+ return 0
82
+ fi
83
+ if ! printf '%s\n' "$rec" >>"$buf" 2>/dev/null; then
84
+ _hook_release_lock "$lock"
85
+ mkdir -p "$(dirname "$MAXY_HOOK_SERVER_LOG")" 2>/dev/null
86
+ printf '[hook-propagate-error] reason=buffer-write agentId=%s hook=%s\n' \
87
+ "$agentId" "$hookName" >>"$MAXY_HOOK_SERVER_LOG" 2>/dev/null
88
+ return 0
89
+ fi
90
+ _hook_release_lock "$lock"
91
+
92
+ local bytesIn="${#stderrPayload}"
93
+ local bytesOut="${#rec}"
94
+ local trunc=false
95
+ [[ "$rec" == *'"truncated":true'* ]] && trunc=true
96
+ mkdir -p "$(dirname "$MAXY_HOOK_SERVER_LOG")" 2>/dev/null
97
+ printf '[hook-propagate] agentId=%s hookName=%s tool=%s decision=%s bytesIn=%s bytesOut=%s truncated=%s\n' \
98
+ "$agentId" "$hookName" "$toolName" "$decision" "$bytesIn" "$bytesOut" "$trunc" \
99
+ >>"$MAXY_HOOK_SERVER_LOG" 2>/dev/null
100
+ return 0
101
+ }
102
+
103
+ # hook_block_with_emit <agentId> <hookName> <toolName> <reason>
104
+ # <userMessage> [durationMs]
105
+ # Convenience wrapper for pre-tool-use.sh: emits the propagation record,
106
+ # echoes the user-facing block message to stderr, and exits 2.
107
+ hook_block_with_emit() {
108
+ local agentId="$1" hookName="$2" toolName="$3" reason="$4"
109
+ local userMessage="$5" durationMs="${6:-0}"
110
+ hook_emit_decision "$agentId" "$hookName" "$toolName" "block" "$reason" \
111
+ "$userMessage" 2 "$durationMs"
112
+ printf '%s\n' "$userMessage" >&2
113
+ exit 2
114
+ }
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env bash
2
+ # Task 560 — PostToolUse hook scoped to tool_name=Agent in the admin role.
3
+ # Drains every subagent hook-decision buffer in the dir, prints each record
4
+ # as a [hook-propagate] line to stdout (Claude Code attaches the stdout to
5
+ # the parent JSONL as a hook_success attachment), and emits one
6
+ # [hook-propagate-census] line. N != M in the census is the propagation
7
+ # regression signal.
8
+ #
9
+ # Task 563 — concurrency hardening:
10
+ # - Atomic claim before read. Each buffer is renamed under a mkdir mutex
11
+ # to a per-fire `claimed/<basename>-<epoch>-<pid>.jsonl` path. The lock
12
+ # is held only for the rename syscall; reading happens against the
13
+ # renamed inode, which no emitter can address. The race window between
14
+ # the drainer's read and the buffer rotation is closed.
15
+ # - No cursor. The cursor file was load-bearing only because consumed
16
+ # buffers could be re-seen; with atomic claim the file's presence in
17
+ # the directory is the unambiguous signal. Dropping the cursor closes
18
+ # two further races (concurrent-drainer cursor write, shared
19
+ # `.unknown.cursor` across broken-stdin fires).
20
+ # - Unknown-parent observability. When stdin's `session_id` is missing
21
+ # or unparseable, an explicit `[hook-propagate] parentSession=unknown
22
+ # reason=session-id-missing` line is emitted to both stdout and
23
+ # server.log so the broken-stdin path is no longer silent.
24
+
25
+ set -uo pipefail
26
+
27
+ : "${MAXY_HOOK_BUFFER_DIR:=$HOME/.maxy-code/logs/hook-decisions}"
28
+ : "${MAXY_HOOK_SERVER_LOG:=$HOME/.maxy-code/logs/server.log}"
29
+ : "${MAXY_HOOK_LOCK_ATTEMPTS:=100}"
30
+ : "${MAXY_HOOK_LOCK_SLEEP:=0.01}"
31
+
32
+ if [ -t 0 ]; then
33
+ # No stdin — no correlation possible. Fail-open.
34
+ exit 0
35
+ fi
36
+ INPUT=$(cat)
37
+ PARENT_SESSION=$(printf '%s' "$INPUT" | python3 -c '
38
+ import sys, json
39
+ try:
40
+ sid = json.load(sys.stdin).get("session_id") or ""
41
+ print(sid if sid else "unknown")
42
+ except Exception:
43
+ print("unknown")
44
+ ' 2>/dev/null || echo "unknown")
45
+
46
+ if [[ "$PARENT_SESSION" == "unknown" ]]; then
47
+ # Conservative play (Task 563 brief option C): emit observability and
48
+ # exit without draining. A broken-stdin fire has no business processing
49
+ # buffers — the next valid parent fire will pick them up.
50
+ mkdir -p "$(dirname "$MAXY_HOOK_SERVER_LOG")" 2>/dev/null
51
+ printf '[hook-propagate] parentSession=unknown reason=session-id-missing\n'
52
+ printf '[hook-propagate] parentSession=unknown reason=session-id-missing\n' \
53
+ >>"$MAXY_HOOK_SERVER_LOG" 2>/dev/null || true
54
+ exit 0
55
+ fi
56
+
57
+ mkdir -p "$MAXY_HOOK_BUFFER_DIR/claimed" "$MAXY_HOOK_BUFFER_DIR/consumed" 2>/dev/null
58
+
59
+ # Sweep orphaned `.<session>.cursor` files left behind by an older
60
+ # version of this script. The cursor mechanism is gone; without this
61
+ # they would persist forever as dir noise.
62
+ rm -f "$MAXY_HOOK_BUFFER_DIR"/.*.cursor 2>/dev/null || true
63
+
64
+ NOW_EPOCH=$(date +%s)
65
+ N_OBSERVED=0
66
+ N_EMITTED=0
67
+
68
+ _drain_acquire_lock() {
69
+ local lockdir="$1" i=0
70
+ while ! mkdir "$lockdir" 2>/dev/null; do
71
+ i=$((i+1))
72
+ [[ "$i" -ge "$MAXY_HOOK_LOCK_ATTEMPTS" ]] && return 1
73
+ sleep "$MAXY_HOOK_LOCK_SLEEP" 2>/dev/null || true
74
+ done
75
+ return 0
76
+ }
77
+
78
+ shopt -s nullglob
79
+ for buf in "$MAXY_HOOK_BUFFER_DIR"/*.jsonl; do
80
+ [[ -f "$buf" ]] || continue
81
+ case "$(basename "$buf")" in
82
+ .*) continue;;
83
+ esac
84
+ lock="${buf}.lock"
85
+ if ! _drain_acquire_lock "$lock"; then
86
+ mkdir -p "$(dirname "$MAXY_HOOK_SERVER_LOG")" 2>/dev/null
87
+ printf '[hook-propagate-error] reason=lock-timeout parentSession=%s buffer=%s\n' \
88
+ "$PARENT_SESSION" "$(basename "$buf")" >>"$MAXY_HOOK_SERVER_LOG" 2>/dev/null || true
89
+ continue
90
+ fi
91
+ # Re-check existence under lock — a peer drainer may have claimed it
92
+ # between our `for` iteration and our lock acquisition.
93
+ if [[ ! -f "$buf" ]]; then
94
+ rmdir "$lock" 2>/dev/null || true
95
+ continue
96
+ fi
97
+ CLAIMED="$MAXY_HOOK_BUFFER_DIR/claimed/$(basename "$buf" .jsonl)-${NOW_EPOCH}-$$.jsonl"
98
+ if ! mv "$buf" "$CLAIMED" 2>/dev/null; then
99
+ rmdir "$lock" 2>/dev/null || true
100
+ mkdir -p "$(dirname "$MAXY_HOOK_SERVER_LOG")" 2>/dev/null
101
+ printf '[hook-propagate-error] reason=claim-mv parentSession=%s buffer=%s\n' \
102
+ "$PARENT_SESSION" "$(basename "$buf")" >>"$MAXY_HOOK_SERVER_LOG" 2>/dev/null || true
103
+ continue
104
+ fi
105
+ rmdir "$lock" 2>/dev/null || true
106
+
107
+ # Ground-truth observed count: non-empty lines in the claimed file. The
108
+ # printf-success N_EMITTED is incremented independently below, so a
109
+ # printf failure (SIGPIPE on a closed downstream pipe, EIO on stdout)
110
+ # produces N_OBSERVED > N_EMITTED — the propagation regression signal
111
+ # the census promises. The two counters must not be bumped together.
112
+ BUF_LINES=$(grep -c . "$CLAIMED" 2>/dev/null || echo 0)
113
+ N_OBSERVED=$((N_OBSERVED + BUF_LINES))
114
+ while IFS= read -r line || [[ -n "$line" ]]; do
115
+ [[ -z "$line" ]] && continue
116
+ if printf '[hook-propagate] %s\n' "$line" 2>/dev/null; then
117
+ N_EMITTED=$((N_EMITTED+1))
118
+ fi
119
+ done <"$CLAIMED"
120
+ TARGET="$MAXY_HOOK_BUFFER_DIR/consumed/$(basename "$CLAIMED")"
121
+ if ! mv "$CLAIMED" "$TARGET" 2>/dev/null; then
122
+ mkdir -p "$(dirname "$MAXY_HOOK_SERVER_LOG")" 2>/dev/null
123
+ printf '[hook-propagate-error] reason=consumed-mv parentSession=%s buffer=%s\n' \
124
+ "$PARENT_SESSION" "$(basename "$CLAIMED")" >>"$MAXY_HOOK_SERVER_LOG" 2>/dev/null || true
125
+ fi
126
+ done
127
+ shopt -u nullglob
128
+
129
+ printf '[hook-propagate-census] parentSession=%s subagentHooksObserved=%d attachmentsEmitted=%d\n' \
130
+ "$PARENT_SESSION" "$N_OBSERVED" "$N_EMITTED"
131
+
132
+ mkdir -p "$(dirname "$MAXY_HOOK_SERVER_LOG")" 2>/dev/null
133
+ printf '[hook-propagate-census] parentSession=%s subagentHooksObserved=%d attachmentsEmitted=%d\n' \
134
+ "$PARENT_SESSION" "$N_OBSERVED" "$N_EMITTED" >>"$MAXY_HOOK_SERVER_LOG" 2>/dev/null || true
135
+
136
+ exit 0
@@ -8,72 +8,110 @@ 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, 566) ───
25
47
  # The admin seat is orchestration + clarification + delivery only.
26
48
  # Write/Edit/MultiEdit/Bash/WebFetch/WebSearch are specialist-owned —
27
49
  # content-producer for authoring, research-assistant for research, the
28
50
  # shell tools for whichever specialist owns the workflow that needs them.
29
51
  # In-window Task subagents must keep these tools (Task 222 regression
30
- # class) the discriminator is the transcript_path: Claude Code stores
31
- # subagent turns in separate `*/subagents/agent-*.jsonl` files.
52
+ # class). Maxy MCP `Agent`-spawned specialists are separate top-level
53
+ # `claude rc` processes they have no `parent_tool_use_id` but pty-spawner
54
+ # stamps `MAXY_SPECIALIST=<name>` on the child env (pty-spawner.ts:1601).
55
+ #
56
+ # Discriminator (union of positive markers, never path-shape inference):
57
+ # parent_tool_use_id non-empty → in-window Task subagent (allow)
58
+ # $MAXY_SPECIALIST non-empty → maxy MCP Agent specialist (allow)
59
+ # $MAXY_SESSION_ROLE = admin → admin-direct (block, orchestration-only)
60
+ # none of the above → fail closed (missing-marker)
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
+ # Adding a future spawn shape that lands neither marker surfaces as a loud
63
+ # `reason=missing-marker` block, not a silent passthrough or a silent
64
+ # admin-direct block on legitimate specialist work.
36
65
  case "$TOOL_NAME" in
37
66
  Write|Edit|MultiEdit|Bash|WebFetch|WebSearch)
38
- TRANSCRIPT_PATH=$(echo "$INPUT" | python3 -c '
67
+ PARENT_TOOL_USE_ID=$(echo "$INPUT" | python3 -c '
39
68
  import sys, json
40
69
  try:
41
- print(json.load(sys.stdin).get("transcript_path", "") or "")
70
+ v = json.load(sys.stdin).get("parent_tool_use_id", "")
71
+ print(v if isinstance(v, str) else "")
42
72
  except Exception:
43
73
  print("")
44
74
  ' 2>/dev/null)
45
- case "$TRANSCRIPT_PATH" in
46
- */subagents/agent-*.jsonl)
47
- # In-window subagent passthrough. Tool name resolves on the
48
- # subagent's own frontmatter `tools:` allowlist downstream.
49
- echo "[admin-tool-gate] role=subagent tool=${TOOL_NAME} decision=allow source=hook-passthrough" >&2
50
- ;;
51
- "")
52
- # Missing transcript_path on a denied native tool — fail closed
53
- # rather than guess (Task 222 fail-closed contract).
54
- echo "[admin-tool-gate] role=unknown tool=${TOOL_NAME} decision=block reason=missing-transcript-path" >&2
55
- echo "Blocked: ${TOOL_NAME} is unavailable from the admin seat and the caller cannot be classified (no transcript_path). Failing closed." >&2
56
- exit 2
57
- ;;
58
- *)
59
- # Admin direct invocation — refuse with dispatch hint.
60
- case "$TOOL_NAME" in
61
- Write|Edit|MultiEdit)
62
- OWNER="content-producer"
63
- ;;
64
- Bash)
65
- OWNER="the specialist that owns the workflow needing shell access"
66
- ;;
67
- WebFetch|WebSearch)
68
- OWNER="research-assistant"
69
- ;;
70
- esac
71
- echo "[admin-tool-gate] role=admin tool=${TOOL_NAME} decision=block reason=orchestration-only owner=${OWNER}" >&2
72
- echo "Blocked: \`${TOOL_NAME}\` is not callable from the admin seat. The admin seat is orchestration + clarification + delivery only — authoring, shell work, and web research are owned by specialists." >&2
73
- echo "Dispatch the work to ${OWNER} via the Agent tool." >&2
74
- exit 2
75
- ;;
76
- esac
75
+ SPECIALIST="${MAXY_SPECIALIST:-}"
76
+ SESSION_ROLE="${MAXY_SESSION_ROLE:-}"
77
+ if [ -n "$PARENT_TOOL_USE_ID" ] || [ -n "$SPECIALIST" ]; then
78
+ # Subagent passthrough. Tool name resolves on the subagent's
79
+ # own frontmatter `tools:` allowlist downstream.
80
+ SOURCE="task-subagent"
81
+ if [ -n "$SPECIALIST" ] && [ -n "$PARENT_TOOL_USE_ID" ]; then
82
+ SOURCE="task-subagent+mcp-specialist"
83
+ elif [ -n "$SPECIALIST" ]; then
84
+ SOURCE="mcp-specialist"
85
+ fi
86
+ echo "[admin-tool-gate] role=subagent parent_tool_use_id=${PARENT_TOOL_USE_ID:--} specialist=${SPECIALIST:--} session_role=${SESSION_ROLE:--} tool=${TOOL_NAME} decision=allow source=${SOURCE}" >&2
87
+ elif [ "$SESSION_ROLE" = "admin" ]; then
88
+ # Admin direct invocation — refuse with dispatch hint.
89
+ case "$TOOL_NAME" in
90
+ Write|Edit|MultiEdit)
91
+ OWNER="content-producer"
92
+ ;;
93
+ Bash)
94
+ OWNER="the specialist that owns the workflow needing shell access"
95
+ ;;
96
+ WebFetch|WebSearch)
97
+ OWNER="research-assistant"
98
+ ;;
99
+ esac
100
+ echo "[admin-tool-gate] role=admin parent_tool_use_id=- specialist=- session_role=admin tool=${TOOL_NAME} decision=block reason=orchestration-only owner=${OWNER}" >&2
101
+ hook_block_with_emit "$SESSION_ID" "admin-tool-gate" "${TOOL_NAME}" \
102
+ "orchestration-only" \
103
+ "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.
104
+ Dispatch the work to ${OWNER} via the Agent tool."
105
+ else
106
+ # No positive marker — fail closed. A new spawn shape that lands
107
+ # neither parent_tool_use_id nor MAXY_SPECIALIST and is not the
108
+ # admin seat should surface visibly, not silently pass or silently
109
+ # block under the wrong reason.
110
+ echo "[admin-tool-gate] role=unknown parent_tool_use_id=- specialist=- session_role=${SESSION_ROLE:--} tool=${TOOL_NAME} decision=block reason=missing-marker" >&2
111
+ hook_block_with_emit "$SESSION_ID" "admin-tool-gate" "${TOOL_NAME}" \
112
+ "missing-marker" \
113
+ "Blocked: \`${TOOL_NAME}\` call carries no positive role marker (no parent_tool_use_id, no MAXY_SPECIALIST, MAXY_SESSION_ROLE is not 'admin'). Failing closed so a new spawn shape cannot silently bypass orchestration gating."
114
+ fi
77
115
  ;;
78
116
  esac
79
117
 
@@ -84,13 +122,15 @@ except Exception:
84
122
  case "$FILE_PATH" in
85
123
  # Platform UI source (platform/ui/) — source lives here, not on device
86
124
  */platform/ui/app/*|*/platform/ui/lib/*|*/platform/ui/*.ts|*/platform/ui/*.tsx|*/platform/ui/*.mjs)
87
- echo "Blocked: Admin agent cannot modify platform UI source at $FILE_PATH" >&2
88
- exit 2
125
+ hook_block_with_emit "$SESSION_ID" "admin-tool-gate" "${TOOL_NAME}" \
126
+ "code-dir-protection-ui" \
127
+ "Blocked: Admin agent cannot modify platform UI source at $FILE_PATH"
89
128
  ;;
90
129
  # Compiled MCP servers, hooks, and scripts — what actually ships to device
91
130
  */platform/plugins/*/mcp/dist/*|*/platform/plugins/*/hooks/*|*/platform/scripts/*)
92
- echo "Blocked: Admin agent cannot modify platform code at $FILE_PATH" >&2
93
- exit 2
131
+ hook_block_with_emit "$SESSION_ID" "admin-tool-gate" "${TOOL_NAME}" \
132
+ "code-dir-protection-platform" \
133
+ "Blocked: Admin agent cannot modify platform code at $FILE_PATH"
94
134
  ;;
95
135
  # Entitlement library — verifier source, compiled output, vendored
96
136
  # pubkey, hash file, and any signed entitlement.json. Tampering here is the
@@ -99,9 +139,10 @@ except Exception:
99
139
  # Patterns intentionally cover both absolute (*/...) and relative
100
140
  # (no leading slash) paths so an agent can't bypass via cwd-relative writes.
101
141
  */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
142
  echo "[entitlement] tool-deny: tool=${TOOL_NAME} path=${FILE_PATH} field=entitlement-file" >&2
104
- exit 2
143
+ hook_block_with_emit "$SESSION_ID" "admin-tool-gate" "${TOOL_NAME}" \
144
+ "entitlement-file" \
145
+ "Blocked: Admin agent cannot modify entitlement files at $FILE_PATH. Effective tier and purchasedPlugins derive from a Rubytech-signed payload."
105
146
  ;;
106
147
  # account.json — agent must use the account-update
107
148
  # MCP tool (or plugin-toggle-enabled for enabledPlugins changes), which
@@ -112,14 +153,16 @@ except Exception:
112
153
  # incident showed a tier upgrade via raw Edit on account.json reach
113
154
  # the disk, exactly the failure mode this hook exists to prevent.
114
155
  */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
156
  echo "[entitlement] tool-deny: tool=${TOOL_NAME} path=${FILE_PATH} field=account-json" >&2
117
- exit 2
157
+ hook_block_with_emit "$SESSION_ID" "admin-tool-gate" "${TOOL_NAME}" \
158
+ "account-json" \
159
+ "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
160
  ;;
119
161
  # Pending action queue — only the hook and MCP tools may write here
120
162
  */pending-actions/*)
121
- echo "Blocked: Admin agent cannot modify pending action files directly" >&2
122
- exit 2
163
+ hook_block_with_emit "$SESSION_ID" "admin-tool-gate" "${TOOL_NAME}" \
164
+ "pending-actions" \
165
+ "Blocked: Admin agent cannot modify pending action files directly"
123
166
  ;;
124
167
  esac
125
168
  ;;
@@ -127,24 +170,28 @@ except Exception:
127
170
  # Block shell commands referencing protected code directories or pending actions
128
171
  case "$INPUT" in
129
172
  *"platform/ui/app/"*|*"platform/ui/lib/"*|*"platform/plugins/"*"hooks/"*|*"platform/scripts/"*)
130
- echo "Blocked: Admin agent cannot run shell commands against code directories" >&2
131
- exit 2
173
+ hook_block_with_emit "$SESSION_ID" "admin-tool-gate" "Bash" \
174
+ "code-dir-protection-bash" \
175
+ "Blocked: Admin agent cannot run shell commands against code directories"
132
176
  ;;
133
177
  # Entitlement files via shell — same surface, blocked symmetrically
134
178
  *"platform/lib/entitlement/"*|*"entitlement.json"*)
135
- echo "Blocked: Admin agent cannot reference entitlement files via shell." >&2
136
179
  echo "[entitlement] tool-deny: tool=Bash path=entitlement-file field=entitlement-file" >&2
137
- exit 2
180
+ hook_block_with_emit "$SESSION_ID" "admin-tool-gate" "Bash" \
181
+ "entitlement-shell" \
182
+ "Blocked: Admin agent cannot reference entitlement files via shell."
138
183
  ;;
139
184
  # account.json via shell — same rationale as Edit/Write block
140
185
  *"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
186
  echo "[entitlement] tool-deny: tool=Bash path=account-json field=account-json" >&2
143
- exit 2
187
+ hook_block_with_emit "$SESSION_ID" "admin-tool-gate" "Bash" \
188
+ "account-json-shell" \
189
+ "Blocked: Admin agent cannot edit account.json via shell. Use the account-update MCP tool."
144
190
  ;;
145
191
  *"pending-actions/"*)
146
- echo "Blocked: Admin agent cannot modify pending action files via shell" >&2
147
- exit 2
192
+ hook_block_with_emit "$SESSION_ID" "admin-tool-gate" "Bash" \
193
+ "pending-actions-shell" \
194
+ "Blocked: Admin agent cannot modify pending action files via shell"
148
195
  ;;
149
196
  esac
150
197
  ;;
@@ -229,17 +276,19 @@ except Exception:
229
276
  REJECT:base64-write-content:*)
230
277
  BYTES="${GUARD_VERDICT##*:}"
231
278
  echo "[pre-tool-use] guard=base64-write-content bytes=${BYTES} action=reject" >&2
232
- echo "Blocked: ${TOOL_NAME} content carries an inline base64 payload (>4 KB encoded). Inline binary in Write.content overloads the model context — the same path produced a main_stream_stalled at ~33 KB on 2026-05-09." >&2
233
- echo "Save the bytes to \$ACCOUNT_DIR/tmp/<sha1>.<ext> via Bash (e.g. 'base64 -d > out.png'), then reference the file from the document: <img src=\"./<file>\"> or Read-by-path. Do not carry binary bytes through the assistant turn." >&2
234
- exit 2
279
+ hook_block_with_emit "$SESSION_ID" "base64-guard" "${TOOL_NAME}" \
280
+ "base64-write-content" \
281
+ "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.
282
+ 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
283
  ;;
236
284
  REJECT:base64-encoder:*|REJECT:xxd-plain-hex:*)
237
285
  REASON="${GUARD_VERDICT#REJECT:}"; REASON="${REASON%:*}"
238
286
  BYTES="${GUARD_VERDICT##*:}"
239
287
  echo "[pre-tool-use] guard=base64-tool-result bytes=${BYTES} tool=Bash reason=${REASON} action=reject" >&2
240
- echo "Blocked: Bash command would emit binary as inline base64/hex to stdout, which lands in the assistant turn and overloads the model context (the 2026-05-09 Rubytech-invoice path hit 66% context after a single ~33 KB tool_result)." >&2
241
- echo "Instead: save the bytes directly to \$ACCOUNT_DIR/tmp/<sha1>.<ext> and operate on the file via path — Read for inspection, <img src=\"./<file>\"> for HTML embedding, the \`file-presentation\` skill for delivery. Decoding base64 (e.g. 'base64 -d in.b64 > out.bin') is allowed." >&2
242
- exit 2
288
+ hook_block_with_emit "$SESSION_ID" "base64-guard" "Bash" \
289
+ "$REASON" \
290
+ "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).
291
+ 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
292
  ;;
244
293
  *)
245
294
  : # ALLOW — fall through to approval gating below
@@ -313,8 +362,9 @@ except Exception:
313
362
  # Write atomically: temp file + mv
314
363
  TEMP=$(mktemp "${PENDING_DIR}/.tmp.XXXXXX" 2>/dev/null)
315
364
  if [ -z "$TEMP" ]; then
316
- echo "Blocked: Action requires approval but failed to create queue file (disk error). Failing closed." >&2
317
- exit 2
365
+ hook_block_with_emit "$SESSION_ID" "approval-gate" "${TOOL_NAME}" \
366
+ "approval-queue-mktemp" \
367
+ "Blocked: Action requires approval but failed to create queue file (disk error). Failing closed."
318
368
  fi
319
369
 
320
370
  # The pending action file contains the full hook JSON (tool_name + tool_input)
@@ -332,10 +382,19 @@ ENDJSON
332
382
 
333
383
  if ! mv "$TEMP" "${PENDING_DIR}/${ACTION_ID}.json" 2>/dev/null; then
334
384
  rm -f "$TEMP" 2>/dev/null
335
- echo "Blocked: Action requires approval but failed to queue (filesystem error). Failing closed." >&2
336
- exit 2
385
+ hook_block_with_emit "$SESSION_ID" "approval-gate" "${TOOL_NAME}" \
386
+ "approval-queue-mv" \
387
+ "Blocked: Action requires approval but failed to queue (filesystem error). Failing closed."
337
388
  fi
338
389
 
390
+ # Record the queued-for-approval block in the propagation buffer; the
391
+ # readable summary still goes to stdout (CC attaches it as the agent's
392
+ # tool_result). hook_emit_decision is the no-exit variant — the
393
+ # subsequent echoes + exit 2 below remain the operator-facing surface.
394
+ hook_emit_decision "$SESSION_ID" "approval-gate" "${TOOL_NAME}" \
395
+ "block" "approval-queued" \
396
+ "Action ${ACTION_ID} queued for review" 2 0
397
+
339
398
  # Extract a readable summary of the action for the agent to present
340
399
  # tool_input is the second JSON value in the hook payload
341
400
  echo "Action requires approval before execution."
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=capabilities-here.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"capabilities-here.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/capabilities-here.test.ts"],"names":[],"mappings":""}