774 lines
37 KiB
Python
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()
|