@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
|
@@ -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)
|
|
31
|
-
#
|
|
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
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
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
|
-
|
|
67
|
+
PARENT_TOOL_USE_ID=$(echo "$INPUT" | python3 -c '
|
|
39
68
|
import sys, json
|
|
40
69
|
try:
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
317
|
-
|
|
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
|
-
|
|
336
|
-
|
|
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 @@
|
|
|
1
|
+
{"version":3,"file":"capabilities-here.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/capabilities-here.test.ts"],"names":[],"mappings":""}
|