#!/usr/bin/env python3 """Fail-closed verifier for machine-readable quote proof manifests. The verifier never executes the recorded argv. It validates a captured execution record against repository source bytes. Disk output is opt-in via ``--output``. """ from __future__ import annotations import argparse import hashlib import json import os from pathlib import Path import re import sys import tempfile from typing import Any, Iterable, Mapping SCHEMA_VERSION = "proof-manifest/v1" RESULT_SCHEMA_VERSION = "proof-manifest-result/v1" DEFAULT_REPO_ROOT = Path(__file__).resolve().parents[2] DEFAULT_PROFILES = DEFAULT_REPO_ROOT / "harness/source/execution-profiles.json" HEX_SHA256 = re.compile(r"^[0-9a-f]{64}$") SAFE_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") SAFE_ROLE = re.compile(r"^[a-z][a-z0-9_-]*$") class ManifestValidationError(Exception): """Raised when one or more fail-closed proof checks fail.""" def __init__(self, issues: Iterable[Mapping[str, str]]) -> None: self.issues = [dict(issue) for issue in issues] super().__init__(f"proof manifest validation failed ({len(self.issues)} issue(s))") def _issue(issues: list[dict[str, str]], code: str, location: str, message: str) -> None: issues.append({"code": code, "location": location, "message": message}) def _is_int(value: Any) -> bool: return isinstance(value, int) and not isinstance(value, bool) def _require_object( value: Any, location: str, required: set[str], optional: set[str], issues: list[dict[str, str]], ) -> Mapping[str, Any] | None: if not isinstance(value, dict): _issue(issues, "INVALID_TYPE", location, "must be a JSON object") return None keys = set(value) for missing in sorted(required - keys): _issue(issues, "MISSING_FIELD", f"{location}.{missing}", "required field is missing") for unknown in sorted(keys - required - optional): _issue(issues, "UNKNOWN_FIELD", f"{location}.{unknown}", "unknown field is not allowed") return value def _load_json(path: Path) -> Any: with path.open("r", encoding="utf-8") as stream: return json.load(stream) def load_allowed_profiles(path: Path) -> set[str]: try: document = _load_json(path) except (OSError, UnicodeError, json.JSONDecodeError) as exc: raise ManifestValidationError( [{"code": "PROFILE_SOURCE_ERROR", "location": str(path), "message": str(exc)}] ) from exc if not isinstance(document, dict) or document.get("schema_version") != "execution-profiles/v1": raise ManifestValidationError( [ { "code": "PROFILE_SOURCE_SCHEMA", "location": str(path), "message": "expected execution-profiles/v1", } ] ) profiles = document.get("profiles") if not isinstance(profiles, dict) or not profiles: raise ManifestValidationError( [{"code": "PROFILE_SOURCE_SCHEMA", "location": str(path), "message": "profiles must be a non-empty object"}] ) return set(profiles) def _resolve_source(root: Path, relative_path: str) -> Path | None: candidate = Path(relative_path) if candidate.is_absolute(): return None resolved = (root / candidate).resolve() try: resolved.relative_to(root) except ValueError: return None return resolved def _validate_proof( proof: Any, index: int, repo_root: Path, run_root: Path | None, seen_finding_roles: set[tuple[str, str]], issues: list[dict[str, str]], ) -> None: base = f"proofs[{index}]" proof_obj = _require_object(proof, base, {"finding", "source", "execution"}, set(), issues) if proof_obj is None: return finding = _require_object(proof_obj.get("finding"), f"{base}.finding", {"id", "role"}, set(), issues) source = _require_object( proof_obj.get("source"), f"{base}.source", {"path", "sha256", "line_start", "line_end", "quote_utf8"}, {"namespace"}, issues, ) execution = _require_object( proof_obj.get("execution"), f"{base}.execution", {"argv", "exit_code", "stdout_utf8", "stdout_sha256", "exact_match"}, set(), issues, ) finding_id: str | None = None role: str | None = None if finding is not None: finding_id_value = finding.get("id") role_value = finding.get("role") if not isinstance(finding_id_value, str) or not SAFE_IDENTIFIER.fullmatch(finding_id_value): _issue(issues, "INVALID_FINDING_ID", f"{base}.finding.id", "must match [A-Za-z0-9][A-Za-z0-9._-]*") else: finding_id = finding_id_value if not isinstance(role_value, str) or not SAFE_ROLE.fullmatch(role_value): _issue(issues, "INVALID_FINDING_ROLE", f"{base}.finding.role", "must be a lowercase role identifier") else: role = role_value if finding_id is not None and role is not None: key = (finding_id, role) if key in seen_finding_roles: _issue(issues, "DUPLICATE_FINDING_ROLE", f"{base}.finding", f"duplicate pair: {finding_id}/{role}") else: seen_finding_roles.add(key) quote_utf8: str | None = None source_bytes: bytes | None = None selected_bytes: bytes | None = None if source is not None: namespace = source.get("namespace", "repo") relative_path = source.get("path") expected_sha256 = source.get("sha256") line_start = source.get("line_start") line_end = source.get("line_end") quote_value = source.get("quote_utf8") if namespace not in {"repo", "run"}: _issue(issues, "INVALID_SOURCE_NAMESPACE", f"{base}.source.namespace", "must be repo or run") source_root = None elif namespace == "run" and run_root is None: _issue(issues, "RUN_ROOT_REQUIRED", f"{base}.source.namespace", "run namespace requires a run root") source_root = None else: source_root = repo_root if namespace == "repo" else run_root if not isinstance(relative_path, str) or not relative_path or "\\" in relative_path: _issue(issues, "INVALID_SOURCE_PATH", f"{base}.source.path", "must be a non-empty repo-relative POSIX path") resolved_source = None elif source_root is None: resolved_source = None else: resolved_source = _resolve_source(source_root, relative_path) if resolved_source is None: _issue(issues, "SOURCE_OUTSIDE_NAMESPACE", f"{base}.source.path", f"path escapes the {namespace} root") elif not resolved_source.is_file(): _issue(issues, "SOURCE_NOT_FOUND", f"{base}.source.path", "source file does not exist") else: try: source_bytes = resolved_source.read_bytes() except OSError as exc: _issue(issues, "SOURCE_READ_ERROR", f"{base}.source.path", str(exc)) if not isinstance(expected_sha256, str) or not HEX_SHA256.fullmatch(expected_sha256): _issue(issues, "INVALID_SOURCE_SHA256", f"{base}.source.sha256", "must be 64 lowercase hexadecimal characters") elif source_bytes is not None: actual_source_sha256 = hashlib.sha256(source_bytes).hexdigest() if actual_source_sha256 != expected_sha256: _issue( issues, "SOURCE_HASH_MISMATCH", f"{base}.source.sha256", f"expected {expected_sha256}, observed {actual_source_sha256}", ) valid_range = True if not _is_int(line_start) or line_start < 1: _issue(issues, "INVALID_LINE_RANGE", f"{base}.source.line_start", "must be an integer >= 1") valid_range = False if not _is_int(line_end) or (_is_int(line_start) and line_end < line_start): _issue(issues, "INVALID_LINE_RANGE", f"{base}.source.line_end", "must be an integer >= line_start") valid_range = False if not isinstance(quote_value, str) or not quote_value: _issue(issues, "INVALID_QUOTE", f"{base}.source.quote_utf8", "must be a non-empty UTF-8 string") else: quote_utf8 = quote_value if source_bytes is not None: try: source_bytes.decode("utf-8", errors="strict") except UnicodeDecodeError as exc: _issue(issues, "SOURCE_NOT_UTF8", f"{base}.source.path", str(exc)) source_bytes = None if source_bytes is not None and valid_range: source_lines = source_bytes.splitlines(keepends=True) if line_end > len(source_lines): _issue( issues, "LINE_RANGE_OUT_OF_BOUNDS", f"{base}.source.line_end", f"source has {len(source_lines)} line(s)", ) else: selected_bytes = b"".join(source_lines[line_start - 1 : line_end]) if quote_utf8 is not None and quote_utf8.encode("utf-8") not in selected_bytes: _issue( issues, "QUOTE_MISMATCH", f"{base}.source.quote_utf8", "exact quote bytes were not found inside the declared line range", ) if execution is not None: argv = execution.get("argv") exit_code = execution.get("exit_code") stdout_utf8 = execution.get("stdout_utf8") stdout_sha256 = execution.get("stdout_sha256") exact_match = execution.get("exact_match") if ( not isinstance(argv, list) or not argv or any(not isinstance(arg, str) or not arg for arg in argv) ): _issue(issues, "INVALID_ARGV", f"{base}.execution.argv", "must be a non-empty array of non-empty strings") if not _is_int(exit_code): _issue(issues, "INVALID_EXIT_CODE", f"{base}.execution.exit_code", "must be an integer") elif exit_code != 0: _issue(issues, "COMMAND_FAILED", f"{base}.execution.exit_code", "recorded verification command did not exit 0") if not isinstance(stdout_utf8, str): _issue(issues, "INVALID_STDOUT", f"{base}.execution.stdout_utf8", "must be a UTF-8 string") if not isinstance(stdout_sha256, str) or not HEX_SHA256.fullmatch(stdout_sha256): _issue(issues, "INVALID_STDOUT_SHA256", f"{base}.execution.stdout_sha256", "must be 64 lowercase hexadecimal characters") elif isinstance(stdout_utf8, str): actual_stdout_sha256 = hashlib.sha256(stdout_utf8.encode("utf-8")).hexdigest() if actual_stdout_sha256 != stdout_sha256: _issue( issues, "STDOUT_HASH_MISMATCH", f"{base}.execution.stdout_sha256", f"expected {stdout_sha256}, observed {actual_stdout_sha256}", ) if not isinstance(exact_match, bool): _issue(issues, "INVALID_EXACT_MATCH", f"{base}.execution.exact_match", "must be a boolean") elif not exact_match: _issue(issues, "EXACT_MATCH_FALSE", f"{base}.execution.exact_match", "proof cannot pass with exact_match=false") if isinstance(stdout_utf8, str) and quote_utf8 is not None and stdout_utf8 != quote_utf8: _issue( issues, "STDOUT_QUOTE_MISMATCH", f"{base}.execution.stdout_utf8", "recorded stdout is not byte-for-byte equal to quote_utf8", ) def verify_manifest( manifest: Any, repo_root: Path, allowed_profiles: set[str], *, run_root: Path | None = None, ) -> dict[str, Any]: issues: list[dict[str, str]] = [] root = _require_object(manifest, "$", {"schema_version", "run", "proofs"}, {"verification"}, issues) if root is None: raise ManifestValidationError(issues) if root.get("schema_version") != SCHEMA_VERSION: _issue(issues, "SCHEMA_VERSION_MISMATCH", "$.schema_version", f"expected {SCHEMA_VERSION}") run = _require_object(root.get("run"), "$.run", {"id", "profile"}, set(), issues) if run is not None: run_id = run.get("id") profile = run.get("profile") if not isinstance(run_id, str) or not SAFE_IDENTIFIER.fullmatch(run_id): _issue(issues, "INVALID_RUN_ID", "$.run.id", "must match [A-Za-z0-9][A-Za-z0-9._-]*") if not isinstance(profile, str) or profile not in allowed_profiles: _issue(issues, "INVALID_PROFILE", "$.run.profile", f"must be one of {sorted(allowed_profiles)}") proofs = root.get("proofs") if not isinstance(proofs, list) or not proofs: _issue(issues, "INVALID_PROOFS", "$.proofs", "must be a non-empty array") else: seen_finding_roles: set[tuple[str, str]] = set() for index, proof in enumerate(proofs): _validate_proof(proof, index, repo_root, run_root, seen_finding_roles, issues) if issues: raise ManifestValidationError(issues) verified = dict(root) verified["verification"] = { "schema_version": RESULT_SCHEMA_VERSION, "status": "PASS", "proof_count": len(proofs), "pass_count": len(proofs), "fail_count": 0, } return verified def manifest_bytes(document: Mapping[str, Any]) -> bytes: """Return the canonical bytes used for persisted manifest hashing.""" return (json.dumps(document, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8") def _failure_document(exc: ManifestValidationError) -> dict[str, Any]: return { "schema_version": RESULT_SCHEMA_VERSION, "status": "FAIL", "errors": exc.issues, } def _atomic_write_json(path: Path, document: Mapping[str, Any]) -> None: # ``os.replace`` 는 대상 심링크를 *따라가지 않고* 그 자리를 실파일로 갈아치운다. # cutover 이후 raw/·wiki/ 경로는 vault 정본을 가리키는 심링크이므로, 그런 경로를 # 그대로 받으면 링크가 끊겨 정본과 분리된다(split-brain, 그리고 조용하다). 심링크면 # 정본 경로에 write-through 해 링크를 보존한다. target = Path(os.path.abspath(path.resolve())) if path.is_symlink() else path parent = target.parent.resolve() if not parent.is_dir(): raise OSError(f"output parent directory does not exist: {parent}") temporary_name: str | None = None try: with tempfile.NamedTemporaryFile( mode="w", encoding="utf-8", dir=parent, prefix=f".{target.name}.", suffix=".tmp", delete=False, ) as stream: temporary_name = stream.name stream.write(manifest_bytes(document).decode("utf-8")) stream.flush() os.fsync(stream.fileno()) os.replace(temporary_name, target) except Exception: if temporary_name is not None: try: os.unlink(temporary_name) except FileNotFoundError: pass raise def _emit(document: Mapping[str, Any]) -> None: json.dump(document, sys.stdout, ensure_ascii=False, indent=2, sort_keys=True) sys.stdout.write("\n") def _parse_args(argv: list[str] | None) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("manifest", type=Path, help="input proof manifest JSON") parser.add_argument("--repo-root", type=Path, default=DEFAULT_REPO_ROOT, help="source path root") parser.add_argument("--run-root", type=Path, help="source root for source.namespace=run") parser.add_argument("--profiles", type=Path, default=DEFAULT_PROFILES, help="execution profile JSON source") parser.add_argument("--output", type=Path, help="atomically write the verified manifest; omitted by default") return parser.parse_args(argv) def main(argv: list[str] | None = None) -> int: args = _parse_args(argv) try: repo_root = args.repo_root.resolve(strict=True) if not repo_root.is_dir(): raise OSError(f"repo root is not a directory: {repo_root}") allowed_profiles = load_allowed_profiles(args.profiles.resolve(strict=True)) manifest = _load_json(args.manifest) run_root = args.run_root.resolve(strict=True) if args.run_root is not None else None if run_root is not None and not run_root.is_dir(): raise OSError(f"run root is not a directory: {run_root}") verified = verify_manifest(manifest, repo_root, allowed_profiles, run_root=run_root) if args.output is not None: _atomic_write_json(args.output, verified) _emit(verified) return 0 except ManifestValidationError as exc: _emit(_failure_document(exc)) return 2 except (OSError, UnicodeError, json.JSONDecodeError) as exc: failure = ManifestValidationError( [{"code": "IO_OR_JSON_ERROR", "location": str(args.manifest), "message": str(exc)}] ) _emit(_failure_document(failure)) return 2 if __name__ == "__main__": raise SystemExit(main())