pantheon-opencode 1.2.2 → 1.3.0
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/AGENTS.md +1 -0
- package/README.md +43 -4
- package/opencode.json +16 -0
- package/package.json +1 -1
- package/scripts/hooks/log-session-start.sh +19 -1
- package/scripts/hooks/on-subagent-delegation-start.sh +116 -6
- package/scripts/hooks/on-subagent-delegation-stop.sh +391 -9
- package/scripts/hooks/scan-secrets.sh +61 -19
- package/scripts/hooks/validate-talos-scope.sh +7 -0
- package/src/agents/zeus.md +33 -5
- package/src/plugins/pantheon-hooks.ts +1391 -41
- package/src/plugins/tui/dist/tui.js +1 -1
- package/src/plugins/tui/dist/tui.tsx +1 -1
- package/src/plugins/tui/package.json +1 -1
- package/src/plugins/tui/src/index.tsx +1 -1
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# on-subagent-delegation-stop.sh — Pantheon SubagentStop Audit Hook
|
|
3
|
-
# Logs completion or
|
|
3
|
+
# Logs completion, failure, or BACKGROUND DISPATCH of subagent tasks.
|
|
4
|
+
#
|
|
5
|
+
# Honest telemetry (Council P1-1, 2026-08-06; P0-1/P0-2, 2026-08-06 2nd
|
|
6
|
+
# session): the previous version read AGENT_NAME from env (always '' — opencode
|
|
7
|
+
# tool.execute hooks carry no agent_id) and defaulted STATUS to 'success',
|
|
8
|
+
# fabricating success even for refusals/failures. This version reads the
|
|
9
|
+
# Claude Code protocol payload {tool_name, tool_input, agent_id, session_id,
|
|
10
|
+
# delegation_id, task_id, duration_ms} from stdin and derives:
|
|
11
|
+
#
|
|
12
|
+
# agent = tool_input.subagent_type (task tool) -> payload agent_id (the
|
|
13
|
+
# plugin's BEFORE-time agent from its callID-keyed record — the
|
|
14
|
+
# after-hook args may carry the MERGED background result
|
|
15
|
+
# {task_id, state} and are NEVER used to derive the agent) ->
|
|
16
|
+
# tool_name -> env AGENT_NAME -> 'unknown'.
|
|
17
|
+
# status = 1. env STATUS override (hook-runner never sets it — empty here)
|
|
18
|
+
# 2. payload.status — plugin-derived from the REAL tool result.
|
|
19
|
+
# P0-1: 'dispatched' is now a first-class status — the task
|
|
20
|
+
# tool in BACKGROUND mode returns IMMEDIATELY with
|
|
21
|
+
# {task_id, state:'running'}, so tool.execute.after observes
|
|
22
|
+
# DISPATCH, not completion. Background dispatch is recorded
|
|
23
|
+
# as 'dispatched', NEVER 'success'.
|
|
24
|
+
# 3. explicit status-ish field inside tool_output, including a
|
|
25
|
+
# state of running/queued/pending/scheduled -> 'dispatched'
|
|
26
|
+
# 4. background-dispatch signature (task_id present in the
|
|
27
|
+
# result, or state running/queued) -> 'dispatched'
|
|
28
|
+
# 5. explicit status-ish field in tool_input (as before)
|
|
29
|
+
# 6. strong failure markers in the tool_output text -> 'failure'
|
|
30
|
+
# 7. non-empty tool_output.output -> 'success' (real completion
|
|
31
|
+
# evidence — the result reached the plugin)
|
|
32
|
+
# 8. strong failure markers in the tool_input text -> 'failure'
|
|
33
|
+
# 9. else 'unknown' — NEVER fabricated 'success'
|
|
34
|
+
# delegation_id = payload.delegation_id — the SAME id the start hook
|
|
35
|
+
# logged (P0-2), so delegations.log joins Start<->Stop on it.
|
|
36
|
+
# task_id = payload.task_id — the plugin extracts it into its OWN
|
|
37
|
+
# field; never concatenated into agent/status.
|
|
38
|
+
# duration_ms = payload.duration_ms — plugin-computed from the
|
|
39
|
+
# before-record start timestamp.
|
|
40
|
+
# All log fields pass through a primitive sanitizer — a merged result blob can
|
|
41
|
+
# never contaminate agent/status/session_id/task_id.
|
|
42
|
+
#
|
|
43
|
+
# LIMITATION (P0-1, documented): on opencode 1.18.13 real completion of
|
|
44
|
+
# BACKGROUND tasks is NOT observable via tool.execute.after (no second
|
|
45
|
+
# after-event fires). The stop record for a background launch is 'dispatched'
|
|
46
|
+
# by design — 'success' would be fabricated telemetry.
|
|
4
47
|
set -euo pipefail
|
|
5
48
|
|
|
6
49
|
# Project-local by default; set XDG_STATE_HOME for system-wide logging
|
|
@@ -14,20 +57,359 @@ fi
|
|
|
14
57
|
mkdir -p "$LOG_DIR"
|
|
15
58
|
|
|
16
59
|
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
60
|
+
|
|
61
|
+
# Preserve the pre-derivation env values (derived fields overwrite them below).
|
|
62
|
+
ENV_AGENT_NAME="${AGENT_NAME:-}"
|
|
63
|
+
ENV_STATUS="${STATUS:-}"
|
|
64
|
+
ENV_REASON="${REASON:-}"
|
|
65
|
+
ARG_REASON="${1:-}"
|
|
66
|
+
|
|
67
|
+
# The Claude Code protocol payload arrives on stdin (hook-runner always pipes
|
|
68
|
+
# it, even empty). Buffer it in a temp file — prompts can exceed argv limits.
|
|
69
|
+
TMP_IN=$(mktemp "${TMPDIR:-/tmp}/subagent-stop.XXXXXX")
|
|
70
|
+
TMP_PY=$(mktemp "${TMPDIR:-/tmp}/subagent-stop.XXXXXX.py")
|
|
71
|
+
TMP_OUT=$(mktemp "${TMPDIR:-/tmp}/subagent-stop.XXXXXX.out")
|
|
72
|
+
trap 'rm -f "$TMP_IN" "$TMP_PY" "$TMP_OUT"' EXIT
|
|
73
|
+
cat > "$TMP_IN"
|
|
74
|
+
|
|
75
|
+
# Derivation logic: agent + status + reason + correlation fields from the
|
|
76
|
+
# payload. (Kept in a temp file — heredocs inside $(...) are not portable.)
|
|
77
|
+
cat > "$TMP_PY" <<'PY'
|
|
78
|
+
import sys
|
|
79
|
+
import json
|
|
80
|
+
import re
|
|
81
|
+
|
|
82
|
+
def prim(v):
|
|
83
|
+
"""P0-1 sanitizer: primitives -> str; junk (dicts/lists from a merged
|
|
84
|
+
result blob) -> '' so log fields are never contaminated."""
|
|
85
|
+
if isinstance(v, str):
|
|
86
|
+
return v
|
|
87
|
+
if isinstance(v, (int, float, bool)):
|
|
88
|
+
return str(v)
|
|
89
|
+
return ''
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
with open(sys.argv[1], encoding='utf-8') as f:
|
|
93
|
+
payload = json.load(f)
|
|
94
|
+
except Exception:
|
|
95
|
+
payload = {}
|
|
96
|
+
|
|
97
|
+
env_status = sys.argv[2] if len(sys.argv) > 2 else ''
|
|
98
|
+
env_agent = sys.argv[3] if len(sys.argv) > 3 else ''
|
|
99
|
+
tool_name = payload.get('tool_name') or ''
|
|
100
|
+
ti = payload.get('tool_input')
|
|
101
|
+
if not isinstance(ti, dict):
|
|
102
|
+
ti = {}
|
|
103
|
+
session_id = prim(payload.get('session_id'))
|
|
104
|
+
delegation_id = prim(payload.get('delegation_id'))
|
|
105
|
+
task_id = prim(payload.get('task_id'))
|
|
106
|
+
duration_ms = prim(payload.get('duration_ms'))
|
|
107
|
+
|
|
108
|
+
# P1-1 follow-up: the plugin now forwards the REAL tool result —
|
|
109
|
+
# payload.tool_output = the tool.execute.after callback output
|
|
110
|
+
# ({title, output, metadata}) and payload.status = the plugin-derived status
|
|
111
|
+
# from it. Prefer these over the args-only heuristics below.
|
|
112
|
+
plugin_status = payload.get('status') or ''
|
|
113
|
+
result = payload.get('tool_output')
|
|
114
|
+
if not isinstance(result, dict):
|
|
115
|
+
result = None
|
|
116
|
+
result_text = json.dumps(result, ensure_ascii=False).lower() if result else ''
|
|
117
|
+
|
|
118
|
+
# Agent (P0-2): subagent_type -> env AGENT_NAME (plugin agent_id resolved from
|
|
119
|
+
# the tool.execute.before record — after-time args may hold the MERGED
|
|
120
|
+
# background result {task_id, state} with no subagent_type) -> tool_name -> ''
|
|
121
|
+
agent = ti.get('subagent_type') or ti.get('subagentType') or env_agent or tool_name or ''
|
|
122
|
+
|
|
123
|
+
# Status: env override -> plugin status -> result field -> background dispatch
|
|
124
|
+
# -> tool_input field -> result markers/output -> tool_input markers
|
|
125
|
+
# -> unknown. NEVER fabricated success.
|
|
126
|
+
status = env_status
|
|
127
|
+
reason = ''
|
|
128
|
+
source = ''
|
|
129
|
+
STATUS_KEYS = (
|
|
130
|
+
'status', 'state', 'result', 'success', 'ok',
|
|
131
|
+
'error', 'failed', 'error_message', 'exception',
|
|
132
|
+
)
|
|
133
|
+
SUCCESS_WORDS = ('success', 'successful', 'completed', 'complete',
|
|
134
|
+
'done', 'finished', 'ok', 'true', '1')
|
|
135
|
+
FAILURE_WORDS = ('failure', 'failed', 'error', 'refused', 'denied',
|
|
136
|
+
'exception', 'false', '0', 'cancelled', 'canceled', 'aborted')
|
|
137
|
+
BACKGROUND_STATES = ('running', 'queued', 'pending', 'scheduled', 'dispatched')
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def classify(key, val):
|
|
141
|
+
"""Return ('success' | 'failure' | '', reason) for a status-ish value."""
|
|
142
|
+
if val in (None, '', False):
|
|
143
|
+
return '', ''
|
|
144
|
+
if isinstance(val, bool):
|
|
145
|
+
return ('success' if val else 'failure'), ''
|
|
146
|
+
if isinstance(val, (str, int, float)):
|
|
147
|
+
low = str(val).strip().lower()
|
|
148
|
+
if low in SUCCESS_WORDS:
|
|
149
|
+
return 'success', ''
|
|
150
|
+
if low in FAILURE_WORDS:
|
|
151
|
+
return 'failure', ''
|
|
152
|
+
return '', "status-like field '%s' present but unrecognized: %s" % (key, str(val)[:80])
|
|
153
|
+
return '', ''
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def status_field_in(scope, prefix):
|
|
157
|
+
"""First explicit status-ish field inside a dict scope, or ('', '')."""
|
|
158
|
+
for key in STATUS_KEYS:
|
|
159
|
+
if key in scope and scope[key] not in (None, '', False):
|
|
160
|
+
s, r = classify(key, scope[key])
|
|
161
|
+
if s:
|
|
162
|
+
return s, prefix + key
|
|
163
|
+
return '', ''
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def result_task_id():
|
|
167
|
+
"""P0-1/P0-5: task_id from metadata, JSON-as-string output, or the TEXT
|
|
168
|
+
form `<task id="ses_..." state="running">` (the REAL opencode 1.18.x
|
|
169
|
+
background-dispatch format) — always its own field."""
|
|
170
|
+
if not result:
|
|
171
|
+
return ''
|
|
172
|
+
meta = result.get('metadata')
|
|
173
|
+
if isinstance(meta, dict):
|
|
174
|
+
for k in ('task_id', 'taskId'):
|
|
175
|
+
v = meta.get(k)
|
|
176
|
+
if v:
|
|
177
|
+
return prim(v)
|
|
178
|
+
text = '%s %s' % (result.get('title') or '', result.get('output') or '')
|
|
179
|
+
m = re.search(r'"task_id"\s*:\s*"?([^",\s}]+)"?', text)
|
|
180
|
+
if m:
|
|
181
|
+
return m.group(1)
|
|
182
|
+
m = re.search(r'\btask\s+id="(ses_[^"]+)"', text, re.I)
|
|
183
|
+
if m:
|
|
184
|
+
return m.group(1)
|
|
185
|
+
return ''
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def result_state():
|
|
189
|
+
"""P0-1/P0-5: state from metadata, JSON-as-string output, or the TEXT
|
|
190
|
+
form `state="running"` (lowercased)."""
|
|
191
|
+
if not result:
|
|
192
|
+
return ''
|
|
193
|
+
meta = result.get('metadata')
|
|
194
|
+
if isinstance(meta, dict) and meta.get('state'):
|
|
195
|
+
return str(meta['state']).strip().lower()
|
|
196
|
+
text = '%s %s' % (result.get('title') or '', result.get('output') or '')
|
|
197
|
+
m = re.search(r'"state"\s*:\s*"?([^",\s}]+)"?', text)
|
|
198
|
+
if m:
|
|
199
|
+
return m.group(1).strip().lower()
|
|
200
|
+
m = re.search(r'\bstate="(running|queued|pending|scheduled|dispatched)"', text, re.I)
|
|
201
|
+
if m:
|
|
202
|
+
return m.group(1).strip().lower()
|
|
203
|
+
return ''
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# 2. Plugin-derived status (from the real tool result) — trusted signal.
|
|
207
|
+
# P0-1: 'dispatched' (background launch) is a first-class status.
|
|
208
|
+
if not status and plugin_status:
|
|
209
|
+
low = plugin_status.strip().lower()
|
|
210
|
+
if low in ('success', 'failure', 'dispatched'):
|
|
211
|
+
status = low
|
|
212
|
+
source = 'plugin'
|
|
213
|
+
reason = 'plugin-derived status from tool.execute.after result'
|
|
214
|
+
else:
|
|
215
|
+
reason = "plugin status unrecognized: %s" % plugin_status[:80]
|
|
216
|
+
|
|
217
|
+
# 3. Explicit status field inside the tool result (flat, then metadata).
|
|
218
|
+
# P0-1: a state of running/queued/pending marks a background dispatch.
|
|
219
|
+
if not status and result:
|
|
220
|
+
meta = result.get('metadata')
|
|
221
|
+
if isinstance(meta, dict):
|
|
222
|
+
for k in ('state', 'status'):
|
|
223
|
+
v = meta.get(k)
|
|
224
|
+
if v is not None and v is not False:
|
|
225
|
+
if str(v).strip().lower() in BACKGROUND_STATES:
|
|
226
|
+
status = 'dispatched'
|
|
227
|
+
source = 'result.metadata.' + k
|
|
228
|
+
reason = 'background dispatch state in tool result'
|
|
229
|
+
break
|
|
230
|
+
if not status:
|
|
231
|
+
status, source = status_field_in(result, 'result.')
|
|
232
|
+
if not status and isinstance(meta, dict):
|
|
233
|
+
status, source = status_field_in(meta, 'result.metadata.')
|
|
234
|
+
if status:
|
|
235
|
+
reason = "explicit field '%s' in tool result indicates %s" % (source, status)
|
|
236
|
+
|
|
237
|
+
# 4. P0-1: background-dispatch signature — task_id present in the result
|
|
238
|
+
# (metadata or JSON-as-string output) without a terminal status above.
|
|
239
|
+
if not status and result:
|
|
240
|
+
tid = result_task_id()
|
|
241
|
+
state = result_state()
|
|
242
|
+
if tid or state in BACKGROUND_STATES:
|
|
243
|
+
status = 'dispatched'
|
|
244
|
+
source = 'result'
|
|
245
|
+
reason = 'background dispatch: task_id=%s state=%s (completion not observable)' % (
|
|
246
|
+
tid or '-', state or '-')
|
|
247
|
+
|
|
248
|
+
# 5. Explicit status field in tool_input (as before).
|
|
249
|
+
if not status:
|
|
250
|
+
for key in STATUS_KEYS:
|
|
251
|
+
if key in ti and ti[key] not in (None, '', False):
|
|
252
|
+
val = ti[key]
|
|
253
|
+
status, reason = classify(key, val)
|
|
254
|
+
source = key
|
|
255
|
+
if status:
|
|
256
|
+
break
|
|
257
|
+
reason = reason or ''
|
|
258
|
+
break
|
|
259
|
+
|
|
260
|
+
# 6. Strong failure markers in the tool result text.
|
|
261
|
+
if not status and result:
|
|
262
|
+
markers = ('"refused"', '"denied"', '"traceback"', '"exception"',
|
|
263
|
+
'failed to', 'unable to', 'timed out', 'error:')
|
|
264
|
+
if any(m in result_text for m in markers):
|
|
265
|
+
status = 'failure'
|
|
266
|
+
source = 'result'
|
|
267
|
+
reason = 'failure markers found in tool result'
|
|
268
|
+
|
|
269
|
+
# 7. Non-empty result output = real completion evidence.
|
|
270
|
+
if not status and result:
|
|
271
|
+
out = result.get('output')
|
|
272
|
+
if isinstance(out, str) and out.strip():
|
|
273
|
+
status = 'success'
|
|
274
|
+
source = 'result'
|
|
275
|
+
reason = 'tool result output present (real completion evidence)'
|
|
276
|
+
|
|
277
|
+
# 8. Strong failure markers in tool_input (as before).
|
|
278
|
+
if not status:
|
|
279
|
+
text = json.dumps(ti, ensure_ascii=False).lower()
|
|
280
|
+
markers = ('"refused"', '"denied"', '"traceback"', '"exception"',
|
|
281
|
+
'failed to', 'unable to', 'timed out', 'error:')
|
|
282
|
+
if any(m in text for m in markers):
|
|
283
|
+
status = 'failure'
|
|
284
|
+
source = 'tool_input'
|
|
285
|
+
reason = 'failure markers found in tool_input'
|
|
286
|
+
|
|
287
|
+
# 9. Unknown — never fabricated success.
|
|
288
|
+
if not status:
|
|
289
|
+
status = 'unknown'
|
|
290
|
+
keys = ','.join(sorted(ti.keys())) if ti else '(empty)'
|
|
291
|
+
reason = ('no result/status in payload; tool_input keys: %s'
|
|
292
|
+
'; tool_output present: %s') % (keys, 'yes' if result else 'no')
|
|
293
|
+
if not reason and status in ('success', 'failure', 'dispatched'):
|
|
294
|
+
reason = "explicit field '%s' indicates %s" % (source, status) if source else 'env STATUS override'
|
|
295
|
+
|
|
296
|
+
# P0-5: task_id fallback — the TEXT form `<task id="ses_...">` is the REAL
|
|
297
|
+
# background-dispatch format on opencode 1.18.x; derive it standalone so the
|
|
298
|
+
# script emits the id in its own field even without the plugin's extraction.
|
|
299
|
+
# A payload-supplied task_id (plugin-derived) always wins.
|
|
300
|
+
if not task_id and result:
|
|
301
|
+
task_id = result_task_id()
|
|
302
|
+
|
|
303
|
+
# Result snippet for telemetry (kept short — prompts can be huge). This is the
|
|
304
|
+
# free-text blob in its OWN field — it never contaminates agent/status/etc.
|
|
305
|
+
result_snippet = ''
|
|
306
|
+
if result:
|
|
307
|
+
try:
|
|
308
|
+
raw = result.get('output') or result.get('title') or json.dumps(result, ensure_ascii=False)
|
|
309
|
+
result_snippet = str(raw)[:200]
|
|
310
|
+
except Exception:
|
|
311
|
+
result_snippet = ''
|
|
312
|
+
|
|
313
|
+
# P0-5: JSON round-trip replaces the 8-field TSV (a real newline in
|
|
314
|
+
# result_snippet used to shift fields 5-8 into garbage with cut -fN). ONE
|
|
315
|
+
# json.dumps line — result_snippet and reason are additionally newline/tab-
|
|
316
|
+
# collapsed + 200-char truncated so NO delimiter-breaking character can
|
|
317
|
+
# corrupt the handoff or the log line (defense in depth on top of the
|
|
318
|
+
# json.dumps escaping).
|
|
319
|
+
|
|
320
|
+
def clean(v):
|
|
321
|
+
"""Collapse \n\t\r to single spaces; truncate to 200 chars."""
|
|
322
|
+
return re.sub(r'[\n\t\r]+', ' ', str(v))[:200]
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
print(json.dumps({
|
|
326
|
+
'agent': prim(agent),
|
|
327
|
+
'status': prim(status),
|
|
328
|
+
'session_id': session_id,
|
|
329
|
+
'result_snippet': clean(result_snippet),
|
|
330
|
+
'delegation_id': delegation_id,
|
|
331
|
+
'task_id': task_id,
|
|
332
|
+
'duration_ms': duration_ms,
|
|
333
|
+
'reason': clean(reason),
|
|
334
|
+
}, ensure_ascii=False))
|
|
335
|
+
PY
|
|
336
|
+
|
|
337
|
+
DERIVED=$(python3 "$TMP_PY" "$TMP_IN" "$ENV_STATUS" "$ENV_AGENT_NAME" 2>/dev/null || printf '{"agent":"unknown","status":"unknown"}')
|
|
338
|
+
|
|
339
|
+
# P0-5: JSON round-trip into the bash variables — NO cut/TSV anywhere. The
|
|
340
|
+
# derivation script emits ONE json.dumps line (snippet/reason already
|
|
341
|
+
# newline-collapsed); a python3 one-liner parses it back into NUL-delimited
|
|
342
|
+
# values; bash reads each field with `IFS= read -r -d ''` so every field
|
|
343
|
+
# arrives intact — empty fields survive (no tab-collapsing), spaces/unicode
|
|
344
|
+
# are not split, and no delimiter-breaking character can shift fields. On
|
|
345
|
+
# python failure the fallback JSON above keeps agent/status honest.
|
|
346
|
+
printf '%s' "$DERIVED" | python3 -c '
|
|
347
|
+
import json
|
|
348
|
+
import sys
|
|
349
|
+
|
|
350
|
+
try:
|
|
351
|
+
d = json.load(sys.stdin)
|
|
352
|
+
except Exception:
|
|
353
|
+
d = {}
|
|
354
|
+
for name in ("agent", "status", "session_id", "result_snippet",
|
|
355
|
+
"delegation_id", "task_id", "duration_ms", "reason"):
|
|
356
|
+
sys.stdout.write(str(d.get(name, "")))
|
|
357
|
+
sys.stdout.write("\0")
|
|
358
|
+
' > "$TMP_OUT" || true
|
|
359
|
+
{
|
|
360
|
+
IFS= read -r -d '' AGENT_NAME
|
|
361
|
+
IFS= read -r -d '' STATUS
|
|
362
|
+
IFS= read -r -d '' SESSION_ID
|
|
363
|
+
IFS= read -r -d '' RESULT_SNIPPET
|
|
364
|
+
IFS= read -r -d '' DELEGATION_ID
|
|
365
|
+
IFS= read -r -d '' TASK_ID
|
|
366
|
+
IFS= read -r -d '' DURATION_MS
|
|
367
|
+
IFS= read -r -d '' REASON
|
|
368
|
+
} < "$TMP_OUT" || true
|
|
369
|
+
|
|
370
|
+
# Agent fallback chain (subagent_type -> env -> tool_name -> 'unknown').
|
|
371
|
+
if [ -z "$AGENT_NAME" ]; then
|
|
372
|
+
AGENT_NAME="$ENV_AGENT_NAME"
|
|
373
|
+
fi
|
|
374
|
+
if [ -z "$AGENT_NAME" ]; then
|
|
375
|
+
AGENT_NAME="unknown"
|
|
376
|
+
fi
|
|
377
|
+
|
|
378
|
+
# Legacy positional/env reason fallback (only when the derivation found none).
|
|
379
|
+
if [ -z "$REASON" ]; then
|
|
380
|
+
REASON="${ENV_REASON:-$ARG_REASON}"
|
|
381
|
+
fi
|
|
382
|
+
|
|
383
|
+
# Emit a schema-compatible line: {event, timestamp, agent, status, reason,
|
|
384
|
+
# session_id, result, delegation_id, task_id, duration_ms} — P0-2: the
|
|
385
|
+
# delegation_id joins this record to the matching SubagentStart; task_id and
|
|
386
|
+
# duration_ms ride in their OWN fields. Built with json.dumps so arbitrary
|
|
387
|
+
# reason/result text cannot break the JSON.
|
|
388
|
+
LOG_LINE=$(python3 -c '
|
|
389
|
+
import json
|
|
390
|
+
import sys
|
|
391
|
+
print(json.dumps({
|
|
392
|
+
"event": "SubagentStop",
|
|
393
|
+
"timestamp": sys.argv[1],
|
|
394
|
+
"agent": sys.argv[2],
|
|
395
|
+
"status": sys.argv[3],
|
|
396
|
+
"reason": sys.argv[4],
|
|
397
|
+
"session_id": sys.argv[5],
|
|
398
|
+
"result": sys.argv[6],
|
|
399
|
+
"delegation_id": sys.argv[7],
|
|
400
|
+
"task_id": sys.argv[8],
|
|
401
|
+
"duration_ms": sys.argv[9] or None,
|
|
402
|
+
}, ensure_ascii=False))
|
|
403
|
+
' "$TIMESTAMP" "$AGENT_NAME" "$STATUS" "$REASON" "$SESSION_ID" "$RESULT_SNIPPET" "$DELEGATION_ID" "$TASK_ID" "$DURATION_MS")
|
|
20
404
|
|
|
21
405
|
LOG_FILE="$LOG_DIR/delegations.log"
|
|
22
|
-
if [
|
|
406
|
+
if [ "$STATUS" = "failure" ]; then
|
|
23
407
|
LOG_FILE="$LOG_DIR/delegation-failures.log"
|
|
24
408
|
fi
|
|
25
409
|
|
|
26
|
-
|
|
27
|
-
{"event":"SubagentStop","timestamp":"$TIMESTAMP","agent":"$AGENT_NAME","status":"$STATUS","reason":"$REASON"}
|
|
28
|
-
JSON
|
|
410
|
+
echo "$LOG_LINE" >> "$LOG_FILE"
|
|
29
411
|
|
|
30
|
-
echo "[DELEGATION] $AGENT_NAME stopped ($STATUS) → $LOG_FILE" >&2
|
|
412
|
+
echo "[DELEGATION] $AGENT_NAME stopped ($STATUS) delegation=$DELEGATION_ID → $LOG_FILE" >&2
|
|
31
413
|
|
|
32
414
|
# --- Post-condition validation: remind about Themis review ---
|
|
33
415
|
if echo "hermes aphrodite demeter prometheus" | grep -wq "$AGENT_NAME" 2>/dev/null; then
|
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# scan-secrets.sh — Pantheon PreToolUse Secret Scanning Hook
|
|
3
|
-
# Detects hardcoded secrets in tool input.
|
|
3
|
+
# Detects hardcoded secrets in tool input. Logs ALWAYS (masked); the exit
|
|
4
|
+
# code drives the plugin's hybrid blocking (see pantheon-hooks.ts):
|
|
5
|
+
#
|
|
6
|
+
# exit 0 — no match.
|
|
7
|
+
# exit 1 — LOW_CONFIDENCE match only (header/KEY NAMES such as the Bifrost header
|
|
8
|
+
# alone, api_key=/password=/secret= without a recognizable token
|
|
9
|
+
# value). Advisory: the plugin logs + toasts, does NOT block.
|
|
10
|
+
# exit 2 — HIGH_CONFIDENCE match (real token formats: sk-bf-<token>,
|
|
11
|
+
# AKIA..., ghp_..., glpat-..., sk_live_/sk_test_..., xox[baprs]-...,
|
|
12
|
+
# Bearer <token>, JWT). The plugin BLOCKS the tool call (throws
|
|
13
|
+
# after logging — the only deliberate throw in the plugin).
|
|
14
|
+
#
|
|
15
|
+
# Matches are always MASKED (first 4 + last 4 chars for len>8, else "****")
|
|
16
|
+
# before being echoed — the hook trail must never contain a raw secret value.
|
|
4
17
|
set -euo pipefail
|
|
5
18
|
|
|
6
|
-
|
|
19
|
+
HIGH_CONFIDENCE_PATTERNS=(
|
|
7
20
|
"AKIA[0-9A-Z]{16}"
|
|
8
21
|
"gh[pousr]_[A-Za-z0-9_]{36,}"
|
|
9
22
|
"glpat-[A-Za-z0-9_\\-]{20}"
|
|
@@ -11,21 +24,37 @@ SECRET_PATTERNS=(
|
|
|
11
24
|
"sk_live_[a-zA-Z0-9]{20,}"
|
|
12
25
|
"sk_test_[a-zA-Z0-9]{20,}"
|
|
13
26
|
"xox[baprs]-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9]{24}"
|
|
14
|
-
"
|
|
15
|
-
"[
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
27
|
+
"bearer\\s+[a-zA-Z0-9_\\-\\.]{20,}"
|
|
28
|
+
"eyJ[A-Za-z0-9_-]*\\.eyJ[A-Za-z0-9_-]*\\.[A-Za-z0-9_-]*"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
LOW_CONFIDENCE_PATTERNS=(
|
|
32
|
+
# KEY/header NAMES without a recognizable token value — high false-positive
|
|
33
|
+
# rate (e.g. the Bifrost header alone, api_key=longvalue, password="x", secret="y"):
|
|
34
|
+
# advisory only, never blocks.
|
|
35
|
+
"[a-zA-Z0-9_-]*api[_-]?key[a-zA-Z0-9_-]*\\s*[:=]\\s*[\"']?[a-zA-Z0-9_\\-]{16,}[\"']?"
|
|
36
|
+
"[a-zA-Z0-9_-]*password[a-zA-Z0-9_-]*\\s*[:=]\\s*[\"'][^\"']{8,}[\"']"
|
|
37
|
+
"[a-zA-Z0-9_-]*secret[a-zA-Z0-9_-]*\\s*[:=]\\s*[\"'][^\"']{8,}[\"']"
|
|
19
38
|
)
|
|
20
39
|
|
|
21
40
|
# Keep provider-specific markers assembled so this scanner is not self-matched
|
|
22
41
|
# by the repository secret test.
|
|
23
42
|
BIFROST_HEADER="x""-bf-""vk"
|
|
24
43
|
BIFROST_TOKEN_PREFIX="sk""-bf-"
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
)
|
|
44
|
+
HIGH_CONFIDENCE_PATTERNS+=("${BIFROST_TOKEN_PREFIX}[A-Za-z0-9_-]{8,}")
|
|
45
|
+
LOW_CONFIDENCE_PATTERNS+=("$BIFROST_HEADER")
|
|
46
|
+
|
|
47
|
+
# Mask a matched secret for safe logging: first 4 + last 4 chars (len > 8),
|
|
48
|
+
# else fully hidden — short matches must not be reconstructable by
|
|
49
|
+
# concatenating the visible slices.
|
|
50
|
+
mask_secret() {
|
|
51
|
+
local match="$1"
|
|
52
|
+
if [[ ${#match} -le 8 ]]; then
|
|
53
|
+
echo "****"
|
|
54
|
+
else
|
|
55
|
+
echo "${match:0:4}****${match: -4}"
|
|
56
|
+
fi
|
|
57
|
+
}
|
|
29
58
|
|
|
30
59
|
INPUT="${1:-${TOOL_INPUT:-}}"
|
|
31
60
|
if [[ -z "$INPUT" ]] && [[ ! -t 0 ]]; then
|
|
@@ -36,19 +65,32 @@ if [[ -z "$INPUT" ]]; then
|
|
|
36
65
|
exit 0
|
|
37
66
|
fi
|
|
38
67
|
|
|
39
|
-
|
|
40
|
-
|
|
68
|
+
FOUND_HIGH=0
|
|
69
|
+
FOUND_LOW=0
|
|
70
|
+
|
|
71
|
+
for pattern in "${HIGH_CONFIDENCE_PATTERNS[@]}"; do
|
|
41
72
|
if echo "$INPUT" | grep -iqE "$pattern"; then
|
|
42
73
|
MATCH=$(echo "$INPUT" | grep -ioE "$pattern" | head -1)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
echo "[SECRET SCAN] Potential secret detected: $MASKED (pattern: $pattern)" >&2
|
|
46
|
-
FOUND=1
|
|
74
|
+
echo "[SECRET SCAN] High-confidence secret detected: $(mask_secret "$MATCH") (pattern: $pattern)" >&2
|
|
75
|
+
FOUND_HIGH=1
|
|
47
76
|
fi
|
|
48
77
|
done
|
|
49
78
|
|
|
50
|
-
|
|
51
|
-
echo "
|
|
79
|
+
for pattern in "${LOW_CONFIDENCE_PATTERNS[@]}"; do
|
|
80
|
+
if echo "$INPUT" | grep -iqE "$pattern"; then
|
|
81
|
+
MATCH=$(echo "$INPUT" | grep -ioE "$pattern" | head -1)
|
|
82
|
+
echo "[SECRET SCAN] Potential secret detected (low confidence): $(mask_secret "$MATCH") (pattern: $pattern)" >&2
|
|
83
|
+
FOUND_LOW=1
|
|
84
|
+
fi
|
|
85
|
+
done
|
|
86
|
+
|
|
87
|
+
if [[ $FOUND_HIGH -eq 1 ]]; then
|
|
88
|
+
echo "[SECRET SCAN] High-confidence hardcoded secret detected. Tool call will be BLOCKED." >&2
|
|
89
|
+
exit 2
|
|
90
|
+
fi
|
|
91
|
+
|
|
92
|
+
if [[ $FOUND_LOW -eq 1 ]]; then
|
|
93
|
+
echo "[SECRET SCAN] Possible secret detected. Remove it and use environment variables or a vault." >&2
|
|
52
94
|
exit 1
|
|
53
95
|
fi
|
|
54
96
|
|
|
@@ -39,5 +39,12 @@ except:
|
|
|
39
39
|
print('0')
|
|
40
40
|
" 2>/dev/null || echo "0")
|
|
41
41
|
|
|
42
|
+
# P0-4: enforce the Talos max-files boundary (default 3, overridable)
|
|
43
|
+
TALOS_MAX_FILES="${TALOS_MAX_FILES:-3}"
|
|
44
|
+
if [ "$FILE_COUNT" -gt "$TALOS_MAX_FILES" ]; then
|
|
45
|
+
echo "[TALOS SCOPE] ❌ Blocked: operation touches $FILE_COUNT files (max $TALOS_MAX_FILES)" >&2
|
|
46
|
+
exit 2
|
|
47
|
+
fi
|
|
48
|
+
|
|
42
49
|
echo "[TALOS SCOPE] ✅ Scope check passed" >&2
|
|
43
50
|
exit 0
|
package/src/agents/zeus.md
CHANGED
|
@@ -95,6 +95,13 @@ kv_store("delegation:<pattern>", "{agent: ..., background: true/false}")
|
|
|
95
95
|
kv_get("delegation:<pattern>") → reusa decisao sem memory_search
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
+
### Telemetria de delegacao (Nyx P1-3)
|
|
99
|
+
|
|
100
|
+
Toda decisao de delegacao grava UM registro `DelegationCacheDecision` no
|
|
101
|
+
namespace `delegation-telemetry` via kv_store: {hit|miss|writeback|corrected,
|
|
102
|
+
pattern, agent, source: cache|routing|user}. Em re-roteamento (recusa P0-3),
|
|
103
|
+
adicione {reroute_from, reroute_to, delegation_id, source}. Uma linha por delegacao.
|
|
104
|
+
|
|
98
105
|
## REGRA DE OURO: NUNCA USE general
|
|
99
106
|
|
|
100
107
|
**`subagent_type: general` e `subagent_type: explore` sao PROIBIDOS.** Nao existem no Pantheon.
|
|
@@ -113,13 +120,16 @@ Tarefa envolve:
|
|
|
113
120
|
AI, RAG, LangChain, embeddings, vetores -> @hephaestus
|
|
114
121
|
observabilidade, tracing, monitoramento -> @nyx
|
|
115
122
|
GitHub, PR, issues, releases, branches -> @iris
|
|
116
|
-
documentacao
|
|
123
|
+
documentacao de PROJETO (README, docs/, changelog) -> @talos (trivial) | implementador (tecnica) | @iris (changelog/release)
|
|
124
|
+
documentacao de SISTEMA (.pantheon/memory-bank/, ADRs, task records) -> @mnemosyne
|
|
117
125
|
hotfix rapido, bug pequeno, typo, CSS -> @talos
|
|
118
126
|
|
|
119
127
|
NENHUMA das acima? -> E descoberta? @apollo. E planejamento? @athena.
|
|
120
128
|
Ainda assim sem match? -> Pergunte ao usuario qual agente usar. NUNCA use general.
|
|
121
129
|
```
|
|
122
130
|
|
|
131
|
+
REGRA: "fora de .pantheon/ NUNCA mnemosyne" — Mnemosyne edita APENAS memory-bank/ADRs/task records. Docs de projeto (README, docs/) vão para talos/implementador/iris.
|
|
132
|
+
|
|
123
133
|
## Background Delegation (PADRAO: background=true)
|
|
124
134
|
|
|
125
135
|
**Requer:** `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true` (env var)
|
|
@@ -199,6 +209,15 @@ Zeus (nivel 0) -> Apollo/Hermes (nivel 1) -> sub-subagente (nivel 2 max).
|
|
|
199
209
|
- Env var: `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true`
|
|
200
210
|
- Guards: `instructions/zeus-timeout-retry.instructions.md`
|
|
201
211
|
|
|
212
|
+
## Taxonomia de Recusa (P0-3)
|
|
213
|
+
|
|
214
|
+
Quando um subagente RECUSA ou retorna "escopo fora do meu dominio" (ex: edit: deny, scope boundary):
|
|
215
|
+
|
|
216
|
+
- **Causa provavel:** agente errado selecionado no roteamento (match por palavra-chave, nao por capacidade).
|
|
217
|
+
- **ACAO:** RE-ROTEAR imediatamente para o agente correto — NAO retry com prompt reformulado.
|
|
218
|
+
- Registre o caso no cache de delegacao (`kv_store("deleg:<pattern>", ...)` para aprendizado futuro).
|
|
219
|
+
- Recusa legitima do agente = comportamento correto do guard; o defeito esta na selecao (zeus), nao no agente.
|
|
220
|
+
|
|
202
221
|
## TODO Enforcer (Auto-Retry)
|
|
203
222
|
|
|
204
223
|
**Se um agente delegado falhar ou travar, recupere automaticamente.**
|
|
@@ -207,13 +226,22 @@ Zeus (nivel 0) -> Apollo/Hermes (nivel 1) -> sub-subagente (nivel 2 max).
|
|
|
207
226
|
```
|
|
208
227
|
Apos task_status(wait=true), verifique:
|
|
209
228
|
result.state == "error" ou timeout?
|
|
210
|
-
SIM → retry
|
|
211
|
-
|
|
229
|
+
SIM → ANTES de retry: detecte a CAUSA na mensagem de erro
|
|
230
|
+
|
|
231
|
+
RECUSA / scope-boundary (result contem "edit: deny", "escopo fora do meu dominio",
|
|
232
|
+
"reviewer-only", "fora do meu dominio", ou similar negativa de dominio)?
|
|
233
|
+
SIM → NAO retry com prompt rephrased.
|
|
234
|
+
RE-ROTEAR para o agente correto (ver "Taxonomia de Recusa (P0-3)").
|
|
235
|
+
Registre o caso: kv_store("deleg:<pattern>", "{agente_correto, ...}").
|
|
236
|
+
|
|
237
|
+
FALHA REAL (timeout, crash, resposta vazia, context exceeded)?
|
|
238
|
+
SIM → retry 1x com prompt rephrased (diferente, mais especifico)
|
|
239
|
+
Ainda erro → escalate: "Agente X falhou 2x. Opcoes: (a) tentar outro, (b) simplificar, (c) pular"
|
|
212
240
|
```
|
|
213
241
|
|
|
214
242
|
### Regras
|
|
215
|
-
- **1 retry automatico** —
|
|
216
|
-
- **Rephrase o prompt** —
|
|
243
|
+
- **1 retry automatico APENAS para falha real** — recusa/scope-boundary NUNCA gera retry; gera re-roteamento
|
|
244
|
+
- **Rephrase o prompt** — so em falha real (timeout/crash/vazio), nao em recusa de dominio
|
|
217
245
|
- **Timeout** — sempre `timeout_ms=120000` em task_status(). Pesquisa leva tempo
|
|
218
246
|
- **Stall** — 3+ turns sem progresso util? Troque de agente ou abordagem
|
|
219
247
|
|