[verified] Bound evidence commands and reap process groups

This commit is contained in:
2026-10-11 07:56:25 +00:00
parent 6cb2317667
commit 18222f2ca8
9 changed files with 630 additions and 50 deletions
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""Create and validate bounded, fail-closed evidence manifests."""
from __future__ import annotations
import argparse, datetime as dt, grp, hashlib, json, math, os, platform, pwd, re, shutil, stat, subprocess, sys, tempfile, time
import argparse, datetime as dt, grp, hashlib, json, math, os, platform, pwd, re, selectors, shutil, signal, stat, subprocess, sys, tempfile, time
from pathlib import Path
HEX40 = re.compile(r"^[0-9a-f]{40}$")
@@ -18,6 +18,7 @@ SECRET_PATTERNS = [
]
SAFE_CHILD_ENV = ("PATH", "HOME", "TMPDIR", "TMP", "TEMP", "LANG", "LC_ALL", "VIRTUAL_ENV", "UV_CACHE_DIR", "PYTHONDONTWRITEBYTECODE")
SHELL_NAMES = {"sh", "bash", "dash", "zsh", "ksh", "fish", "cmd", "cmd.exe", "powershell", "powershell.exe", "pwsh", "pwsh.exe"}
MAX_OUTPUT_BYTES = 1 << 20
def die(msg):
@@ -140,6 +141,35 @@ def source_file(root, value):
def canonical(obj): return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + "\n"
def text_bytes(b): return b.decode("utf-8", "replace")
def bounded_text(b, limit):
# Linear, including all-invalid UTF-8. Repeated slicing/reencoding would
# turn a 1 MiB invalid-byte stream into unbounded quadratic postprocessing.
return text_bytes(b).encode("utf-8")[:limit].decode("utf-8", "ignore")
def sensitive_boundary(text, secrets):
"""Reject a captured suffix that is a known secret prefix (linear KMP)."""
for secret in secrets:
if not secret or not text:
continue
prefix = [0] * len(secret)
for i in range(1, len(secret)):
j = prefix[i-1]
while j and secret[i] != secret[j]:
j = prefix[j-1]
if secret[i] == secret[j]:
j += 1
prefix[i] = j
matched = 0
for char in text[-len(secret):]:
while matched and (matched == len(secret) or secret[matched] != char):
matched = prefix[matched-1]
if matched < len(secret) and secret[matched] == char:
matched += 1
if matched:
return True
return False
def scan_sensitive(text, secrets, forbidden):
for s in secrets:
@@ -161,24 +191,143 @@ def validate_argv(argv):
if executable in SHELL_NAMES or any(x.lower() in ("shell", "shell=true") for x in argv): raise ValueError("shell commands are forbidden")
def run_bounded(argv, root, env, max_bytes):
proc=subprocess.Popen(argv,cwd=root,stdout=subprocess.PIPE,stderr=subprocess.STDOUT,shell=False,env=env,bufsize=0)
stream=proc.stdout
if stream is None: raise ValueError("command output pipe unavailable")
data=bytearray()
def valid_timeout(value):
# Range comparisons reject NaN/Infinity without converting huge integers.
return isinstance(value, (int, float)) and not isinstance(value, bool) and 0 < value <= 3600
def _group_has_live_members(pgid):
"""Linux-only liveness: orphan zombies are not running descendants.
This is cleanup, not a sandbox: a process that changes session/group escapes.
Failure to inspect an extant group is a cleanup failure, never success.
"""
try:
while True:
chunk=stream.read(min(65536,max_bytes-len(data)+1))
if not chunk: break
data.extend(chunk)
if len(data)>max_bytes:
proc.kill(); proc.wait()
raise ValueError("command output exceeds max_output_bytes")
return proc.wait(),bytes(data)
os.killpg(pgid, 0)
except ProcessLookupError:
return False
for entry in Path("/proc").iterdir():
if not entry.name.isdecimal():
continue
try:
fields = (entry / "stat").read_text(encoding="utf-8", errors="replace").rsplit(")", 1)[1].split()
except (FileNotFoundError, ProcessLookupError):
continue
if int(fields[2]) == pgid and fields[0] not in ("Z", "X"):
return True
return False
def _cleanup_process_group(proc):
record = {"term_sent": False, "kill_sent": False,
"direct_child_reaped": False, "group_cleanup_completed": False}
try:
if _group_has_live_members(proc.pid):
try:
os.killpg(proc.pid, signal.SIGTERM)
record["term_sent"] = True
except ProcessLookupError:
pass
grace = time.monotonic() + 0.25
while _group_has_live_members(proc.pid) and time.monotonic() < grace:
time.sleep(min(0.01, max(0, grace - time.monotonic())))
if _group_has_live_members(proc.pid):
try:
os.killpg(proc.pid, signal.SIGKILL)
record["kill_sent"] = True
except ProcessLookupError:
pass
reap_deadline = time.monotonic() + 0.5
while _group_has_live_members(proc.pid) and time.monotonic() < reap_deadline:
time.sleep(min(0.01, max(0, reap_deadline - time.monotonic())))
record["group_cleanup_completed"] = not _group_has_live_members(proc.pid)
proc.wait(timeout=max(0, reap_deadline - time.monotonic()))
record["direct_child_reaped"] = True
if not record["group_cleanup_completed"]:
return record, ValueError("process group cleanup incomplete")
return record, None
except (OSError, subprocess.TimeoutExpired, ValueError, IndexError) as exc:
# Even inspection failure must attempt group termination, including when
# the leader already exited. Never use leader.poll() to skip signals.
try:
os.killpg(proc.pid, signal.SIGKILL)
record["kill_sent"] = True
except OSError:
pass
try:
proc.wait(timeout=0.1)
record["direct_child_reaped"] = True
except (OSError, subprocess.TimeoutExpired):
pass
return record, exc
def run_bounded(argv, root, env, max_bytes, timeout_seconds):
validate_argv(argv)
if not valid_timeout(timeout_seconds):
raise ValueError("invalid timeout_seconds")
if type(max_bytes) is not int or not 0 <= max_bytes <= MAX_OUTPUT_BYTES:
raise ValueError("invalid command bounds")
deadline = time.monotonic() + timeout_seconds
proc = subprocess.Popen(argv, cwd=root, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL, shell=False, env=env, bufsize=0,
start_new_session=True)
stream = proc.stdout
sel = None
data = bytearray()
status = "completed"
try:
if stream is None:
raise ValueError("command output pipe unavailable")
os.set_blocking(stream.fileno(), False)
sel = selectors.DefaultSelector()
sel.register(stream, selectors.EVENT_READ)
pipe_open = True
while pipe_open:
remaining = deadline - time.monotonic()
if remaining <= 0:
status = "timeout"
break
ready = sel.select(remaining)
if not ready:
status = "timeout"
break
try:
chunk = os.read(stream.fileno(), min(65536, max_bytes - len(data) + 1))
except BlockingIOError:
continue
if not chunk:
sel.unregister(stream)
pipe_open = False
break
take = min(len(chunk), max_bytes - len(data))
data.extend(chunk[:take])
if len(chunk) > take:
status = "output_limit"
break
if status == "completed":
try:
proc.wait(timeout=max(0, deadline - time.monotonic()))
except subprocess.TimeoutExpired:
status = "timeout"
# Cleanup all same-group descendants even after normal leader exit.
termination, cleanup_error = _cleanup_process_group(proc)
if cleanup_error is not None:
status = "cleanup_failed"
elif status == "completed" and (termination["term_sent"] or termination["kill_sent"]):
# A zero-exit leader with unfinished descendants is not a completed
# verification command merely because cleanup successfully killed them.
status = "descendant_cleanup"
return {"status": status, "output": bytes(data), "exit_code": proc.returncode,
"termination": termination}
except BaseException:
_cleanup_process_group(proc)
raise
finally:
stream.close()
if proc.poll() is None:
proc.kill(); proc.wait()
if sel is not None:
sel.close()
if stream is not None:
stream.close()
def status_paths(root, *, include_ignored=True):
@@ -199,7 +348,7 @@ def check_clean(root, allowed, *, include_ignored=True):
if bad: raise ValueError("source tree is not clean: " + ", ".join(bad))
def plan_checks(plan, root):
def plan_checks(plan, root, *, require_timeout=True):
if not isinstance(plan, dict): raise ValueError("plan must be an object")
for key in ("hash_paths", "commands", "limitations", "secret_env_names", "forbidden_regexes", "command_env_allowlist"):
if key in plan and not isinstance(plan[key], list): raise ValueError(f"{key} must be a list")
@@ -221,14 +370,16 @@ def plan_checks(plan, root):
if not isinstance(spec, dict): raise ValueError("command must be object")
argv=spec.get("argv"); validate_argv(argv)
expected=spec.get("expected_exit_code",0); maxb=spec.get("max_output_bytes",65536)
if not isinstance(expected,int) or isinstance(expected,bool) or not isinstance(maxb,int) or maxb < 0: raise ValueError("invalid command bounds")
timeout=spec.get("timeout_seconds")
if require_timeout and not valid_timeout(timeout): raise ValueError("invalid timeout_seconds")
if not isinstance(expected,int) or isinstance(expected,bool) or not isinstance(maxb,int) or isinstance(maxb,bool) or maxb < 0 or (require_timeout and maxb > MAX_OUTPUT_BYTES): raise ValueError("invalid command bounds")
claims=spec.get("claims",[])
if not isinstance(claims,list) or not claims or any(not isinstance(x,str) or not ID.fullmatch(x) for x in claims): raise ValueError("claims must be a nonempty list of valid IDs")
if any(x in seen for x in claims): raise ValueError("duplicate claim ID")
command_id=spec.get("id",f"command-{index}"); purpose=spec.get("purpose","")
if not isinstance(command_id,str) or not ID.fullmatch(command_id) or command_id in command_ids: raise ValueError("invalid or duplicate command ID")
if not isinstance(purpose,str): raise ValueError("command purpose must be a string")
command_ids.add(command_id); seen.update(claims); commands.append((spec,argv,expected,maxb,claims))
command_ids.add(command_id); seen.update(claims); commands.append((spec,argv,expected,maxb,claims,timeout))
return secrets, forbidden, requested, child_names, commands
@@ -261,7 +412,7 @@ def run(args):
if not out.parent.is_dir(): raise ValueError("output parent must already exist")
plan_bytes=plan_path.read_bytes(); plan_sha256=hashlib.sha256(plan_bytes).hexdigest()
plan=json.loads(plan_bytes.decode("utf-8")); secrets,forbidden,env_keys,child_names,commandspec=plan_checks(plan,root)
for _,argv,_,_,_ in commandspec:
for _,argv,_,_,_,_ in commandspec:
violation=scan_sensitive("\n".join(argv),secrets,forbidden)
if violation: raise ValueError("sensitive command rejected before execution: "+violation)
check_clean(root,set())
@@ -276,12 +427,16 @@ def run(args):
files.append({"path":rel,"sha256":sha,"blob_oid":oid})
if plan_rel==out_rel: raise ValueError("plan/output paths must not overlap")
child_env={name:os.environ[name] for name in child_names if name in os.environ}
result_commands=[]; claim_map={}
for i,(spec,argv,expected,maxb,claim_ids) in enumerate(commandspec):
started=utc_now(); t=time.monotonic(); returncode,raw=run_bounded(argv,root,child_env,maxb); finished=utc_now()
output=text_bytes(raw); violation=scan_sensitive("\n".join(argv)+"\n"+output,secrets,forbidden)
result_commands=[]; claim_map={}; infrastructure_failure=False
for i,(spec,argv,expected,maxb,claim_ids,timeout) in enumerate(commandspec):
started=utc_now(); t=time.monotonic(); outcome=run_bounded(argv,root,child_env,maxb,timeout); finished=utc_now()
raw_output=outcome["output"]; full_output=text_bytes(raw_output)
violation=scan_sensitive("\n".join(argv)+"\n"+full_output,secrets,forbidden)
if not violation and outcome["status"] != "completed" and sensitive_boundary(full_output, secrets):
violation = "secret boundary"
if violation: raise ValueError("sensitive evidence rejected: "+violation)
c={"id":spec.get("id",f"command-{i}"),"purpose":spec.get("purpose",""),"argv":argv,"exit_code":returncode,"expected_exit_code":expected,"max_output_bytes":maxb,"output":output,"started_at_utc":started,"finished_at_utc":finished,"duration_seconds":time.monotonic()-t,"claim_ids":claim_ids}
output=bounded_text(raw_output,maxb)
c={"id":spec.get("id",f"command-{i}"),"purpose":spec.get("purpose",""),"argv":argv,"exit_code":outcome["exit_code"],"expected_exit_code":expected,"max_output_bytes":maxb,"timeout_seconds":timeout,"execution_status":outcome["status"],"termination":outcome["termination"],"output":output,"started_at_utc":started,"finished_at_utc":finished,"duration_seconds":time.monotonic()-t,"claim_ids":claim_ids}
result_commands.append(c)
for cid in claim_ids:
claim_map[cid]=[
@@ -291,7 +446,13 @@ def run(args):
f"/commands/{i}/output",
f"/commands/{i}/started_at_utc",
f"/commands/{i}/finished_at_utc",
f"/commands/{i}/timeout_seconds",
f"/commands/{i}/execution_status",
f"/commands/{i}/termination",
]
if outcome["status"] != "completed" or not outcome["termination"]["direct_child_reaped"] or not outcome["termination"]["group_cleanup_completed"]:
infrastructure_failure=True
break
env: dict[str, str | None]={k:None for k in env_keys}
if "os" in env: env["os"]=platform.system()
if "kernel" in env: env["kernel"]=platform.release()
@@ -312,8 +473,8 @@ def run(args):
path,_=source_file(root,record["path"])
if hashlib.sha256(path.read_bytes()).hexdigest()!=record["sha256"]:
raise ValueError(f"source changed during verification: {record['path']}")
success=all(c["exit_code"]==c["expected_exit_code"] for c in result_commands)
manifest={"schema":"evidence-manifest/v2","source_revision":source,"source_tree_clean":True,"artifact":identity,"plan":{"path":plan_rel,"sha256":plan_sha256},"generated_at":utc_now(),"environment":env,"commands":result_commands,"files":files,"claims":claim_map,"redaction":{"checked":True,"violations":[]},"limitations":plan.get("limitations",[]),"result":"success" if success else "failure"}
success=not infrastructure_failure and len(result_commands)==len(commandspec) and all(c["execution_status"]=="completed" and c["exit_code"]==c["expected_exit_code"] for c in result_commands)
manifest={"schema":"evidence-manifest/v3","source_revision":source,"source_tree_clean":True,"artifact":identity,"plan":{"path":plan_rel,"sha256":plan_sha256},"generated_at":utc_now(),"environment":env,"commands":result_commands,"files":files,"claims":claim_map,"redaction":{"checked":True,"violations":[]},"limitations":plan.get("limitations",[]),"result":"success" if success else "failure"}
atomic_write(out,canonical(manifest))
return 0 if success else 1
@@ -331,6 +492,7 @@ def pointer(obj,ptr):
def validate(args):
legacy = args.op == "validate-legacy"
archived = args.op == "validate-archived-v2"
identity = None
if legacy:
root=Path(args.repo_root).resolve(); artifact=root; mp,mp_rel=inside(root,args.manifest)
@@ -339,7 +501,8 @@ def validate(args):
raw=mp.read_text(encoding="utf-8"); obj=json.loads(raw)
if canonical(obj)!=raw: raise ValueError("manifest is not canonical JSON")
required={"schema","source_revision","source_tree_clean","plan","generated_at","commands","files","claims","redaction","environment","limitations","result"} | ({"excluded_paths"} if legacy else {"artifact"})
if set(obj)!=required or obj["schema"]!=("evidence-manifest/v1" if legacy else "evidence-manifest/v2") or obj["result"] not in ("success","failure"): raise ValueError("invalid manifest structure")
expected_schema = "evidence-manifest/v1" if legacy else ("evidence-manifest/v2" if archived else "evidence-manifest/v3")
if set(obj)!=required or obj["schema"]!=expected_schema or obj["result"] not in ("success","failure"): raise ValueError("invalid manifest structure")
if obj["source_tree_clean"] is not True: raise ValueError("source tree was not clean")
if not isinstance(obj["generated_at"],str) or not ISO.fullmatch(obj["generated_at"]): raise ValueError("invalid generated timestamp")
if legacy:
@@ -357,7 +520,8 @@ def validate(args):
if plan_path.suffix.lower()!=".json" or not plan_path.is_file() or plan_path.is_symlink(): raise ValueError("bound plan is unavailable")
plan_bytes=plan_path.read_bytes()
if hashlib.sha256(plan_bytes).hexdigest()!=obj["plan"]["sha256"]: raise ValueError("plan hash mismatch")
plan=json.loads(plan_bytes.decode("utf-8")); plan_secrets,plan_forbidden,requested_env,_,planned_commands=plan_checks(plan,root)
archive_shape = legacy or archived
plan=json.loads(plan_bytes.decode("utf-8")); plan_secrets,plan_forbidden,requested_env,_,planned_commands=plan_checks(plan,root,require_timeout=not archive_shape)
check_clean(root,set(obj["excluded_paths"]) if legacy else set(),include_ignored=not legacy)
src=obj["source_revision"]
if not isinstance(src,str) or not HEX40.fullmatch(src): raise ValueError("invalid source revision")
@@ -371,7 +535,7 @@ def validate(args):
if obj["redaction"]!={"checked":True,"violations":[]}: raise ValueError("redaction failed")
if not isinstance(obj["claims"],dict) or any(
not isinstance(k,str) or not ID.fullmatch(k) or not isinstance(v,list)
or len(v)!=6 or any(not isinstance(ptr,str) for ptr in v)
or len(v)!=(6 if archive_shape else 9) or any(not isinstance(ptr,str) for ptr in v)
for k,v in obj["claims"].items()
): raise ValueError("invalid claims")
if not isinstance(obj["files"],list) or not obj["files"]: raise ValueError("files must be nonempty")
@@ -386,20 +550,48 @@ def validate(args):
oid,sha=tracked_blob(root,src,rel)
if oid!=f["blob_oid"] or sha!=f["sha256"] or hashlib.sha256(path.read_bytes()).hexdigest()!=sha: raise ValueError("file hash mismatch")
if legacy and any(f["path"] in set(obj["excluded_paths"]) for f in obj["files"]): raise ValueError("evidence paths overlap bound source paths")
if not isinstance(obj["commands"],list) or not obj["commands"] or len(obj["commands"])!=len(planned_commands): raise ValueError("commands do not match plan")
if not isinstance(obj["commands"],list) or not obj["commands"] or len(obj["commands"]) > len(planned_commands): raise ValueError("commands do not match plan")
if archive_shape and len(obj["commands"]) != len(planned_commands): raise ValueError("commands do not match plan")
if not archive_shape and len(obj["commands"]) < len(planned_commands):
final = obj["commands"][-1]
if obj["result"] != "failure" or not isinstance(final, dict) or final.get("execution_status") not in ("timeout", "output_limit", "cleanup_failed", "descendant_cleanup"):
raise ValueError("invalid interrupted command prefix")
command_ids=set()
for i,c in enumerate(obj["commands"]):
spec,planned_argv,planned_exit,planned_max,planned_claims=planned_commands[i]
command_keys={"id","purpose","argv","exit_code","expected_exit_code","max_output_bytes","output","started_at_utc","finished_at_utc","duration_seconds","claim_ids"}
if archive_shape:
spec,planned_argv,planned_exit,planned_max,planned_claims,_=planned_commands[i]
planned_timeout=None
command_keys={"id","purpose","argv","exit_code","expected_exit_code","max_output_bytes","output","started_at_utc","finished_at_utc","duration_seconds","claim_ids"}
else:
spec,planned_argv,planned_exit,planned_max,planned_claims,planned_timeout=planned_commands[i]
command_keys={"id","purpose","argv","exit_code","expected_exit_code","max_output_bytes","timeout_seconds","execution_status","termination","output","started_at_utc","finished_at_utc","duration_seconds","claim_ids"}
if not isinstance(c,dict) or set(c)!=command_keys: raise ValueError("invalid command structure")
if not isinstance(c["id"],str) or not ID.fullmatch(c["id"]) or c["id"] in command_ids or not isinstance(c["purpose"],str): raise ValueError("invalid command identity")
command_ids.add(c["id"])
validate_argv(c["argv"])
if not isinstance(c["output"],str): raise ValueError("invalid command types")
if any(not isinstance(c[k],int) or isinstance(c[k],bool) for k in ("exit_code","expected_exit_code","max_output_bytes")) or c["max_output_bytes"]<0 or len(c["output"].encode())>c["max_output_bytes"]: raise ValueError("invalid command bounds")
if (not isinstance(c["exit_code"],int) or isinstance(c["exit_code"],bool)) and not (not archive_shape and c["exit_code"] is None): raise ValueError("invalid command bounds")
if not isinstance(c["expected_exit_code"],int) or isinstance(c["expected_exit_code"],bool) or not isinstance(c["max_output_bytes"],int) or isinstance(c["max_output_bytes"],bool) or c["max_output_bytes"]<0 or (not archive_shape and c["max_output_bytes"]>MAX_OUTPUT_BYTES) or len(c["output"].encode())>c["max_output_bytes"]: raise ValueError("invalid command bounds")
if not archive_shape:
if not valid_timeout(c["timeout_seconds"]): raise ValueError("invalid timeout_seconds")
if c["execution_status"] not in ("completed","timeout","output_limit","cleanup_failed","descendant_cleanup"): raise ValueError("invalid execution status")
if not archive_shape and (not isinstance(c["termination"],dict) or set(c["termination"])!={"term_sent","kill_sent","direct_child_reaped","group_cleanup_completed"} or any(type(c["termination"][k]) is not bool for k in c["termination"])): raise ValueError("invalid termination record")
if not archive_shape:
signalled = c["termination"]["term_sent"] or c["termination"]["kill_sent"]
if c["execution_status"] == "completed" and signalled:
raise ValueError("completed command has termination signal")
if c["execution_status"] == "descendant_cleanup" and not signalled:
raise ValueError("descendant cleanup missing termination signal")
settled = c["termination"]["direct_child_reaped"] and c["termination"]["group_cleanup_completed"]
if c["execution_status"] == "cleanup_failed":
if settled: raise ValueError("cleanup failure inconsistent")
elif not settled or c["exit_code"] is None:
raise ValueError("execution cleanup incomplete")
if c["execution_status"] != "completed" and i != len(obj["commands"])-1:
raise ValueError("command executed after infrastructure failure")
if not isinstance(c["duration_seconds"],(int,float)) or isinstance(c["duration_seconds"],bool) or not math.isfinite(c["duration_seconds"]) or c["duration_seconds"]<0: raise ValueError("invalid command duration")
if not isinstance(c["claim_ids"],list) or any(not isinstance(x,str) or not ID.fullmatch(x) for x in c["claim_ids"]): raise ValueError("invalid command fields")
if c["id"]!=spec.get("id",f"command-{i}") or c["purpose"]!=spec.get("purpose","") or c["argv"]!=planned_argv or c["expected_exit_code"]!=planned_exit or c["max_output_bytes"]!=planned_max or c["claim_ids"]!=planned_claims: raise ValueError("command record does not match plan")
if c["id"]!=spec.get("id",f"command-{i}") or c["purpose"]!=spec.get("purpose","") or c["argv"]!=planned_argv or c["expected_exit_code"]!=planned_exit or c["max_output_bytes"]!=planned_max or (not archive_shape and c["timeout_seconds"]!=planned_timeout) or c["claim_ids"]!=planned_claims: raise ValueError("command record does not match plan")
if not ISO.fullmatch(c["started_at_utc"]) or not ISO.fullmatch(c["finished_at_utc"]): raise ValueError("invalid timestamp")
for cid in c["claim_ids"]:
if cid in claim_seen or cid not in obj["claims"]: raise ValueError("duplicate or missing claim")
@@ -413,12 +605,14 @@ def validate(args):
f"/commands/{i}/started_at_utc",
f"/commands/{i}/finished_at_utc",
]
if not archive_shape:
expected_pointers += [f"/commands/{i}/timeout_seconds", f"/commands/{i}/execution_status", f"/commands/{i}/termination"]
if pointers!=expected_pointers: raise ValueError("claim pointers are not canonical")
for ptr in pointers: pointer(obj,ptr)
violation=scan_sensitive("\n".join(c["argv"])+"\n"+c["output"],plan_secrets,plan_forbidden)
if violation: raise ValueError("redaction violation")
if claim_seen != set(obj["claims"]): raise ValueError("unreferenced claim")
expected="success" if all(c["exit_code"]==c["expected_exit_code"] for c in obj["commands"]) else "failure"
expected="success" if (all(c["exit_code"]==c["expected_exit_code"] for c in obj["commands"]) if archive_shape else all(c["execution_status"]=="completed" and c["termination"]["direct_child_reaped"] and c["termination"]["group_cleanup_completed"] and c["exit_code"]==c["expected_exit_code"] for c in obj["commands"])) and len(obj["commands"])==len(planned_commands) else "failure"
if obj["result"]!=expected: raise ValueError("result inconsistent")
return 0
@@ -433,6 +627,9 @@ def main():
parser.add_argument("--artifact-root",required=True)
old=sub.add_parser("validate-legacy",help="read-only archived v1 compatibility; not a new publication gate")
old.add_argument("--repo-root",required=True); old.add_argument("--manifest",required=True)
archived=sub.add_parser("validate-archived-v2",help="read-only archived v2 compatibility; not a new publication gate")
for parser in (archived,):
parser.add_argument("--contract-path",required=True); parser.add_argument("--repo-root",required=True); parser.add_argument("--artifact-root",required=True); parser.add_argument("--manifest",required=True)
a=p.parse_args()
try: return run(a) if a.op=="run" else validate(a)
except (OSError, ValueError, TypeError, KeyError, AttributeError, UnicodeError, json.JSONDecodeError, re.error, subprocess.SubprocessError) as e: return die(e)