#!/usr/bin/env python3 """SubagentStop / Stop adapter: bind the (sub)agent to its report and validate it — FAIL-CLOSED. Closes finding #2 (SubagentStop validation missed most reports and failed open). The prior version resolved only ``$CLAUDE_REPORT_PATH`` else the newest report *anywhere*, ignored agent identity, and exited 1 (non-blocking) on YAML errors — so a subagent could stop without a valid report, or be judged against a peer's. This rewrite: - reads the SubagentStop payload (``agent_id``, ``last_assistant_message``, optional ``agent_transcript_path``), - resolves THIS agent's report via a strict priority chain (below), - FAILS CLOSED (exit 2 = block) on: a registered report-producing agent with no report; a YAML parse error; malformed hook JSON on stdin, - ALLOWS (exit 0) genuinely exempt agents: one recorded in the registry as not report-producing, or an unknown/never-registered helper type — so read-only helpers are not over-blocked. An out-of-workspace declared report path is NEITHER bound NOR blocked — it is simply skipped (resolution falls through to the workspace-scoped priorities). Rationale: a session/agent commonly QUOTES or READS an existing report path from another workspace (status reports, scratch-dir artifacts, cross-workspace summaries); fail-closing on a mere mention traps legitimate sessions (observed live: a main-session Stop blocked because its final message quoted a real report path under a scratch dir). Security is preserved by NOT validating against it — a report-producing agent that produced no REAL in-workspace report is still fail-closed at the missing-report check, i.e. it can never satisfy validation by pointing outside the workspace. Report-path resolution priority (SHARED CONTRACT C2 / C4): 1. a ``*.report.yaml`` path mentioned inside ``last_assistant_message`` (or the agent transcript). AGENT-CONTROLLED text — bound as the declared report ONLY if it EXISTS and resolves INSIDE the workspace root; an out-of-workspace or non-existent match is skipped (not blocked), falling through to (2)/(3)/(4). 2. ``$CLAUDE_REPORT_PATH`` — trusted wiring env; used as-is (no escape block). 3. the registry's ``expected_report_dir`` for this ``agent_id`` (newest match). 4. RECURSIVE ``records_dir()/**/*.report.yaml`` filtered to this agent's workflow/role (never validate a subagent against a peer's report). This broad search runs ONLY for a registered agent (identity to filter on) or in --main mode; for an unregistered subagent it is skipped (that broad "newest anywhere" grab was the original fail-open bug). Exit codes (Claude Code convention): 0 = allow stop, 2 = block stop. Modes: (default) SubagentStop — validate the stopping subagent's report (identity-scoped, fail-closed exactly as described above). --main main-session Stop — ADVISORY ONLY (never blocks on a report). "Newest report anywhere under records_dir" cannot be reliably bound to *this* session's output, so blocking would trap unrelated turn-ends whenever the workspace already holds a stale/invalid report (e.g. a stale test workspace with old pre-receipt reports) — which would make the harness unusable once the Stop hook is wired. Instead --main resolves the workflow's final report if one exists, validates it, and on an invalid/unparseable/escaping report emits "[stop_validate] WARN (advisory, --main): ..." to stderr and exits 0. No report -> exit 0 (a main session may be read-only). Only malformed stdin still fails closed (harness corruption, defensive). Real enforcement of main-session outputs belongs in the command flow (e.g. /ceo-intake validating its own packet), NOT a blanket Stop hook that could trap an unrelated session. """ import glob import json import os import re import sys from datetime import datetime, timezone from functools import lru_cache import yaml HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) import validate_report as vr # noqa: E402 import _workspace as W # noqa: E402 ROOT = os.environ.get("CLAUDE_PROJECT_DIR") or os.path.dirname(os.path.dirname(HERE)) # Fallback classifier for agents that were never registered (mirrors # subagent_register.HELPER_AGENT_TYPES). Kept local so stop_validate has no import # dependency on the SubagentStart hook. HELPER_AGENT_TYPES = { "explore", "plan", "general-purpose", "claude", "claude-code-guide", "statusline-setup", "code-simplifier", "output-style-setup", } REPORT_RE = re.compile(r"[^\s'\"`()\[\]<>]+\.report\.yaml") # --------------------------------------------------------------------------- io def load_stdin(): """Return (payload_dict, malformed). Empty stdin -> ({}, False).""" try: raw = sys.stdin.read() except Exception: return {}, False raw = (raw or "").strip() if not raw: return {}, False try: obj = json.loads(raw) except json.JSONDecodeError: return {}, True if not isinstance(obj, dict): return {}, True return obj, False def workspace_paths(): """(work_root, records_dir, state_dir) or (None, None, None) if unresolved. Post-WP-4, _workspace raises when no workspace is configured; we degrade to None (skip registry/recursive resolution) rather than crash. """ try: return W.work_root(), W.records_dir(), W.state_dir() except Exception as e: sys.stderr.write(f"[stop_validate] workspace unresolved: {e}\n") return None, None, None # --------------------------------------------------------------------- registry def registry_records(state_dir): if not state_dir: return [] path = os.path.join(state_dir, "subagent-registry.jsonl") recs = [] try: with open(path, encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue try: o = json.loads(line) except json.JSONDecodeError: continue # tolerate a partial/corrupt line if isinstance(o, dict) and o.get("agent_id"): recs.append(o) except OSError: return [] return recs def lookup(agent_id, recs): if not agent_id: return None matches = [r for r in recs if str(r.get("agent_id")) == str(agent_id)] return matches[-1] if matches else None # append-only -> last = newest def is_report_producing(rec): if rec is None: return False for k in ("report_producing", "produces_report", "report-producing"): v = rec.get(k) if isinstance(v, bool): return v if rec.get("workflow_id") or rec.get("role") or rec.get("expected_report_dir"): return True at = str(rec.get("agent_type") or "").strip().lower() if not at or at in HELPER_AGENT_TYPES: return False if os.path.exists(os.path.join(ROOT, ".claude", "agents", at + ".md")): return True return False # -------------------------------------------------------------------- resolving def resolve_path(raw): raw = raw.strip().strip("'\"`") if os.path.isabs(raw): return os.path.normpath(raw) return os.path.normpath(os.path.join(ROOT, raw)) def within(root, path): """True if ``path`` is inside ``root`` (both realpath-normalized).""" if not root: return True # no boundary known -> cannot enforce containment try: r = os.path.realpath(root) p = os.path.realpath(path) return r == p or os.path.commonpath([r, p]) == r except (ValueError, OSError): return False def report_paths_in_text(text): if not isinstance(text, str) or not text: return [] return REPORT_RE.findall(text) def iter_reports(records_dir): if not records_dir or not os.path.isdir(records_dir): return [] return glob.glob(os.path.join(records_dir, "**", "*.report.yaml"), recursive=True) def matches_agent(path, rec): """Filter a report path to THIS agent by workflow (dir segment) and/or role (filename ``-.report.yaml``). rec=None (--main) matches all. A registered agent that carries NEITHER workflow NOR role has no identity to bind by — matching everything would (wrongly) grab the newest unrelated report in a populated workspace. Return False so such a rec never match-alls onto a peer's report (defense-in-depth for the finding-#2 "newest anywhere" bug).""" if rec is None: return True wf = str(rec.get("workflow_id") or "").strip() role = str(rec.get("role") or "").strip() if not wf and not role: return False ok = True if wf: parts = path.replace("\\", "/").split("/") ok = ok and (wf in parts) if role: ok = ok and os.path.basename(path).lower().startswith(role.lower() + "-") return ok def newest(paths): paths = [p for p in paths if os.path.exists(p)] if not paths: return None return max(paths, key=os.path.getmtime) # ------------------------------------------------------- ownership + freshness (P0-3) def _parse_ts(s): """Parse an ISO 'YYYY-MM-DDTHH:MM:SSZ' or compact 'YYYYMMDDTHHMMSSZ' UTC stamp to an epoch float, or None. Used to compare report creation vs agent start.""" if not isinstance(s, str) or not s.strip(): return None s = s.strip() for fmt in ("%Y-%m-%dT%H:%M:%SZ", "%Y%m%dT%H%M%SZ", "%Y-%m-%dT%H:%M:%S"): try: return datetime.strptime(s, fmt).replace(tzinfo=timezone.utc).timestamp() except ValueError: continue return None def _report_identity(path): """(workflow-id, role-id, created-at) from a report file header; ('', '', None) on error.""" try: with open(path, encoding="utf-8") as f: doc = yaml.safe_load(f) or {} if not isinstance(doc, dict): return "", "", None identity = doc.get("identity") if isinstance(doc.get("identity"), dict) else {} return (str(identity.get("workflow-id") or doc.get("workflow-id") or "").strip(), str(identity.get("producer-role-id") or doc.get("role-id") or "").strip(), doc.get("created-at")) except Exception: return "", "", None def owns_report(path, rec): """True iff `path` BELONGS to the agent in `rec` (ownership) AND is not STALE (freshness). rec=None (--main / unregistered) -> True (advisory, unchanged). Closes finding P0-3: previously a declared report path was bound with NO ownership or freshness check, so an agent could return a PEER's valid report or reuse a STALE report from a prior run. Ownership is PATH-based (workflow dir segment + role filename prefix, via ``matches_agent`` — the same signal Priority-4 uses): a peer's report lives under a DIFFERENT workflow dir / role-named file and is rejected. If the report ALSO declares workflow-id/role-id internally, a contradiction is rejected too (defence in depth). Freshness rejects a report created well before the agent started (cross-run reuse); a generous skew tolerance avoids false positives for a report written moments before registration in the same run.""" if rec is None: return True wf = str(rec.get("workflow_id") or "").strip() role = str(rec.get("role") or "").strip() if wf or role: # Identity captured at registration -> enforce strict path-based ownership # (workflow dir segment + role filename prefix). if not matches_agent(path, rec): return False # else: NO registry identity. Claude Code's native SubagentStart event provides only # agent_id + agent_type (no Org OS workflow_id/role, and the spawn prompt is not in the # payload either), so a report-producing worker registers with no identity to path-match # on. matches_agent() returns False for such a record, which previously rejected the # agent's OWN valid report and fail-closed it as "produced no report" — an infinite # Stop-block loop on EVERY worker. When there is no identity to match, we cannot reject # by path; instead we trust the agent's OWN declared report (Priority 1 — existence and # in-workspace containment already checked by the caller) gated by FRESHNESS + internal # workflow-id consistency below. Priority 4's recursive "newest under records_dir" grab # stays closed because it independently requires matches_agent(), which is still False # for a no-identity record — so this leniency binds only a self-declared report, never # an unrelated newest-anywhere report (the finding-#2 fail-open is not reopened). # # Native registration now derives a concrete role-id from the selected agent card. # Cross-check the report body as well as its filename so renaming a peer report cannot # transfer ownership. Synthetic legacy routing tokens are left path-scoped; registered # Org OS roles (agent card role-id) are body-bound. r_wf, r_role, r_created = _report_identity(path) if wf and r_wf and wf != r_wf: return False if role and _is_concrete_card_role(role) and str(r_role).upper() != role.upper(): return False started = _parse_ts(rec.get("started_at")) if started is not None: # Freshness keys on the file's actual MTIME (filesystem ground truth), NOT the # report's self-declared created-at field. An orchestrator often PRE-MINTS a # report path + created-at stamp before the spawn, then spends time compiling # context packages, so created-at legitimately predates the worker's own # SubagentStart — using it as the freshness signal fail-closes every pre-minted # report as "stale", forcing a costly re-emit (observed ~2x tokens on a fan-out # wave). The mtime is set when the worker actually writes the report THIS run and, # unlike the (agent-controlled) created-at, cannot be back-dated from within the # file. A genuinely reused cross-run report keeps its OLD mtime and is still # rejected, so the stale-reuse defence (finding P0-3) is preserved. try: mtime = os.path.getmtime(path) except OSError: mtime = None if mtime is not None and mtime < started - 10: # 10s skew tolerance return False # stale: the file was last written before this agent started return True @lru_cache(maxsize=128) def _is_concrete_card_role(role): target = str(role or "").upper() if not target: return False for path in glob.glob(os.path.join(ROOT, ".claude", "agents", "*.md")): try: text = open(path, encoding="utf-8").read() if not text.startswith("---\n"): continue frontmatter = yaml.safe_load(text.split("---\n", 2)[1]) or {} if str(frontmatter.get("role-id") or "").upper() == target: return True except Exception: continue return False def resolve_report(payload, rec, work_root, records_dir, main_mode): """Return (path, block_reason). A non-None block_reason means fail-closed.""" # Whether THIS agent is under the "declare/produce a report" contract at all. # A non-report-producing helper (registered report_producing=false, or an # unknown/never-registered agent -> is_report_producing(None) is False) is NOT # bound to any report: it must not be fail-closed against a report it merely # READ or QUOTED. Audit/review/status agents routinely mention existing # *.report.yaml paths in their final message; treating such a mention as a # self-declared report (Priority 1) wrongly validates a peer's report and blocks # the helper. Real family/role workers are always registered report-producing via # the unconditionally-wired SubagentStart hook, so gating on this flag does not # open a fail-open hole for a genuine producer. reporting = main_mode or is_report_producing(rec) # --- Priority 1: a report path the agent itself DECLARED (untrusted). # last_assistant_message is scanned before the transcript: the agent declares its # report in its final message per the return contract, while the transcript is a # secondary source where INCIDENTAL mentions live (e.g. it read a file that merely # contains a "*.report.yaml" string). A match counts as a declaration ONLY if the # path actually EXISTS on disk — a mere mention of a non-existent path is skipped, # NOT blocked (otherwise reading e.g. test_enforcement.py, which contains # "lowrole.report.yaml", would fail-close a legitimate subagent). The escape-block # fires only when the path EXISTS and is outside work_root (a real out-of-workspace # report — the actual thing worth blocking); resolution otherwise falls through to # Priority 2/3/4 (ultimately fail-closing for a registered agent with no real report). texts = [] msg = payload.get("last_assistant_message") if isinstance(msg, str): texts.append(msg) tpath = payload.get("agent_transcript_path") if isinstance(tpath, str) and tpath and os.path.exists(tpath): try: with open(tpath, encoding="utf-8", errors="replace") as f: texts.append(f.read()) except OSError: pass # Mention-binding applies ONLY to report-producing agents / --main (see `reporting`). for text in (texts if reporting else []): for raw in report_paths_in_text(text): absp = resolve_path(raw) if not (absp.endswith(".report.yaml") and os.path.exists(absp)): continue # a mention of a non-existent path is not a declaration -> skip if work_root is not None and not within(work_root, absp): # Out-of-workspace path: do NOT bind and do NOT block. Merely quoting or # reading an existing report path from another workspace (status reports, # scratch-dir artifacts, cross-workspace summaries) is common and must not # fail-close the session. Security holds because we never validate against # it: resolution falls through to the workspace-scoped priorities, so a # report-producing agent with no REAL in-workspace report is still # fail-closed below (it cannot pass by pointing outside the workspace). continue if not owns_report(absp, rec): # finding P0-3: the declared path exists and is in-workspace, but it is a # PEER's report or a STALE report (identity/freshness mismatch). Do NOT # bind it — fall through so a producer with no OWN fresh report stays # fail-closed. This is the hole that let agent A return agent B's report. continue return absp, None # exists, in-workspace, owned & fresh -> the declared report # --- Priority 2: $CLAUDE_REPORT_PATH (trusted wiring; used as-is) env = os.environ.get("CLAUDE_REPORT_PATH") if env: absp = resolve_path(env) if os.path.exists(absp) and owns_report(absp, rec): return absp, None # --- Priority 3: registry expected_report_dir (newest report within) if rec is not None: edir = rec.get("expected_report_dir") if edir: edabs = resolve_path(str(edir)) cand = newest([ path for path in glob.glob( os.path.join(edabs, "**", "*.report.yaml"), recursive=True) if owns_report(path, rec) ]) if cand: return cand, None # --- Priority 4: recursive search under records_dir, filtered to this agent. # registered REPORT-PRODUCING agent -> filter by workflow/role (never a peer's report). # --main -> rec is None, newest report = the workflow's final artifact. # registered NON-report-producing helper (general-purpose/explore/etc.) -> SKIP: it is # exempt (main() lets it stop with no report), and with no workflow/role its filter # would match EVERYTHING and wrongly bind it to the newest unrelated report in a # populated workspace — fail-closed false positive (finding #2 re-manifesting). # unregistered subagent (rec None, not main) -> SKIP (the original fail-open bug # was grabbing the newest report anywhere with no identity filter). if records_dir and (main_mode or (rec is not None and is_report_producing(rec))): cand = newest([p for p in iter_reports(records_dir) if matches_agent(p, rec) and owns_report(p, rec)]) if cand: return cand, None return None, None # -------------------------------------------------------------------------- run def _block(msg): sys.stderr.write(f"[stop_validate] BLOCK: {msg}\n") sys.exit(2) def _reject(msg, main_mode): """Report-derived rejection: fail-closed (block, exit 2) for SubagentStop, but ADVISORY (warn + allow, exit 0) for --main. Main-session final-report binding is ambiguous — the "newest report under records_dir" cannot be reliably attributed to this session — so a hard block there would trap unrelated turn-ends whenever the workspace holds a stale/invalid report. --main therefore only warns; enforcement of main-session outputs belongs in the command flow (e.g. /ceo-intake validating its own packet), not this hook. (Malformed stdin is handled separately and still fails closed even in --main.) """ if main_mode: sys.stderr.write(f"[stop_validate] WARN (advisory, --main): {msg}\n") sys.exit(0) sys.stderr.write(f"[stop_validate] BLOCK: {msg}\n") sys.exit(2) def main(): main_mode = "--main" in sys.argv[1:] payload, malformed = load_stdin() if malformed: # Defensive: valid hooks always send well-formed JSON; corruption fails closed. _block("malformed hook JSON on stdin (fail-closed).") agent_id = payload.get("agent_id") work_root, records_dir, state_dir = workspace_paths() # fail-closed(P0-1): a SubagentStop for an Org OS report-producing agent must not # pass merely because the workspace is UNSET — an unset workspace empties the # registry and skips all report resolution, so the old code let such an agent stop # with no report at all. When the registry is unavailable we classify by the # payload's agent_type (Org OS workers have a generated agent card; helpers do not). # --main stays advisory and never hard-blocks on report state. if not main_mode and work_root is None: at = str(payload.get("agent_type") or payload.get("subagent_type") or payload.get("agentType") or "").strip().lower() producing_by_type = bool(at) and at not in HELPER_AGENT_TYPES and \ os.path.exists(os.path.join(ROOT, ".claude", "agents", at + ".md")) if producing_by_type: _block( f"report-producing agent '{agent_id}' (type={at}) stopped with workspace " f"UNSET — cannot resolve/validate its report (fail-closed). " f"Set ORGOS_WORKSPACE=.") rec = lookup(agent_id, registry_records(state_dir)) # block_reason is a reserved slot for a resolution-time hard failure. It is # currently never set (out-of-workspace paths are skipped, not blocked — see # resolve_report), but the plumbing is kept so a future resolution-level failure # can fail-closed here consistently. path, block_reason = resolve_report(payload, rec, work_root, records_dir, main_mode) if block_reason: _reject(block_reason, main_mode) if path is None: if main_mode: # Main session may legitimately produce no report -> allow. sys.exit(0) if is_report_producing(rec): _block( f"report-producing agent '{agent_id}' " f"(type={rec.get('agent_type')}) produced no report." ) # Exempt: registry says not report-producing, or unknown/never-registered helper. sys.exit(0) # Concrete report resolved -> validate (fail-closed on parse error / violations). try: with open(path, encoding="utf-8") as f: report = yaml.safe_load(f) except FileNotFoundError: # Raced away between resolve and read. if main_mode or not is_report_producing(rec): sys.exit(0) _block(f"report vanished before validation: {path}") except yaml.YAMLError as e: _reject(f"YAML 파싱 오류 {path}: {e}", main_mode) # C3 contract call. Sibling WP-6 extends validate() to accept report_path=; until # it lands, fall back to the back-compatible positional call so we never crash. try: errors = vr.validate(report, report_path=path) except TypeError: errors = vr.validate(report) if errors: joined = "\n".join(f" - {e}" for e in errors) if main_mode: # Advisory only — never trap the main session on a report we can't reliably # attribute to it. sys.stderr.write(f"[stop_validate] WARN (advisory, --main) {path}:\n{joined}\n") sys.exit(0) sys.stderr.write(f"[stop_validate] BLOCK {path}:\n{joined}\n") sys.exit(2) print(f"OK stop_validate: {path}") sys.exit(0) if __name__ == "__main__": main()