Files
company-haness/.claude/hooks/stop_validate.py
T

550 lines
25 KiB
Python

#!/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 ``<role>-<stamp>.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=<project>.")
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()