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

774 lines
37 KiB
Python

#!/usr/bin/env python3
"""Validate an Org OS agent report against the report contract (C3/C6).
헤더 '모양'만 보지 않고, 실제 실행 근거(evidence-ledger receipt)와 대조한다.
강제하는 것:
- answer-first (BLUF): report-header.bottom-line 필수.
- decision-needed(+approver), confidence enum, risks list, evidence[] 존재.
- report-type 판별자 + .claude/schemas/*.json 유형별 필수필드(JSON Schema).
- **receipt 기반 등급(C6)**: E4/E5 주장은 <evidence_dir>/ledger.jsonl(C5)의
실제 실행 receipt와 일치해야 한다. command+exit-code:0 주장 → 같은 command·
exit_code:0 receipt 필요. 파일 산출 주장 → 그 경로의 artifact_sha256 receipt
필요. 기존 파일(예: CLAUDE.md) 단순 참조만으로는 E4/E5 불가. receipt 없으면 차단.
- synthesis dissent 보존: linked-reports(실존) + conflicts/dissent(리스트, null 불가).
- role-id 정합: 미등록/소문자 role-id는 lens 판별 불가 → 차단.
- **회사 문맥 상한(#5)**: company-context.yaml 이 채워지기(status: populated) 전에는
org-os/01-company·03-products·04-architecture·05-operations 등 회사/제품 문맥 폴더를
E3+ 강한 근거로 인용할 수 없다(빈 템플릿 위장 방지). 그런 판단은 일반론 → E1/E2·Med 상한.
Usage:
python3 validate_report.py <report.yaml> # exit 0 pass / 2 block
echo '{"report_path": "..."}' | python3 validate_report.py # stdin JSON
Public API (C3):
validate(report: dict, report_path: str | None = None) -> list[str]
- 위반 사유 문자열 리스트(빈 리스트 = 통과). **예외를 던지지 않는다.**
- report_path가 주어지면 그 경로에서 workspace를 해석해 evidence-ledger를 대조한다.
- 하위호환: 기존 호출부 validate(report) 그대로 동작.
"""
import json
import os
import re
import sys
import yaml
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
try: # P3-B: method-execution 강제(공용 policy engine). 미가용 시 degrade(신규 게이트·회귀 방지).
import method_contracts as _MC
except Exception: # noqa: BLE001
_MC = None
ROOT = os.environ.get("CLAUDE_PROJECT_DIR") or os.path.dirname(os.path.dirname(HERE))
SCHEMA_DIR = os.path.join(ROOT, ".claude", "schemas")
VALID_GRADES = {"E0", "E1", "E2", "E3", "E4", "E5"}
KNOWN_TYPES = {
"decision", "work", "completion", "review", "blocked", "design", "build", "spec",
"workflow-artifact",
}
# P1-D(#9): 실물 산출물을 report와 분리 요구하는 유형. 이 유형이면 primary-artifacts[]가
# 존재하고 각 path가 실존해야 한다(보고서 몇 줄 요약으로 실물을 대체하지 못하게).
ARTIFACT_REQUIRED_TYPES = {"completion", "design", "build", "spec"}
# --------------------------------------------------------------------------- #
# 경로/원장 해석
# --------------------------------------------------------------------------- #
def _resolve(uri):
if os.path.isabs(uri):
return uri
return os.path.join(ROOT, uri)
# finding #5: 회사/제품 문맥 네임스페이스. company-context.yaml 이 채워지기(status: populated) 전에는
# 이 폴더의 파일을 E3+ 강한 근거로 인용할 수 없다(빈 템플릿을 '실제 회사 자료'로 위장 방지).
# 그런 판단은 일반론이므로 근거등급 <= E2, confidence <= Med 로 상한한다.
_COMPANY_NS = (
"org-os/01-company", "org-os/02-capabilities", "org-os/03-products",
"org-os/04-architecture", "org-os/05-operations", "org-os/07-knowledge-base",
)
_COMPANY_CTX = os.path.join(ROOT, "org-os", "01-company", "company-context.yaml")
def _company_context_populated():
"""공식 company-context 가 '실데이터로 운영 중'이면 True → 회사 인용 상한 해제.
신 어휘 status=='operating' 만 True. 구 'populated' 는 읽기 호환(operating 취급).
template/provisional(및 구 'demo')은 False = 회사 인용 항목 E2/Med 상한 유지(§9.1)."""
try:
import yaml as _y # noqa: E402
doc = _y.safe_load(open(_COMPANY_CTX, encoding="utf-8")) or {}
st = str(doc.get("status", "")).strip().lower()
if st == "populated":
sys.stderr.write("[validate_report] WARN: status='populated' deprecated → 'operating'\n")
return True
return st == "operating"
except Exception:
return False
def _is_unpopulated_company_ref(src):
"""src 가 회사/제품 문맥(또는 CLAUDE.md)인데 company-context 가 아직 채워지지 않았으면 True.
finding R1: 예전엔 상대경로 `org-os/01-company/…` 만 잡아, (a) 같은 파일의 **절대경로**나
(b) **CLAUDE.md**(하네스/회사 계획 문서)로 인용하면 E3 상한을 우회할 수 있었다. 이제 절대경로를
ROOT 기준으로 정규화하고, CLAUDE.md 도 빈 회사문맥의 강근거 위장 대상으로 본다."""
if not src or re.match(r"^https?://", str(src), re.IGNORECASE):
return False
raw = str(src).strip().strip("'\"`").replace("\\", "/")
try:
ap = os.path.abspath(raw if os.path.isabs(raw) else os.path.join(ROOT, raw))
rel = os.path.relpath(ap, ROOT).replace("\\", "/")
except Exception:
rel = raw.lstrip("./")
base = rel.rsplit("/", 1)[-1]
in_company_ns = any(rel.startswith(ns) for ns in _COMPANY_NS)
is_claude_md = base == "CLAUDE.md"
if not (in_company_ns or is_claude_md):
return False
return not _company_context_populated()
def _is_hypothesis_company_ref(src):
"""company-context 의 hypothesis 항목을 anchor(#HYP-...)로 인용하면 True.
가설 기반 회사 결론은 status=operating 이어도 E2/Med 상한(§9.1)."""
if not src:
return False
raw = str(src).strip().strip("'\"`").replace("\\", "/")
if "#" not in raw:
return False
path, _, anchor = raw.partition("#")
base = path.rsplit("/", 1)[-1]
return base == "company-context.yaml" and anchor.upper().startswith("HYP-")
def _ledger_path_for(report_path):
"""report_path에서 workspace를 해석해 <evidence_dir>/ledger.jsonl 경로를 반환.
우선순위: (1) 경로 구조에서 completion-records의 부모=work_root를 유도(env 독립),
(2) 실패 시 _workspace(ORGOS_WORKSPACE/포인터) 폴백.
둘 다 안 되면 None(receipt 0개로 취급)."""
if report_path:
try:
ap = os.path.abspath(report_path)
parts = ap.split(os.sep)
if "completion-records" in parts:
idx = len(parts) - 1 - parts[::-1].index("completion-records")
work_root = os.sep.join(parts[:idx]) or os.sep
return os.path.join(work_root, "evidence", "ledger.jsonl")
except Exception:
pass
try:
import _workspace as W # noqa: E402
return os.path.join(W.evidence_dir(), "ledger.jsonl")
except Exception:
return None
def _load_receipts(report_path):
lp = _ledger_path_for(report_path)
if not lp or not os.path.exists(lp):
return []
out = []
try:
with open(lp, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
if isinstance(rec, dict):
out.append(rec)
except Exception:
continue
except Exception:
return []
return out
def _norm(s):
return " ".join(str(s).split())
def _receipt_exit_ok(r):
ec = r.get("exit_code")
return ec == 0 or ec == "0"
def _cmd_receipt(receipts, cmd):
"""command+exit_code:0 receipt를 찾는다. finding P0-6: **정확 일치만** 인정한다.
예전엔 부분일치(claim ⊂ receipt)를 허용해, `echo` 주장이 `python3 -m pytest … && echo done`
receipt에 매칭되는 우회가 있었다 — 이제 정규화 동등(rcn == n)만 인정한다."""
if not cmd:
return None
n = _norm(cmd)
if not n:
return None
for r in receipts:
rc = r.get("command")
if not isinstance(rc, str):
continue
rcn = _norm(rc)
if not rcn:
continue
if rcn == n and _receipt_exit_ok(r):
return r
return None
def _artifact_receipt(receipts, src, report_path):
"""해당 파일 경로에 대한 artifact_sha256 receipt를 찾는다."""
if not src:
return None
cand = set()
if os.path.isabs(src):
cand.add(os.path.normpath(src))
else:
cand.add(os.path.normpath(os.path.join(ROOT, src)))
if report_path:
cand.add(os.path.normpath(os.path.join(
os.path.dirname(os.path.abspath(report_path)), src)))
for r in receipts:
ap = r.get("artifact_path")
if not isinstance(ap, str) or not ap:
continue
if not r.get("artifact_sha256"):
continue
rcwd = r.get("cwd") or ROOT
rap = ap if os.path.isabs(ap) else os.path.join(rcwd, ap)
# finding P0-6: **정확한 절대경로 일치만** 인정한다. 예전엔 basename 일치 폴백이 있어
# /tmp/a/result.json receipt 가 /different/project/result.json 주장에 매칭되는 우회가 있었다.
if os.path.normpath(rap) in cand:
try:
live = next(path for path in cand if os.path.normpath(path) == os.path.normpath(rap))
import hashlib
digest = hashlib.sha256(open(live, "rb").read()).hexdigest()
except Exception:
continue
if digest == r.get("artifact_sha256"):
return r
return None
def _linked_exists(link, report_path):
if not isinstance(link, str) or not link.strip():
return False
link = link.strip()
cands = []
if os.path.isabs(link):
cands.append(link)
else:
if report_path:
cands.append(os.path.join(
os.path.dirname(os.path.abspath(report_path)), link))
cands.append(os.path.join(ROOT, link))
cands.append(link)
return any(os.path.exists(c) for c in cands)
def _artifact_exists(path, report_path):
"""primary-artifacts의 path 실존 검사. report 디렉토리·ROOT·raw 순으로 후보 해석."""
if not isinstance(path, str) or not path.strip():
return False
p = path.strip()
cands = []
if os.path.isabs(p):
cands.append(p)
else:
if report_path:
cands.append(os.path.join(
os.path.dirname(os.path.abspath(report_path)), p))
cands.append(os.path.join(ROOT, p))
cands.append(p)
return any(os.path.exists(c) for c in cands)
def _primary_artifacts_errors(report, report_path, receipts):
"""P1-D(#9): design/spec/build/completion 유형은 실물 산출물을 report와 분리 요구.
- primary-artifacts[]가 존재하고 비어있지 않아야 한다(보고서 몇 줄 요약으로 실물 대체 금지).
- 각 항목은 path를 갖고 그 path가 실존해야 한다(report는 실물의 envelope).
- sha를 선언하면 evidence-ledger(C5) artifact receipt와 교차검증한다(불일치 시 차단).
다른 유형(decision/work/review/blocked/미지)에는 적용하지 않는다(하위호환)."""
rtype = report.get("report-type") or report.get("report_type")
if not (isinstance(rtype, str) and rtype in ARTIFACT_REQUIRED_TYPES):
return []
pa = report.get("primary-artifacts")
if not isinstance(pa, list) or len(pa) == 0:
return [
f"primary-artifacts[] 필수({rtype} 유형): RFC/ADR·data-model·threat-model·"
"api-contract·실제 코드 같은 실물 산출물을 report와 분리해 실제 파일로 등재해야 한다 "
"— 보고서 몇 줄 요약으로 실물을 대체할 수 없다(#9)."]
errs = []
for i, a in enumerate(pa):
if not isinstance(a, dict):
errs.append(
f"primary-artifacts[{i}] 형식 오류: dict 필요(path/kind/verification).")
continue
path = a.get("path")
if not (isinstance(path, str) and path.strip()):
errs.append(f"primary-artifacts[{i}].path 누락: 실물 산출물 경로 필수.")
continue
if not _artifact_exists(path, report_path):
errs.append(
f"primary-artifacts[{i}].path 실존하지 않음: {path} "
"— report는 실물의 envelope이며 실제 파일이 존재해야 한다(#9).")
continue
# finding P0-5: kind/verification 이 빈 문자열/null 이면 스키마 required(presence)는
# 통과해도 실제로는 미검증 산출물이다 — 비어있음·위장값을 거부한다.
kind = a.get("kind")
if not (isinstance(kind, str) and kind.strip()):
errs.append(f"primary-artifacts[{i}].kind 비어있음: 산출물 종류(rfc/adr/code/api-contract/…) 명시 필수.")
verif = a.get("verification")
_bad_verif = (verif is None
or (isinstance(verif, str) and (not verif.strip()
or verif.strip().lower() in ("none", "n/a", "self-assertion", "self-report", "trust-me"))))
if _bad_verif:
errs.append(f"primary-artifacts[{i}].verification 누락/무의미: 이 산출물을 무엇으로 검증했는지 명시(E4/E5는 receipt 필요).")
declared_sha = a.get("sha")
if declared_sha:
r = _artifact_receipt(receipts, path, report_path)
if r and r.get("artifact_sha256") and \
str(r["artifact_sha256"]) != str(declared_sha):
errs.append(
f"primary-artifacts[{i}].sha가 evidence-ledger receipt와 불일치: "
f"{path} (declared={declared_sha}, receipt={r['artifact_sha256']}).")
return errs
def _known_role_ids():
"""capability-families의 member-role-ids(+lead)를 등록 role 집합으로. 실패 시 빈 set."""
try:
p = os.path.join(ROOT, "org-os", "00-role-registry", "capability-families.yaml")
fams = yaml.safe_load(open(p, encoding="utf-8"))["capability-families"]["families"]
s = set()
for f in fams:
for rid in (f.get("member-role-ids") or []):
s.add(rid)
if f.get("lead-role-id"):
s.add(f["lead-role-id"])
s.add("HUMAN-001")
return s
except Exception:
return set()
def _fixture_roles():
"""P3-B cutover: 테스트 전용 fixture 역할 레지스트리(.claude/tests/fixtures/_fixture_roles.yaml).
실패 시 빈 set — 그래도 TST- 접두 패턴으로 인식(레지스트리는 문서·화이트리스트 보조)."""
try:
p = os.path.join(ROOT, ".claude", "tests", "fixtures", "_fixture_roles.yaml")
d = yaml.safe_load(open(p, encoding="utf-8")) or {}
return set((d.get("test-fixture-roles") or {}).keys())
except Exception:
return set()
def _is_fixture_role(rid):
"""role-id 가 테스트 전용 fixture 역할인가(TST- 접두 또는 레지스트리 등재)."""
if not isinstance(rid, str) or not rid.strip():
return False
r = rid.strip()
return r.upper().startswith("TST-") or r in _fixture_roles()
def _in_test_fixture_context(report_path):
"""TST-* 허용 컨텍스트인가. 경로 없음(programmatic 단위테스트) 또는 .claude/tests 아래면 True.
실제 워크스페이스 reports 경로면 False → production 에서 TST-* 는 계약 우회로 간주해 차단."""
if not report_path:
return True
ap = os.path.abspath(report_path)
marker = os.sep + os.path.join(".claude", "tests") + os.sep
return marker in ap
# --------------------------------------------------------------------------- #
# JSON Schema (report-type 판별자)
# --------------------------------------------------------------------------- #
def _load_json(path):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except Exception:
return None
def _deep_merge(base, delta):
"""base에 delta를 재귀 병합. 'required' 리스트는 합집합(순서보존), 중첩 dict는 재귀,
그 외 leaf는 delta 우선. finding P0-5: 예전 properties.update()는 얕은 덮어쓰기라
build/completion/design/spec 델타의 `primary-artifacts: {type: array}`가 공통 스키마의
`primary-artifacts.items.required(path/kind/verification)` 제약을 통째로 지웠다 —
이제 재귀 병합으로 공통 중첩 제약을 보존한다."""
if not isinstance(base, dict) or not isinstance(delta, dict):
return delta if delta is not None else base
out = dict(base)
for k, dv in delta.items():
bv = out.get(k)
if k == "required" and isinstance(bv, list) and isinstance(dv, list):
out[k] = list(dict.fromkeys(bv + dv))
elif isinstance(bv, dict) and isinstance(dv, dict):
out[k] = _deep_merge(bv, dv)
else:
out[k] = dv if dv is not None else bv
return out
def _merged_schema(rtype):
common = _load_json(os.path.join(SCHEMA_DIR, "report.schema.json"))
if not isinstance(common, dict):
return None
if rtype in KNOWN_TYPES:
delta = _load_json(os.path.join(SCHEMA_DIR, f"{rtype}.schema.json"))
if isinstance(delta, dict):
common = _deep_merge(common, delta)
return common
def _minimal_schema_check(report, schema):
"""jsonschema 미설치 시 폴백: 최상위 required + report-header 하위 required만 점검."""
errs = []
for req in (schema.get("required") or []):
if req not in report:
errs.append(f"[schema] 최상위 필수 필드 누락: {req}")
rh = report.get("report-header")
rh_schema = (schema.get("properties") or {}).get("report-header") or {}
if isinstance(rh, dict):
for req in (rh_schema.get("required") or []):
if req not in rh:
errs.append(f"[schema] report-header 필수 필드 누락: {req}")
return errs
def _schema_errors(report):
rtype = report.get("report-type") or report.get("report_type")
rtype = rtype if isinstance(rtype, str) else None
schema = _merged_schema(rtype)
if not isinstance(schema, dict):
return [] # 스키마 파일 부재 → sane degrade
tag = f":{rtype}" if rtype in KNOWN_TYPES else ""
try:
import jsonschema # noqa: E402
validator = jsonschema.Draft7Validator(schema)
errs = []
for err in sorted(validator.iter_errors(report), key=lambda e: list(e.path)):
loc = "/".join(str(p) for p in err.path) or "(root)"
errs.append(f"[schema{tag}] {loc}: {err.message}")
return errs
except ImportError:
return _minimal_schema_check(report, schema)
except Exception:
# 어떤 스키마 처리 실패도 검증을 막지 않는다(hand-check가 본류)
return _minimal_schema_check(report, schema)
# --------------------------------------------------------------------------- #
# 메인 검증
# --------------------------------------------------------------------------- #
def validate(report, report_path=None, current_artifact=None):
"""C3 시그니처. 위반 사유 리스트 반환. 예외를 던지지 않는다."""
errors = []
if not isinstance(report, dict):
return ["report가 dict가 아님(YAML 파싱 실패/형식 오류) — 통과 불가."]
# Contract v1 envelope: identity가 정본이다. 기존 validator/스키마와의 읽기 호환을 위해
# 검증 중에만 top-level aliases를 만든다(원본 report를 mutate하지 않음).
report = dict(report)
ident = report.get("identity")
if isinstance(ident, dict):
report.setdefault("report-id", ident.get("artifact-id"))
report.setdefault("workflow-id", ident.get("workflow-id"))
report.setdefault("role-id", ident.get("producer-role-id"))
# Standalone validation must apply the same artifact method binding as the
# trusted submit path. Previously `validate_report.py <workflow-artifact>`
# did not know its current artifact kind and falsely demanded a craft method
# trace from workflow-control/stage-synthesis/independent-review records,
# while state_engine submission accepted the exact same bytes.
if current_artifact is None and report.get("report-type") == "workflow-artifact":
current_artifact = {
"artifact-id": report.get("report-id"),
"artifact-kind": report.get("artifact-kind"),
}
hdr = report.get("report-header")
if not isinstance(hdr, dict):
return ["report-header 누락: 모든 산출물은 report-header(BLUF)로 시작해야 한다."]
# receipt는 evidence(C6)와 primary-artifacts(#9) sha 교차검증에 함께 쓰므로 한 번만 로드.
receipts = _load_receipts(report_path)
# Strong evidence is workflow scoped. Unscoped workspace-wide receipts are never reusable.
_wf = str(report.get("workflow-id") or "").strip()
if _wf:
receipts = [r for r in receipts
if str(r.get("workflow_id") or "").strip() == _wf
and r.get("session_id") and r.get("agent_id")]
# 0a) identity + type 필수(finding P0-3/P0-5): 보고서는 자기식별 가능해야 하고(SubagentStop이
# 소유·freshness 바인딩에 사용), 유형은 알려진 것이어야 한다(미지/오타 유형으로 typed-schema
# 검사를 우회하지 못하게).
rtype = report.get("report-type") or report.get("report_type")
if not (isinstance(rtype, str) and rtype.strip()):
errors.append("report-type 누락(P0-5): decision/work/completion/review/blocked/design/build/spec 중 하나 필수.")
elif rtype.strip() not in KNOWN_TYPES:
errors.append(f"report-type '{rtype}' 미지 유형(P0-5): 알려진 유형만 허용 {sorted(KNOWN_TYPES)} — 오타/위장 차단.")
for idf in ("report-id", "workflow-id", "role-id"):
v = report.get(idf)
if not (isinstance(v, (str, int)) and str(v).strip()):
errors.append(f"{idf} 누락/빈값(P0-3): 보고서 자기식별 필수 — SubagentStop이 소유·freshness 바인딩에 쓴다.")
if isinstance(rtype, str) and rtype.strip() in ("build", "completion"):
vp = report.get("verification-performed")
if vp is None or (isinstance(vp, str) and not vp.strip()):
errors.append("verification-performed 누락/빈값(#9): 무엇을 검증했는지(테스트/명령/리뷰) 명시 필수.")
# 0) 구조/유형 스키마
try:
errors += _schema_errors(report)
except Exception:
pass
# 0.5) primary-artifacts 분리(#9): design/spec/build/completion 유형은 실물 산출물 실존 강제.
try:
errors += _primary_artifacts_errors(report, report_path, receipts)
except Exception:
pass
# 1) BLUF
bl = hdr.get("bottom-line")
if not (isinstance(bl, str) and bl.strip()):
errors.append("report-header.bottom-line 비어있음(answer-first 위반).")
# 2) decision-needed
dn = hdr.get("decision-needed")
if not isinstance(dn, dict) or "needed" not in dn:
errors.append("report-header.decision-needed(needed/approver) 누락.")
elif dn.get("needed") is True and not str(dn.get("approver") or "").strip():
errors.append("decision-needed=true인데 approver 미지정(RACI: 승인권자 필수).")
# 3) confidence
conf = hdr.get("confidence") or {}
cval = conf.get("value") if isinstance(conf, dict) else None
if cval not in {"High", "Med", "Low"}:
errors.append("confidence.value는 High/Med/Low 중 하나여야 한다.")
# 4) risks list
if not isinstance(hdr.get("risks"), list):
errors.append("report-header.risks는 리스트여야 한다(빈 리스트 허용).")
# 5) evidence grounding + receipt 기반 등급(C6) — receipts는 위에서 이미 로드됨.
ev = hdr.get("evidence")
strong = False
if not isinstance(ev, list) or len(ev) == 0:
errors.append("evidence[] 비어있음: 근거 없는 산출은 통과 불가(자기채점 차단).")
else:
for i, e in enumerate(ev):
if not isinstance(e, dict):
errors.append(f"evidence[{i}] 형식 오류.")
continue
grade = e.get("grade")
if grade not in VALID_GRADES:
errors.append(f"evidence[{i}].grade는 E0..E5 여야 한다(got {grade}).")
continue
gnum = int(str(grade)[1])
# finding #5: 회사/제품 문맥이 비어있으면(company-context status != populated) 그 폴더를
# E3+ 강한 근거로 인용 불가 — 빈 템플릿을 실제 회사 자료로 위장하는 것을 막는다.
# Task 14: hypothesis anchor(#HYP-...)는 status=operating 이어도 상한 유지 —
# gasl-based 회사 결론(가설)이 사실처럼 강근거로 인용되는 걸 막는다.
src = e.get("source-uri")
if gnum >= 3 and (_is_unpopulated_company_ref(src) or _is_hypothesis_company_ref(src)):
errors.append(
f"evidence[{i}] grade {grade}: 회사/제품 문맥('{e.get('source-uri')}')이 "
"아직 채워지지 않았거나(org-os/01-company/company-context.yaml status != populated) "
"hypothesis 기반(anchor #HYP-...) 근거다. "
"빈 회사 문맥/가설 기반 근거는 E3+ 근거가 될 수 없다 — E1/E2로 낮추거나 실제 회사 자료를 채워라(#5).")
continue
has_file = "source-uri" in e
has_cmd = "command" in e and "exit-code" in e
is_url = False
file_ok = False
src = None
if has_file:
src = str(e["source-uri"])
if re.match(r"^https?://", src, re.IGNORECASE):
is_url = True # 외부 URL: E2 이하 참고근거로만 유효
else:
file_ok = os.path.exists(_resolve(src))
if not file_ok:
errors.append(
f"evidence[{i}].source-uri 실존하지 않음: {src} (허위 근거 차단).")
cmd_ok = has_cmd and e.get("exit-code") == 0
if not (has_file or has_cmd):
errors.append(f"evidence[{i}]: source-uri 또는 command+exit-code 필요.")
continue
if gnum >= 4:
# E4/E5: 실제 실행 receipt로만 접지된다(자기신고 차단).
backed = False
if has_cmd:
if e.get("exit-code") != 0:
errors.append(
f"evidence[{i}] grade {grade}: exit-code≠0인데 강한 근거 주장 "
"— 실패한 실행은 E4/E5 근거가 될 수 없다.")
else:
receipt = _cmd_receipt(receipts, e.get("command"))
if not receipt:
errors.append(
f"evidence[{i}] grade {grade}: command "
f"'{_norm(e.get('command'))}' 의 실행 receipt가 ledger에 없음 "
"— 자기신고 미검증(PostToolUse evidence_ledger의 exit_code:0 "
"receipt 필요). 실행 없이 통과 불가.")
elif grade == "E5" and not (
receipt.get("receipt_type") in ("test-run", "experiment-run", "verification-run")
and receipt.get("assertion_status") == "passed"):
errors.append(f"evidence[{i}] grade E5: typed test/experiment receipt와 passed assertion 필요")
else:
backed = True
elif is_url:
errors.append(
f"evidence[{i}] grade {grade}: 외부 URL은 E4/E5 불가"
"(로컬 실행/산출 아티팩트 필요).")
elif has_file:
artifact_receipt = _artifact_receipt(receipts, src, report_path)
if artifact_receipt and grade == "E5" and not (
artifact_receipt.get("receipt_type") in ("test-run", "experiment-run", "verification-run")
and artifact_receipt.get("assertion_status") == "passed"):
errors.append(f"evidence[{i}] grade E5: 파일 hash만으로는 부족하며 typed verification receipt 필요")
elif artifact_receipt:
backed = True
else:
errors.append(
f"evidence[{i}] grade {grade}: '{src}' 파일 산출 receipt 없음 "
"— 기존 파일 단순 참조만으로는 E4/E5 불가"
"(산출 시 artifact_sha256 receipt 필요).")
else:
errors.append(
f"evidence[{i}] grade {grade}: command+exit-code:0(receipt) 또는 "
"산출 아티팩트(receipt) 필요.")
if backed:
strong = True
elif gnum == 3:
# finding P0-6: E3 도 command 자기신고(exit-code:0)만으로는 '강한 근거(strong)'가
# 될 수 없다 — 실존 파일이거나, 실행 receipt로 뒷받침된 command 여야 High confidence를
# 정당화한다. receipt 없는 command 자기신고는 근거로 기록되되 strong으로 세지 않는다
# (과잉확신 가드가 걸러낸다).
if file_ok:
strong = True
elif has_cmd and cmd_ok and _cmd_receipt(receipts, e.get("command")):
strong = True
elif not (file_ok or has_cmd):
errors.append(
f"evidence[{i}] grade E3 근거 부족: 실존 파일 또는 실행 필요"
"(외부 URL은 E2 이하).")
# E0..E2: url/파일 존재는 위에서 처리(허위 파일만 차단), 강한근거로 세지 않음.
# 6) overconfidence guard
if cval == "High" and not strong:
errors.append(
"confidence:High인데 E3+ 실존근거 또는 receipt 뒷받침 실행 0개(과잉확신 차단).")
# 7) role-id 정합(미등록 → lens 판별 불가). 대소문자는 무관하게 매칭한다:
# context_package.target-role-agent 는 소문자 에이전트 카드명(arch-solution)을, validate_report 는
# 등록 role-id(ARCH-SOLUTION)를 요구하는데 이 둘은 같은 역할의 다른 표기다. case 만 달라도 거부하면
# fan-out 워커(카드명으로 spawn)가 자기 카드명을 role-id 로 써서 오탐 거부된다(P2). 등록 여부만 보고
# 표기(case)는 정규화해 대조 — 진짜 미등록(대문자로 올려도 등록 집합에 없음)은 그대로 차단.
rid = report.get("role-id")
# 7-pre) P3-B cutover: 테스트 전용 fixture 역할(TST-*) 격리. production 경로에서 쓰이면 계약 강제
# 우회로 간주 → Hard Fail. test-fixture 컨텍스트(.claude/tests 아래 or programmatic)면 허용하고
# 아래 role-id 등록 대조·method-execution 강제를 면제(fixture-scope: method-contract not-applicable).
_fixture_role = _is_fixture_role(rid)
if _fixture_role and not _in_test_fixture_context(report_path):
errors.append(
f"role-id '{rid}' 는 test-fixture 전용(TST-*) — production report 에서 금지"
"(method-contract 강제 우회 차단). 실제 역할 id 를 사용하라.")
if isinstance(rid, str) and rid.strip() and not _fixture_role:
r = rid.strip()
known = _known_role_ids()
# known 이 비어있으면(registry 판독 불가·degraded) 등록 대조 자체가 불가하므로 건너뛴다
# (그 경우 case 기반 프록시 거부는 case-무관 원칙과 모순이라 하지 않는다).
if known and r.upper() not in {k.upper() for k in known}:
errors.append(
f"role-id '{rid}' 미등록: capability-families member-role-ids에 없음 "
"— lens 판별/다양성 검증 불가(등록된 role-id 사용, 대소문자 무관).")
# 8) synthesis dissent-preservation
# 종합 판별은 명시 마커 synthesized-by로만 한다(워커의 linked-reports 인용을 오인 금지).
is_synth = ("synthesized-by" in report) or ("synthesised-by" in report)
if is_synth:
linked = report.get("linked-reports")
if not (isinstance(linked, list) and linked):
errors.append(
"종합 보고서인데 linked-reports(하위 워커 보고서 링크) 없음 "
"— 종합 근거 추적 불가(synthesis-rehydration 증명 실패).")
else:
for lr in linked:
if not _linked_exists(lr, report_path):
errors.append(
f"종합 linked-report 실존하지 않음: {lr} "
"— 없는 워커 보고서를 종합했다는 주장은 허위(추적 불가).")
conflicts = report.get("conflicts")
dissent = report.get("dissent")
if not (isinstance(conflicts, list) or isinstance(dissent, list)):
errors.append(
"종합 보고서에 conflicts/dissent가 리스트로 없음 — dissent 보존 미증명"
"(요약으로 관점 유실 차단). 이견이 없으면 conflicts: [] 로 명시(null 불가).")
# 8.5) Projection v1 is the bounded synthesis/read surface. Legacy reports without an
# explicit version remain readable, while every newly minted report opts into enforcement.
if report.get("projection-version") is not None:
if report.get("projection-version") != 1:
errors.append("projection-version은 현재 1이어야 한다.")
summary = report.get("decision-summary")
if not isinstance(summary, dict):
errors.append("projection v1: decision-summary object 필수.")
else:
if not str(summary.get("bottom-line") or "").strip():
errors.append("projection v1: decision-summary.bottom-line 비어있음.")
if not str(summary.get("recommendation") or "").strip():
errors.append("projection v1: decision-summary.recommendation 비어있음.")
if summary.get("confidence") not in {"High", "Med", "Low"}:
errors.append("projection v1: decision-summary.confidence는 High/Med/Low.")
if not isinstance(summary.get("decision-needed"), bool):
errors.append("projection v1: decision-summary.decision-needed boolean 필수.")
for field in ("evidence-index", "dissent", "open-risks", "artifact-refs"):
if not isinstance(report.get(field), list):
errors.append(f"projection v1: {field}는 list여야 한다.")
# P3-B(#11): active 계약(standard/heavy) 역할은 method-execution step-results 증명 필수.
# 테스트 전용 fixture 역할(TST-*)은 계약 강제 면제(not-applicable) — 위 7-pre 가드가 production 차단.
if _MC is not None and not _fixture_role:
try:
errors.extend(_MC.validate_method_execution(report, current_artifact=current_artifact))
except Exception as exc: # noqa: BLE001
if report.get("tier") in ("standard", "heavy"):
errors.append(f"method execution policy 평가 실패(fail-closed): {exc}")
return errors
# --------------------------------------------------------------------------- #
# CLI
# --------------------------------------------------------------------------- #
def load_report():
if len(sys.argv) > 1 and os.path.exists(sys.argv[1]):
with open(sys.argv[1]) as f:
return yaml.safe_load(f), sys.argv[1]
data = sys.stdin.read().strip()
if not data:
return None, None
try:
payload = json.loads(data)
path = payload.get("report_path")
if path and os.path.exists(path):
with open(path) as f:
return yaml.safe_load(f), path
except json.JSONDecodeError:
return yaml.safe_load(data), "<stdin>"
return None, None
def main():
if len(sys.argv) > 1 and not os.path.isfile(sys.argv[1]):
sys.stderr.write(
f"[validate_report] BLOCK {sys.argv[1]}: 명시한 report 파일이 존재하지 않습니다.\n")
sys.exit(2)
report, path = load_report()
if report is None:
sys.exit(0) # nothing to validate -> non-blocking
# C3: report_path를 넘겨 evidence-ledger(C6)를 대조한다.
real_path = path if path and path != "<stdin>" else None
errors = validate(report, report_path=real_path)
if errors:
sys.stderr.write(
f"[validate_report] BLOCK {path}:\n"
+ "\n".join(f" - {e}" for e in errors) + "\n")
sys.exit(2)
print(f"OK report valid: {path}")
sys.exit(0)
if __name__ == "__main__":
main()