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.
@@ -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 failure of subagent tasks.
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
- AGENT_NAME="${AGENT_NAME:-unknown}"
18
- STATUS="${STATUS:-success}"
19
- REASON="${REASON:-${1:-}}"
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 [[ "$STATUS" == "failure" ]]; then
406
+ if [ "$STATUS" = "failure" ]; then
23
407
  LOG_FILE="$LOG_DIR/delegation-failures.log"
24
408
  fi
25
409
 
26
- cat >> "$LOG_FILE" << JSON
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
- SECRET_PATTERNS=(
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
- "[a-zA-Z0-9_-]*api[_-]?key[a-zA-Z0-9_-]*\s*[:=]\s*[\"']?[a-zA-Z0-9_\\-]{16,}[\"']?"
15
- "[a-zA-Z0-9_-]*password[a-zA-Z0-9_-]*\s*[:=]\s*[\"'][^\"']{8,}[\"']"
16
- "[a-zA-Z0-9_-]*secret[a-zA-Z0-9_-]*\s*[:=]\s*[\"'][^\"']{8,}[\"']"
17
- "bearer\s+[a-zA-Z0-9_\\-\\.]{20,}"
18
- "eyJ[A-Za-z0-9_-]*\.eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]*"
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
- SECRET_PATTERNS+=(
26
- "$BIFROST_HEADER"
27
- "${BIFROST_TOKEN_PREFIX}[A-Za-z0-9_-]{8,}"
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
- FOUND=0
40
- for pattern in "${SECRET_PATTERNS[@]}"; do
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
- # Mask the match for safe logging
44
- MASKED="${MATCH:0:4}****${MATCH: -4}"
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
- if [[ $FOUND -eq 1 ]]; then
51
- echo "[SECRET SCAN] Hardcoded secret detected. Remove it and use environment variables or a vault." >&2
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
@@ -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, memory-bank, ADRs -> @mnemosyne
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 1x com prompt rephrased (diferente, mais especifico)
211
- Ainda erro → escalate: "Agente X falhou 2x. Opcoes: (a) tentar outro, (b) simplificar, (c) pular"
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** — 2a falha = escalate, nao loop infinito
216
- - **Rephrase o prompt** — nao mande o mesmo texto. Mais especifico, menos escopo
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