166 lines
5.8 KiB
Python
166 lines
5.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Run one verifier and append a typed, context-bound evidence receipt.
|
|
|
|
Claude Code's generic PostToolUse response does not always expose a process exit
|
|
code. This sanctioned runner owns the subprocess, so exit status, assertion
|
|
status, command argv, output hashes, workflow/session/agent context, and subject
|
|
are recorded together. It never invokes a shell.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, HERE)
|
|
import _workspace as W # noqa: E402
|
|
|
|
CATEGORIES = (
|
|
"acceptance-criteria", "test", "security", "privacy", "data-quality",
|
|
"reliability", "release-readiness",
|
|
)
|
|
TRIVIAL_EXECUTABLES = {"true", "false", "echo", "printf", "ls", "cat", "grep", "pwd"}
|
|
|
|
|
|
def _now():
|
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
|
def _sha(value):
|
|
return hashlib.sha256(value).hexdigest()
|
|
|
|
|
|
def _safe_cwd(raw):
|
|
root = os.path.realpath(W.work_root())
|
|
candidate = raw if os.path.isabs(raw) else os.path.join(root, raw)
|
|
candidate = os.path.realpath(candidate)
|
|
if os.path.commonpath([root, candidate]) != root or not os.path.isdir(candidate):
|
|
raise ValueError(f"--cwd must be an existing directory inside workspace: {raw}")
|
|
return candidate
|
|
|
|
|
|
def _append_receipt(receipt):
|
|
evidence_dir = W.evidence_dir()
|
|
os.makedirs(evidence_dir, exist_ok=True)
|
|
path = os.path.join(evidence_dir, "ledger.jsonl")
|
|
with open(path, "a", encoding="utf-8") as fh:
|
|
try:
|
|
import fcntl
|
|
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
|
|
except Exception:
|
|
pass
|
|
fh.write(json.dumps(receipt, ensure_ascii=False) + "\n")
|
|
fh.flush()
|
|
os.fsync(fh.fileno())
|
|
try:
|
|
import fcntl
|
|
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def build_parser():
|
|
parser = argparse.ArgumentParser(description="execute a verifier and mint a typed receipt")
|
|
parser.add_argument("--workflow", required=True)
|
|
parser.add_argument("--agent", required=True)
|
|
parser.add_argument("--session", required=True)
|
|
parser.add_argument("--category", choices=CATEGORIES, required=True)
|
|
parser.add_argument("--subject", required=True,
|
|
help="specific criterion/component being verified")
|
|
parser.add_argument("--source-revision-sha256")
|
|
parser.add_argument("--cwd", default=".")
|
|
parser.add_argument("command", nargs=argparse.REMAINDER)
|
|
return parser
|
|
|
|
|
|
def main(argv=None):
|
|
args = build_parser().parse_args(argv)
|
|
command = list(args.command)
|
|
if command and command[0] == "--":
|
|
command = command[1:]
|
|
if not command:
|
|
sys.stderr.write("[verify_run] verifier command required after --\n")
|
|
return 2
|
|
executable = os.path.basename(command[0]).lower()
|
|
if executable in TRIVIAL_EXECUTABLES:
|
|
sys.stderr.write(f"[verify_run] trivial command cannot prove verification: {executable}\n")
|
|
return 2
|
|
if executable in {"sh", "bash", "zsh", "fish"}:
|
|
sys.stderr.write("[verify_run] shell interpreters are forbidden; pass verifier argv directly\n")
|
|
return 2
|
|
if args.source_revision_sha256 and (
|
|
len(args.source_revision_sha256) != 64
|
|
or any(ch not in "0123456789abcdef" for ch in args.source_revision_sha256.lower())):
|
|
sys.stderr.write("[verify_run] --source-revision-sha256 must be 64-hex\n")
|
|
return 2
|
|
try:
|
|
cwd = _safe_cwd(args.cwd)
|
|
except Exception as exc:
|
|
sys.stderr.write(f"[verify_run] {exc}\n")
|
|
return 2
|
|
|
|
started_at = _now()
|
|
started = time.monotonic()
|
|
try:
|
|
completed = subprocess.run(command, cwd=cwd, capture_output=True, check=False)
|
|
exit_code = completed.returncode
|
|
stdout = completed.stdout or b""
|
|
stderr = completed.stderr or b""
|
|
except OSError as exc:
|
|
exit_code, stdout, stderr = 127, b"", str(exc).encode("utf-8", "replace")
|
|
finished_at = _now()
|
|
receipt_id = f"vr-{int(time.time())}-{uuid.uuid4().hex[:12]}"
|
|
receipt = {
|
|
"receipt_id": receipt_id,
|
|
"tool_use_id": receipt_id,
|
|
"receipt_type": "verification-run",
|
|
"tool_name": "VerifyRun",
|
|
"workflow_id": args.workflow,
|
|
"session_id": args.session,
|
|
"agent_id": args.agent,
|
|
"verification_category": args.category,
|
|
"verification_subject": args.subject,
|
|
"assertion_status": "passed" if exit_code == 0 else "failed",
|
|
"exit_code": exit_code,
|
|
"command_argv": command,
|
|
"command_argv_sha256": _sha(
|
|
json.dumps(command, ensure_ascii=False, separators=(",", ":")).encode("utf-8")),
|
|
"cwd": cwd,
|
|
"started_at": started_at,
|
|
"ts": finished_at,
|
|
"duration_ms": round((time.monotonic() - started) * 1000),
|
|
"stdout_sha256": _sha(stdout),
|
|
"stderr_sha256": _sha(stderr),
|
|
}
|
|
if args.source_revision_sha256:
|
|
receipt["source_revision_sha256"] = args.source_revision_sha256.lower()
|
|
try:
|
|
_append_receipt(receipt)
|
|
except Exception as exc:
|
|
sys.stderr.write(f"[verify_run] receipt append failed: {exc}\n")
|
|
return 125
|
|
|
|
if stdout:
|
|
sys.stdout.buffer.write(stdout)
|
|
if not stdout.endswith(b"\n"):
|
|
sys.stdout.buffer.write(b"\n")
|
|
if stderr:
|
|
sys.stderr.buffer.write(stderr)
|
|
if not stderr.endswith(b"\n"):
|
|
sys.stderr.buffer.write(b"\n")
|
|
sys.stderr.write(
|
|
f"[verify_run] receipt-id={receipt_id} status={receipt['assertion_status']} "
|
|
f"exit={exit_code} category={args.category} subject={args.subject}\n")
|
|
return exit_code if 0 <= exit_code <= 124 else 124
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|