[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
@@ -281,8 +281,15 @@ python3 harness/agents/impl/skills/gitea-issue-rework/scripts/evidence_manifest.
--manifest evidence-manifest.json
```
`VERIFICATION_RUN_ROOT`는 `$HARNESS_ROOT/repositories/$OWNER/$REPOSITORY/worktrees/issue-$ISSUE/verification/$RUN_ID`의 exact 절대 경로다. Plan의 `hash_paths`만 repository-relative이고 plan/output/manifest는 run-relative다. Source filename과 artifact filename이 같아도 같은 파일로 취급하지 않는다. `run`은 **strict clean checkout**, evidence **exclusion 없음**, full SHA·source/plan digest와 실제 command/exit code/bounded output binding을 요구하고 `evidence-manifest/v2`를 게시한다. Existing manifest를 덮어쓰지 않는다. `validate`도 같은 roots와 exact current HEAD를 요구한다. Dirty 개발 tree에서 validation 성공을 주장하지 않으며, final tracked 산출물을 commit한 뒤 그 exact commit을 검증하고 실패하면 push하지 않는다.
`VERIFICATION_RUN_ROOT`는 `$HARNESS_ROOT/repositories/$OWNER/$REPOSITORY/worktrees/issue-$ISSUE/verification/$RUN_ID`의 exact 절대 경로다. Plan의 `hash_paths`만 repository-relative이고 plan/output/manifest는 run-relative다. Source filename과 artifact filename이 같아도 같은 파일로 취급하지 않는다. `run`은 **strict clean checkout**, evidence **exclusion 없음**, full SHA·source/plan digest와 실제 command/exit code/bounded output binding을 요구하고 `evidence-manifest/v3`를 게시한다. Existing manifest를 덮어쓰지 않는다. `validate`도 같은 roots와 exact current HEAD를 요구한다. Dirty 개발 tree에서 validation 성공을 주장하지 않으며, final tracked 산출물을 commit한 뒤 그 exact commit을 검증하고 실패하면 push하지 않는다.
PR/결과 보고에는 stage/run-id, source SHA, manifest·plan digest, 실제 command/result/limitations와 reviewer 접근 수단을 함께 기록한다. Same-UID reviewer는 기록만 read-only로 읽고, distinct-UID/host이면 승인된 credential-free read-only export와 digest/readback receipt를 제공한다. 경로만 제시하거나 권한을 완화하는 것은 접근 검증이 아니다. 확인할 수 없으면 `evidence_gap`으로 보고한다. Product 테스트 증거와 harness 자체 검증을 구분하고, 인증정보 값은 `[REDACTED]`로만 기록한다.
Remote branch/PR head를 authoritative하게 읽어 source SHA·exact artifact와 결합하고 cleanup 전 실제 record readback을 확인한다. Helper의 local validate가 Gitea readback을 대신하지 않는다. Checkout 제거와 verification 폐기는 분리하며 완료 기록은 보존한다. 기존 v1은 `validate-legacy`의 read-only 호환 경로만 사용하고 새 publication proof로 사용하지 않는다.
Command deadline 계약: 새 plan의 각 command는 `timeout_seconds`를 명시한다. Boolean·문자열·누락·비유한 값·0 이하·3600 초 초과를 실행 전에 거절하며 암묵적인 무제한 실행을 허용하지 않는다. Monotonic wall-clock deadline은 무출력 대기, stdout EOF 이후 실행, leader 종료 뒤 descendant가 유지한 pipe까지 적용한다. 각 command는 별도 session/process group에서 실행하고 timeout·출력 초과·예외 시 해당 process group을 TERM → 제한된 grace → KILL로 정리하고 direct child를 bounded wait로 회수한다. Leader가 종료됐어도 같은 group의 살아 있는 descendant를 남기지 않는다. 이것은 sandbox가 아니며 별도 session으로 이탈한 descendant의 containment나 grandchild 직접 회수를 보장하지 않는다.
`evidence-manifest/v3`는 실제 timeout, `execution_status`, `termination`의 `term_sent`·`kill_sent`·`direct_child_reaped`·`group_cleanup_completed` boolean 기록과 안전한 bounded 부분 출력을 결합한다. 새 claim pointer는 기존 6개에 timeout·실행 상태·종료 기록을 추가한 9개다. Linux `/proc`로 같은 group의 non-zombie 프로세스를 확인하며 확인·정리에 실패하면 `cleanup_failed`로 차단한다. TERM grace는 0.25 초, KILL 후 group 종료·direct-child 회수 예산은 0.5 초이며 오류 fallback wait도 0.1 초로 제한한다. 새 command의 `max_output_bytes`는 0–1048576 범위의 non-boolean 정수만 허용한다. `execution_status`는 `completed|timeout|output_limit|cleanup_failed|descendant_cleanup`이다. 정상 종료 leader 뒤의 살아 있는 descendant를 신호로 정리한 경우도 `descendant_cleanup` 실패이며, `completed`에는 TERM/KILL 신호가 있을 수 없다. Timeout·출력 한도 초과·정리 실패는 expected exit code와 같아도 성공이 아니며 **TDD RED** 또는 제품 assertion 실패로 분류하지 않는다. 해당 실행은 실패로 보존하고 뒤의 command를 실행하지 않으며, 실행된 prefix의 claim만 기록한다. Partial output에도 기존 secret 탐지를 적용하고 인증정보 유출은 fail-closed한다. 자동 재실행은 하지 않으며 새로 승인된 실행에는 새 run-id를 사용한다.
기존 `evidence-manifest/v2`는 명시적 `validate-archived-v2 --contract-path <canonical-contract> --repo-root <checkout> --artifact-root <canonical-run> --manifest <run-relative-manifest>`로 read-only 호환 검증한다. Canonical identity, exact current HEAD, source/plan/output binding을 유지하지만 과거 기록에 없던 timeout이나 종료 보장을 추론하지 않는다. 새 `run`/`validate`에는 v2 fallback이 없다. v1은 기존 `validate-legacy`만 사용한다. 두 archive 경로의 성공은 새 실행·TDD RED·fresh publication proof가 아니며 기존 artifact를 수정하거나 새 schema로 자동 변환하지 않는다.
@@ -351,8 +351,15 @@ python3 harness/agents/impl/skills/gitea-issue-rework/scripts/evidence_manifest.
--manifest evidence-manifest.json
```
`VERIFICATION_RUN_ROOT`는 `$HARNESS_ROOT/repositories/$OWNER/$REPOSITORY/worktrees/issue-$ISSUE/verification/$RUN_ID`의 exact 절대 경로다. Plan의 `hash_paths`만 repository-relative이고 plan/output/manifest는 run-relative다. Source filename과 artifact filename이 같아도 같은 파일로 취급하지 않는다. `run`은 **strict clean checkout**, evidence **exclusion 없음**, full SHA·source/plan digest와 실제 command/exit code/bounded output binding을 요구하고 `evidence-manifest/v2`를 게시한다. Existing manifest를 덮어쓰지 않는다. `validate`도 같은 roots와 exact current HEAD를 요구한다. Dirty 개발 tree에서 validation 성공을 주장하지 않으며, final tracked 산출물을 commit한 뒤 그 exact commit을 검증하고 실패하면 push하지 않는다.
`VERIFICATION_RUN_ROOT`는 `$HARNESS_ROOT/repositories/$OWNER/$REPOSITORY/worktrees/issue-$ISSUE/verification/$RUN_ID`의 exact 절대 경로다. Plan의 `hash_paths`만 repository-relative이고 plan/output/manifest는 run-relative다. Source filename과 artifact filename이 같아도 같은 파일로 취급하지 않는다. `run`은 **strict clean checkout**, evidence **exclusion 없음**, full SHA·source/plan digest와 실제 command/exit code/bounded output binding을 요구하고 `evidence-manifest/v3`를 게시한다. Existing manifest를 덮어쓰지 않는다. `validate`도 같은 roots와 exact current HEAD를 요구한다. Dirty 개발 tree에서 validation 성공을 주장하지 않으며, final tracked 산출물을 commit한 뒤 그 exact commit을 검증하고 실패하면 push하지 않는다.
PR/결과 보고에는 stage/run-id, source SHA, manifest·plan digest, 실제 command/result/limitations와 reviewer 접근 수단을 함께 기록한다. Same-UID reviewer는 기록만 read-only로 읽고, distinct-UID/host이면 승인된 credential-free read-only export와 digest/readback receipt를 제공한다. 경로만 제시하거나 권한을 완화하는 것은 접근 검증이 아니다. 확인할 수 없으면 `evidence_gap`으로 보고한다. Product 테스트 증거와 harness 자체 검증을 구분하고, 인증정보 값은 `[REDACTED]`로만 기록한다.
Remote branch/PR head를 authoritative하게 읽어 source SHA·exact artifact와 결합하고 cleanup 전 실제 record readback을 확인한다. Helper의 local validate가 Gitea readback을 대신하지 않는다. Checkout 제거와 verification 폐기는 분리하며 완료 기록은 보존한다. 기존 v1은 `validate-legacy`의 read-only 호환 경로만 사용하고 새 publication proof로 사용하지 않는다.
Command deadline 계약: 새 plan의 각 command는 `timeout_seconds`를 명시한다. Boolean·문자열·누락·비유한 값·0 이하·3600 초 초과를 실행 전에 거절하며 암묵적인 무제한 실행을 허용하지 않는다. Monotonic wall-clock deadline은 무출력 대기, stdout EOF 이후 실행, leader 종료 뒤 descendant가 유지한 pipe까지 적용한다. 각 command는 별도 session/process group에서 실행하고 timeout·출력 초과·예외 시 해당 process group을 TERM → 제한된 grace → KILL로 정리하고 direct child를 bounded wait로 회수한다. Leader가 종료됐어도 같은 group의 살아 있는 descendant를 남기지 않는다. 이것은 sandbox가 아니며 별도 session으로 이탈한 descendant의 containment나 grandchild 직접 회수를 보장하지 않는다.
`evidence-manifest/v3`는 실제 timeout, `execution_status`, `termination`의 `term_sent`·`kill_sent`·`direct_child_reaped`·`group_cleanup_completed` boolean 기록과 안전한 bounded 부분 출력을 결합한다. 새 claim pointer는 기존 6개에 timeout·실행 상태·종료 기록을 추가한 9개다. Linux `/proc`로 같은 group의 non-zombie 프로세스를 확인하며 확인·정리에 실패하면 `cleanup_failed`로 차단한다. TERM grace는 0.25 초, KILL 후 group 종료·direct-child 회수 예산은 0.5 초이며 오류 fallback wait도 0.1 초로 제한한다. 새 command의 `max_output_bytes`는 0–1048576 범위의 non-boolean 정수만 허용한다. `execution_status`는 `completed|timeout|output_limit|cleanup_failed|descendant_cleanup`이다. 정상 종료 leader 뒤의 살아 있는 descendant를 신호로 정리한 경우도 `descendant_cleanup` 실패이며, `completed`에는 TERM/KILL 신호가 있을 수 없다. Timeout·출력 한도 초과·정리 실패는 expected exit code와 같아도 성공이 아니며 **TDD RED** 또는 제품 assertion 실패로 분류하지 않는다. 해당 실행은 실패로 보존하고 뒤의 command를 실행하지 않으며, 실행된 prefix의 claim만 기록한다. Partial output에도 기존 secret 탐지를 적용하고 인증정보 유출은 fail-closed한다. 자동 재실행은 하지 않으며 새로 승인된 실행에는 새 run-id를 사용한다.
기존 `evidence-manifest/v2`는 명시적 `validate-archived-v2 --contract-path <canonical-contract> --repo-root <checkout> --artifact-root <canonical-run> --manifest <run-relative-manifest>`로 read-only 호환 검증한다. Canonical identity, exact current HEAD, source/plan/output binding을 유지하지만 과거 기록에 없던 timeout이나 종료 보장을 추론하지 않는다. 새 `run`/`validate`에는 v2 fallback이 없다. v1은 기존 `validate-legacy`만 사용한다. 두 archive 경로의 성공은 새 실행·TDD RED·fresh publication proof가 아니며 기존 artifact를 수정하거나 새 schema로 자동 변환하지 않는다.
@@ -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)
@@ -11,6 +11,7 @@
"argv": ["python3", "-B", "-m", "unittest", "tests.test_project", "-v"],
"expected_exit_code": 0,
"max_output_bytes": 65536,
"timeout_seconds": 120,
"claims": ["focused-result"]
}
],
@@ -311,10 +311,17 @@ Review write 응답, process exit, PR body의 test 주장, delegate 보고 또
## External Issue verification evidence boundary
제품 검증 실행 evidence의 정본은 공통 [Repository cache 운영 지침](../../../../operations/repository-cache.md)의 `<harness-root>/repositories/<owner>/<repo>/worktrees/issue-<number>/verification/<run-id>`다. 구현·재작업의 `evidence-manifest/v2`와 plan/output을 read-only로 소비하며 제품 저장소에 회차별 증빙 파일을 새로 요구하지 않는다. 기존 genuine 정본 문서와 fixture의 존재 자체를 결함으로 판정하거나 삭제를 요구하지 않는다.
제품 검증 실행 evidence의 정본은 공통 [Repository cache 운영 지침](../../../../operations/repository-cache.md)의 `<harness-root>/repositories/<owner>/<repo>/worktrees/issue-<number>/verification/<run-id>`다. 구현·재작업의 `evidence-manifest/v3`와 plan/output을 read-only로 소비하며 제품 저장소에 회차별 증빙 파일을 새로 요구하지 않는다. 기존 genuine 정본 문서와 fixture의 존재 자체를 결함으로 판정하거나 삭제를 요구하지 않는다.
- Authenticated repository/Issue identity, source full SHA, plan·manifest·source/output digest와 실제 command/result binding을 독립 검증한다. Manifest의 경로나 SHA 주장을 신뢰하는 것만으로 검증하지 않고 pinned provider/Git blob과 fresh PR head에 결합한다.
- Same-UID의 승인된 읽기 경로 또는 distinct-UID/host의 승인된 credential-free read-only export와 digest/readback receipt를 확인한다. 접근/무결성/source binding을 확인할 수 없으면 `evidence_gap`으로 남기고 증거를 만들어내지 않는다. `0700`/`0600`을 완화하거나 임의 외부 경로·다른 role credential로 우회하지 않는다.
- Producer evidence와 reviewer 실행을 구분한다. Spec reviewer는 제품 테스트를 실행하지 않으며 moving implementation checkout을 authority로 읽거나 재사용·수정하지 않는다. Impl helper의 `validate`는 clean current checkout을 요구하므로 spec review의 pinned source 검증을 대신하지 않는다. Immutable source bundle과 authoritative blob evidence를 사용한다.
- Native-review snapshot, matrix/result와 publication/readback 자료는 trusted `harness/.runtime`에 보존할 수 있다. 그것을 제품 실행 evidence로 오인하지 않는다. 구현자의 verification 기록을 수정하거나 reviewer 자료로 덮어쓰지 않는다.
- 기존 v1의 archived read-only 호환 분석은 허용하되 fresh 제품 실행·새 remote head 검증·native review publication 완료의 근거로 과장하지 않는다. 인증정보 값은 보존하지 않고 `[REDACTED]`로 기록한다.
Command deadline 계약: 새 plan의 각 command는 `timeout_seconds`를 명시한다. Boolean·문자열·누락·비유한 값·0 이하·3600 초 초과를 실행 전에 거절하며 암묵적인 무제한 실행을 허용하지 않는다. Monotonic wall-clock deadline은 무출력 대기, stdout EOF 이후 실행, leader 종료 뒤 descendant가 유지한 pipe까지 적용한다. 각 command는 별도 session/process group에서 실행하고 timeout·출력 초과·예외 시 해당 process group을 TERM → 제한된 grace → KILL로 정리하고 direct child를 bounded wait로 회수한다. Leader가 종료됐어도 같은 group의 살아 있는 descendant를 남기지 않는다. 이것은 sandbox가 아니며 별도 session으로 이탈한 descendant의 containment나 grandchild 직접 회수를 보장하지 않는다.
`evidence-manifest/v3`는 실제 timeout, `execution_status`, `termination`의 `term_sent`·`kill_sent`·`direct_child_reaped`·`group_cleanup_completed` boolean 기록과 안전한 bounded 부분 출력을 결합한다. 새 claim pointer는 기존 6개에 timeout·실행 상태·종료 기록을 추가한 9개다. Linux `/proc`로 같은 group의 non-zombie 프로세스를 확인하며 확인·정리에 실패하면 `cleanup_failed`로 차단한다. TERM grace는 0.25 초, KILL 후 group 종료·direct-child 회수 예산은 0.5 초이며 오류 fallback wait도 0.1 초로 제한한다. 새 command의 `max_output_bytes`는 0–1048576 범위의 non-boolean 정수만 허용한다. `execution_status`는 `completed|timeout|output_limit|cleanup_failed|descendant_cleanup`이다. 정상 종료 leader 뒤의 살아 있는 descendant를 신호로 정리한 경우도 `descendant_cleanup` 실패이며, `completed`에는 TERM/KILL 신호가 있을 수 없다. Timeout·출력 한도 초과·정리 실패는 expected exit code와 같아도 성공이 아니며 **TDD RED** 또는 제품 assertion 실패로 분류하지 않는다. 해당 실행은 실패로 보존하고 뒤의 command를 실행하지 않으며, 실행된 prefix의 claim만 기록한다. Partial output에도 기존 secret 탐지를 적용하고 인증정보 유출은 fail-closed한다. 자동 재실행은 하지 않으며 새로 승인된 실행에는 새 run-id를 사용한다.
기존 `evidence-manifest/v2`는 명시적 `validate-archived-v2 --contract-path <canonical-contract> --repo-root <checkout> --artifact-root <canonical-run> --manifest <run-relative-manifest>`로 read-only 호환 검증한다. Canonical identity, exact current HEAD, source/plan/output binding을 유지하지만 과거 기록에 없던 timeout이나 종료 보장을 추론하지 않는다. 새 `run`/`validate`에는 v2 fallback이 없다. v1은 기존 `validate-legacy`만 사용한다. 두 archive 경로의 성공은 새 실행·TDD RED·fresh publication proof가 아니며 기존 artifact를 수정하거나 새 schema로 자동 변환하지 않는다.
+8 -1
View File
@@ -555,10 +555,17 @@ Issue별 검증 실행 기록의 정본은 `<harness-root>/repositories/<owner>/
- evidence `run` 전에 다음 명령으로 live broker lease와 exact harness root·Issue identity를 인증한다: `python3 harness/lib/verification_root.py prepare --root <harness-root> --owner <owner> --repository <repository> --issue <issue> --run-id <run-id> --handle <handle>`. 이 helper는 `verification/`과 `<run-id>/`를 각각 별도 생성하고, 각 directory가 실행 UID 소유 mode `0700`인지 생성 직후 다시 `lstat`한다. Symlink, owner·mode 불일치와 path collision은 거부하며, 기존 malformed root를 자동 chmod하거나 repair하지 않는다. `Path.mkdir(parents=True, mode=0o700)`는 중간 parent가 umask를 따르므로 이 경로에 사용하지 않는다.
- Caller가 인증된 Issue identity와 cache/worktree admission을 확인하고 **broker Issue lease** 아래 위 prepare를 실행한다. Directory는 실행 UID 소유 mode `0700`, plan·manifest·output 파일은 실행 UID 소유 mode `0600`으로 만들고 즉시 `lstat`으로 확인한다. 기존 cache bootstrap, repository lock과 lease 계약은 바꾸지 않는다. Artifact helper는 lease·Gitea identity·Git registration을 대신 인증하거나 directory를 만들고 cache/권한을 복구하지 않는다.
- `repo_root`는 canonical `checkout`, `artifact_root`는 exact `verification/<run-id>`로 분리한다. Plan의 `hash_paths`는 repository-relative source 경로이고 plan/output/manifest 인자는 run-relative artifact 경로다. 두 namespace는 이름이 같아도 서로 다른 파일이다. Source tree의 tracked·untracked·ignored 파일을 함께 검사하고 evidence 경로 exclusion을 허용하지 않는다. Python은 `-B` 또는 allowlist에 선언한 `PYTHONDONTWRITEBYTECODE=1`, pytest는 필요하면 `-p no:cacheprovider`로 checkout 안의 자동 cache 생성을 막는다. 테스트 임시 산출물·dependency environment도 checkout 밖 승인된 경로를 사용한다. Helper가 ignored cache를 자동 삭제하거나 clean 상태로 간주하지 않는다.
- 새 실행은 `evidence-manifest/v2`로 source full SHA, tracked source blob SHA·byte digest, owner/repository/Issue/run-id, plan path·digest, 실제 argv·exit code·환경·bounded output·시간과 claim pointer를 결합한다. Existing manifest는 no-clobber 원자 게시로 덮어쓰지 않는다. 완료 기록은 불변으로 취급하고 실패·재실행도 새 run-id에 보존한다. Helper의 `validate` 성공은 local artifact 정합성일 뿐 원격 PR head 또는 provider readback 검증을 대체하지 않는다.
- 새 실행은 `evidence-manifest/v3`로 source full SHA, tracked source blob SHA·byte digest, owner/repository/Issue/run-id, plan path·digest, 실제 argv·exit code·환경·bounded output·시간과 claim pointer를 결합한다. Existing manifest는 no-clobber 원자 게시로 덮어쓰지 않는다. 완료 기록은 불변으로 취급하고 실패·재실행도 새 run-id에 보존한다. Helper의 `validate` 성공은 local artifact 정합성일 뿐 원격 PR head 또는 provider readback 검증을 대체하지 않는다.
- Baseline, integration, precommit과 remote-head 검증은 각 stage에서 검증한 **그 commit의 clean source**에 결합한다. Dirty 개발 tree는 보존하되 helper가 성공 증거로 게시하지 않는다. Final tracked 제품 산출물을 commit한 뒤 전달할 exact commit에서 검증하고, 실패하면 push하지 않는다. 모든 PR/결과 보고는 같은 immutable record의 command/result를 사용한다.
- Same-UID 배포에서는 인증된 reviewer가 producer 기록을 read-only로 읽는다. 다른 UID나 host에서는 승인된 credential-free read-only export/immutable artifact 전달 수단을 명시한다. 전달 receipt는 repository/Issue/run-id, source SHA, manifest·plan·output digest와 실제 읽기 검증을 묶는다. UID가 다르다고 chmod/chown하거나 임의 sibling path를 허용하지 않는다. 경로 문자열이나 구현자 요약만으로 reviewer-accessible이라고 판단하지 않으며, 접근/무결성/source binding 실패는 `evidence_gap`이다.
- Reviewer는 검증 source를 immutable provider/Git blob에 독립 결합한다. Producer manifest의 테스트 결과는 구현자 증거로 표시하고 reviewer 자체 실행으로 표현하지 않는다. Spec native-review snapshot/result와 운영 자료는 trusted `harness/.runtime`에 남겨도 되며 제품 실행 evidence와 혼동하지 않는다. Spec reviewer는 moving implementation checkout을 근거나 test 실행 장소로 재사용하지 않는다.
- Checkout 제거, broker release와 verification 폐기는 별개다. Checkout과 state를 제거해도 verification과 그 Issue directory는 보존한다. 보존 기간의 자동 만료나 재귀 삭제를 도입하지 않는다. Artifact 폐기는 별도의 exact 경로 승인, 활성 consumer·delivery/readback 의존성 조사와 digest 기록을 거쳐야 한다. 생성 실패의 미완성 run도 경로 identity가 입증된 현재 run 범위만 정리할 수 있고, 완료 기록은 자동 삭제하지 않는다.
- 인증정보를 사용했다는 사실은 남길 수 있지만 값·토큰·비밀번호·cookie·연결 문자열은 보존하지 않고 `[REDACTED]`로 기록한다. 새 증거를 만들기 위해 credential을 plan/argv/output에 넣지 않는다. Helper는 알려진 secret·header 유출을 fail-closed하고, 이것을 완전한 sandbox 또는 모든 비밀에 대한 자동 탐지로 과장하지 않는다.
- 기존 `evidence-manifest/v1`은 명시적 `validate-legacy --repo-root <historical-checkout> --manifest <repository-relative-manifest>`로 read-only 호환 확인만 한다. 새 `run`/`validate`에는 v1·repository-relative evidence 생성 fallback이 없다. Archived replay는 fresh source 실행·원격 head/readback 또는 새 publication 완료를 증명하지 않는다. Migration도 별도 승인 없이 기존 제품 evidence를 수정하지 않는다.
Command deadline 계약: 새 plan의 각 command는 `timeout_seconds`를 명시한다. Boolean·문자열·누락·비유한 값·0 이하·3600 초 초과를 실행 전에 거절하며 암묵적인 무제한 실행을 허용하지 않는다. Monotonic wall-clock deadline은 무출력 대기, stdout EOF 이후 실행, leader 종료 뒤 descendant가 유지한 pipe까지 적용한다. 각 command는 별도 session/process group에서 실행하고 timeout·출력 초과·예외 시 해당 process group을 TERM → 제한된 grace → KILL로 정리하고 direct child를 bounded wait로 회수한다. Leader가 종료됐어도 같은 group의 살아 있는 descendant를 남기지 않는다. 이것은 sandbox가 아니며 별도 session으로 이탈한 descendant의 containment나 grandchild 직접 회수를 보장하지 않는다.
`evidence-manifest/v3`는 실제 timeout, `execution_status`, `termination`의 `term_sent`·`kill_sent`·`direct_child_reaped`·`group_cleanup_completed` boolean 기록과 안전한 bounded 부분 출력을 결합한다. 새 claim pointer는 기존 6개에 timeout·실행 상태·종료 기록을 추가한 9개다. Linux `/proc`로 같은 group의 non-zombie 프로세스를 확인하며 확인·정리에 실패하면 `cleanup_failed`로 차단한다. TERM grace는 0.25 초, KILL 후 group 종료·direct-child 회수 예산은 0.5 초이며 오류 fallback wait도 0.1 초로 제한한다. 새 command의 `max_output_bytes`는 0–1048576 범위의 non-boolean 정수만 허용한다. `execution_status`는 `completed|timeout|output_limit|cleanup_failed|descendant_cleanup`이다. 정상 종료 leader 뒤의 살아 있는 descendant를 신호로 정리한 경우도 `descendant_cleanup` 실패이며, `completed`에는 TERM/KILL 신호가 있을 수 없다. Timeout·출력 한도 초과·정리 실패는 expected exit code와 같아도 성공이 아니며 **TDD RED** 또는 제품 assertion 실패로 분류하지 않는다. 해당 실행은 실패로 보존하고 뒤의 command를 실행하지 않으며, 실행된 prefix의 claim만 기록한다. Partial output에도 기존 secret 탐지를 적용하고 인증정보 유출은 fail-closed한다. 자동 재실행은 하지 않으며 새로 승인된 실행에는 새 run-id를 사용한다.
기존 `evidence-manifest/v2`는 명시적 `validate-archived-v2 --contract-path <canonical-contract> --repo-root <checkout> --artifact-root <canonical-run> --manifest <run-relative-manifest>`로 read-only 호환 검증한다. Canonical identity, exact current HEAD, source/plan/output binding을 유지하지만 과거 기록에 없던 timeout이나 종료 보장을 추론하지 않는다. 새 `run`/`validate`에는 v2 fallback이 없다. v1은 기존 `validate-legacy`만 사용한다. 두 archive 경로의 성공은 새 실행·TDD RED·fresh publication proof가 아니며 기존 artifact를 수정하거나 새 schema로 자동 변환하지 않는다.
@@ -54,6 +54,25 @@ class VerificationContractDocumentationTests(unittest.TestCase):
self.assertIn("-B", plan["commands"][0]["argv"])
self.assertIn("PYTHONDONTWRITEBYTECODE", plan["command_env_allowlist"])
def test_command_deadline_and_archive_policy_is_explicit(self):
import json
template = ROOT / "harness/agents/impl/skills/gitea-issue-rework/templates/evidence-plan.json"
for command in json.loads(template.read_text())["commands"]:
self.assertIsInstance(command["timeout_seconds"], (int, float))
self.assertGreater(command["timeout_seconds"], 0)
self.assertLessEqual(command["timeout_seconds"], 3600)
for path in (CACHE, *SKILLS.values()):
text = path.read_text()
for token in ("evidence-manifest/v3", "timeout_seconds", "process group",
"validate-archived-v2", "TDD RED", "자동 재실행"):
with self.subTest(path=str(path), token=token):
self.assertIn(token, text)
help_result = subprocess.run([sys.executable, str(SCRIPT), "validate-archived-v2", "--help"],
capture_output=True, text=True, timeout=10)
self.assertEqual(help_result.returncode, 0, help_result.stderr)
for option in ("--contract-path", "--repo-root", "--artifact-root", "--manifest"):
self.assertIn(option, help_result.stdout)
def test_all_roles_reference_canonical_external_artifacts(self):
for path in (CACHE, *SKILLS.values()):
with self.subTest(path=path.name):
@@ -61,7 +80,7 @@ class VerificationContractDocumentationTests(unittest.TestCase):
self.assertIn(CANONICAL, text)
self.assertIn("evidence_gap", text)
self.assertIn("[REDACTED]", text)
self.assertIn("evidence-manifest/v2", text)
self.assertIn("evidence-manifest/v3", text)
def test_examples_match_real_helper_cli_not_unsupported_options(self):
for path in (SKILLS["implementation"], SKILLS["rework"]):
@@ -0,0 +1,323 @@
import hashlib
import json
import os
import signal
import subprocess
import sys
import tempfile
import time
import unittest
from pathlib import Path
SCRIPT = Path(__file__).resolve().parents[2] / "agents/impl/skills/gitea-issue-rework/scripts/evidence_manifest.py"
SCRATCH = Path("/opt/data/cache/scratch/evidence-deadline-regressions")
def git(cwd, *args):
return subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True)
class EvidenceCommandDeadlineTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(dir="/opt/data/cache/scratch")
root = Path(self.tmp.name)
self.contract = root / "harness/operations/repository-cache.md"
self.contract.parent.mkdir(parents=True)
self.contract.write_text("contract\n")
self.repo = root / "repositories/o/p/worktrees/issue-1/checkout"
self.artifacts = self.repo.parent / "verification/run-a"
self.repo.mkdir(parents=True)
self.artifacts.mkdir(parents=True)
for p in (root / "repositories", root / "repositories/o", root / "repositories/o/p",
root / "repositories/o/p/worktrees", root / "repositories/o/p/worktrees/issue-1",
self.repo, self.artifacts.parent, self.artifacts):
if p.exists(): p.chmod(0o700)
git(self.repo, "init", "-q")
git(self.repo, "config", "user.email", "test@example.invalid")
git(self.repo, "config", "user.name", "Test")
(self.repo / "input.txt").write_text("input\n")
git(self.repo, "add", "input.txt")
git(self.repo, "commit", "-qm", "initial")
self.known_pid_files = set()
self.outer_processes = set()
def tearDown(self):
for pid_file in self.known_pid_files:
try:
pid = int(pid_file.read_text().strip())
except (FileNotFoundError, ValueError):
continue
self._kill_pid_group(pid)
for proc in self.outer_processes:
if proc.poll() is None:
try:
os.killpg(proc.pid, signal.SIGKILL)
except ProcessLookupError:
pass
proc.wait(timeout=1)
self.tmp.cleanup()
@staticmethod
def _kill_pid_group(pid):
try:
pgid = os.getpgid(pid)
if pgid == os.getpgrp():
raise AssertionError("refusing cleanup of test runner group")
os.killpg(pgid, signal.SIGTERM)
time.sleep(0.05)
except ProcessLookupError:
return
try:
os.killpg(pgid, signal.SIGKILL)
except ProcessLookupError:
pass
def invoke(self, plan, output="manifest.json", op="run", manifest=None, env=None):
if op == "run":
args = ["run", "--contract-path", str(self.contract), "--repo-root", str(self.repo),
"--artifact-root", str(self.artifacts), "--plan", str(plan.name), "--output", output]
elif op == "validate":
args = ["validate", "--contract-path", str(self.contract), "--repo-root", str(self.repo),
"--artifact-root", str(self.artifacts), "--manifest", str(manifest or output)]
elif op == "validate-archived-v2":
args = ["validate-archived-v2", "--contract-path", str(self.contract), "--repo-root", str(self.repo),
"--artifact-root", str(self.artifacts), "--manifest", str(manifest or output)]
else:
raise AssertionError(op)
proc = subprocess.Popen([sys.executable, str(SCRIPT), *args], cwd=self.repo,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env, start_new_session=True)
self.outer_processes.add(proc)
try:
stdout, stderr = proc.communicate(timeout=8)
except subprocess.TimeoutExpired:
self._kill_pid_group(proc.pid)
stdout, stderr = proc.communicate(timeout=1)
stderr += "\nOUTER_TIMEOUT\n"
return subprocess.CompletedProcess(proc.args, proc.returncode, stdout, stderr)
def make_plan(self, argv, timeout: float | None = 1.5, max_output_bytes=65536,
expected_exit_code=0, extra=None, commands=None):
plan = self.artifacts / "plan.json"
if commands is None:
commands = [{"id": "probe", "argv": argv, "timeout_seconds": timeout,
"max_output_bytes": max_output_bytes, "expected_exit_code": expected_exit_code,
"claims": ["probe"]}]
data = {"hash_paths": ["input.txt"], "commands": commands}
if extra:
data.update(extra)
plan.write_text(json.dumps(data))
plan.chmod(0o600)
return plan
def pid_file(self, name="child.pid"):
path = Path(self.tmp.name) / name
self.known_pid_files.add(path)
return path
def assert_no_live_pid(self, pid_file):
self.assertTrue(pid_file.is_file(), "child must actually have executed")
pid = int(pid_file.read_text().strip())
proc = Path("/proc") / str(pid)
if not proc.exists():
return
state = (proc / "stat").read_text().split()[2]
self.assertIn(state, {"Z"}, f"descendant still live: pid={pid} state={state}")
def test_leader_exits_zero_descendant_retains_stdout_is_killed(self):
pid = self.pid_file()
code = ("import os,signal,time; p=os.fork(); "
f"(open({str(pid)!r},'w').write(str(os.getpid())) if p==0 else os._exit(0)); "
"signal.signal(signal.SIGTERM,signal.SIG_IGN); time.sleep(30)")
result = self.invoke(self.make_plan([sys.executable, "-c", code]))
self.assertEqual(result.returncode, 1, result.stderr)
record = json.loads((self.artifacts / "manifest.json").read_text())["commands"][0]
self.assertEqual(record["execution_status"], "timeout")
self.assertTrue(record["termination"]["kill_sent"])
self.assertTrue(record["termination"]["group_cleanup_completed"])
self.assert_no_live_pid(pid)
def test_leader_dies_on_term_descendant_ignores_term_and_requires_kill(self):
pid = self.pid_file()
code = ("import os,signal,time; p=os.fork(); "
f"(signal.signal(signal.SIGTERM,signal.SIG_IGN), open({str(pid)!r},'w').write(str(os.getpid())), time.sleep(30)) if p==0 else (signal.signal(signal.SIGTERM,signal.SIG_DFL), time.sleep(30))")
result = self.invoke(self.make_plan([sys.executable, "-c", code]))
self.assertEqual(result.returncode, 1, result.stderr)
record = json.loads((self.artifacts / "manifest.json").read_text())["commands"][0]
self.assertEqual(record["execution_status"], "timeout")
self.assertTrue(record["termination"]["kill_sent"])
self.assertTrue(record["termination"]["group_cleanup_completed"])
self.assert_no_live_pid(pid)
def test_normal_leader_exit_with_live_descendant_is_not_success(self):
pid = self.pid_file()
code = ("import os,time\np=os.fork()\nif p==0:\n"
f" open({str(pid)!r},'w').write(str(os.getpid()))\n"
" os.close(1)\n os.close(2)\n time.sleep(30)\nelse:\n os._exit(0)")
result = self.invoke(self.make_plan([sys.executable, "-c", code]))
self.assertEqual(result.returncode, 1, result.stderr)
self.assert_no_live_pid(pid)
record = json.loads((self.artifacts / "manifest.json").read_text())["commands"][0]
self.assertEqual(record["execution_status"], "descendant_cleanup")
self.assertTrue(record["termination"]["group_cleanup_completed"])
self.assertEqual(self.invoke(None, op="validate").returncode, 0)
def test_stdout_eof_while_leader_runs_is_bounded(self):
code = "import os,time; os.close(1); os.close(2); time.sleep(30)"
started = time.monotonic()
result = self.invoke(self.make_plan([sys.executable, "-c", code]))
self.assertLess(time.monotonic() - started, 6)
self.assertEqual(result.returncode, 1, result.stderr)
record = json.loads((self.artifacts / "manifest.json").read_text())["commands"][0]
self.assertEqual(record["execution_status"], "timeout")
def test_timeout_expected_signal_exit_cannot_count_as_success(self):
code = "import time; time.sleep(30)"
plan = self.make_plan([sys.executable, "-c", code], expected_exit_code=-15)
result = self.invoke(plan)
self.assertEqual(result.returncode, 1, result.stderr)
self.assertEqual(json.loads((self.artifacts / "manifest.json").read_text())["result"], "failure")
def test_infrastructure_failure_stops_second_command_and_failure_validates(self):
marker = self.repo / "second-ran.txt"
commands = [
{"id": "first", "argv": [sys.executable, "-c", "import time; time.sleep(30)"], "timeout_seconds": 0.2, "claims": ["first"]},
{"id": "second", "argv": [sys.executable, "-c", f"from pathlib import Path; Path({str(marker)!r}).write_text('ran')"], "timeout_seconds": 1, "claims": ["second"]},
]
plan = self.make_plan([], commands=commands)
result = self.invoke(plan)
self.assertEqual(result.returncode, 1, result.stderr)
data = json.loads((self.artifacts / "manifest.json").read_text())
self.assertEqual(len(data["commands"]), 1)
self.assertFalse(marker.exists())
validated = self.invoke(plan, op="validate", manifest="manifest.json")
self.assertEqual(validated.returncode, 0, validated.stderr)
self.assertEqual(data["result"], "failure")
def test_output_limit_retains_safe_bounded_failure(self):
plan = self.make_plan([sys.executable, "-c", "print('safe-' * 10000)"], max_output_bytes=17)
result = self.invoke(plan)
self.assertEqual(result.returncode, 1, result.stderr)
record = json.loads((self.artifacts / "manifest.json").read_text())["commands"][0]
self.assertEqual(record["execution_status"], "output_limit")
self.assertLessEqual(len(record["output"].encode()), 17)
self.assertTrue(record["output"].startswith("safe-"))
def test_invalid_utf8_output_is_bounded(self):
plan = self.make_plan([sys.executable, "-c", "import sys; sys.stdout.buffer.write(b'\\xff' * 1000)"], timeout=2, max_output_bytes=7)
result = self.invoke(plan)
self.assertEqual(result.returncode, 1, result.stderr)
record = json.loads((self.artifacts / "manifest.json").read_text())["commands"][0]
self.assertEqual(record["execution_status"], "output_limit")
self.assertLessEqual(len(record["output"].encode()), 7)
def test_secret_on_timeout_is_rejected_without_manifest_or_leak(self):
secret = "SYNTHETIC_TIMEOUT_SECRET_7f3"
env = os.environ.copy(); env["SYNTHETIC_SECRET"] = secret
plan = self.make_plan([sys.executable, "-c", "import os,time; print(os.environ['SYNTHETIC_SECRET'],flush=True); time.sleep(30)"], extra={"secret_env_names": ["SYNTHETIC_SECRET"]})
result = self.invoke(plan, env=env)
self.assertEqual(result.returncode, 2)
self.assertFalse((self.artifacts / "manifest.json").exists())
self.assertNotIn(secret, result.stdout + result.stderr)
def test_secret_on_overflow_is_rejected_without_manifest_or_leak(self):
secret = "SYNTHETIC_OVERFLOW_SECRET_9c2"
env = os.environ.copy(); env["SYNTHETIC_SECRET"] = secret
plan = self.make_plan([sys.executable, "-c", "import os; print(os.environ['SYNTHETIC_SECRET'] + 'x'*1000)"], max_output_bytes=16, extra={"secret_env_names": ["SYNTHETIC_SECRET"]})
result = self.invoke(plan, env=env)
self.assertEqual(result.returncode, 2)
self.assertFalse((self.artifacts / "manifest.json").exists())
self.assertNotIn(secret, result.stdout + result.stderr)
def test_timeout_bounds_are_rejected_before_later_command_execution(self):
invalids = [None, 0, -1, False, "0.2", float("nan"), float("inf"), 3601, 10**500]
for index, invalid in enumerate(invalids):
with self.subTest(timeout=invalid):
marker = self.repo / f"later-ran-{index}.txt"
first = {"id": "bad", "argv": ["true"], "claims": ["bad"]}
if invalid is not None:
first["timeout_seconds"] = invalid
commands = [
first,
{"id": "later", "argv": [sys.executable, "-c", f"from pathlib import Path; Path({str(marker)!r}).write_text('ran')"], "timeout_seconds": 1, "claims": ["later"]},
]
result = self.invoke(self.make_plan([], commands=commands), output=f"invalid-{index}.json")
self.assertEqual(result.returncode, 2, result.stderr)
self.assertFalse(marker.exists())
def test_archived_v2_shape_validates_read_only_but_fresh_v3_rejects(self):
plan = self.make_plan(["true"], timeout=1)
result = self.invoke(plan)
self.assertEqual(result.returncode, 0, result.stderr)
manifest = self.artifacts / "manifest.json"
obj = json.loads(manifest.read_text())
archived_plan = json.loads(plan.read_text())
archived_plan["commands"][0].pop("timeout_seconds")
plan.write_text(json.dumps(archived_plan)); plan.chmod(0o600)
obj["schema"] = "evidence-manifest/v2"
obj["plan"]["sha256"] = hashlib.sha256(plan.read_bytes()).hexdigest()
for key in ("timeout_seconds", "execution_status", "termination"):
obj["commands"][0].pop(key, None)
obj["claims"]["probe"] = obj["claims"]["probe"][:6]
manifest.write_text(json.dumps(obj, sort_keys=True, separators=(",", ":")) + "\n")
before = manifest.read_bytes()
fresh = self.invoke(plan, op="validate", manifest="manifest.json")
self.assertEqual(fresh.returncode, 2)
archived = self.invoke(plan, op="validate-archived-v2", manifest="manifest.json")
self.assertEqual(archived.returncode, 0, archived.stderr)
self.assertEqual(manifest.read_bytes(), before)
def test_tampered_completed_status_or_termination_fails_validation(self):
plan = self.make_plan(["true"], timeout=1)
self.assertEqual(self.invoke(plan).returncode, 0)
manifest = self.artifacts / "manifest.json"
original = json.loads(manifest.read_text())
for mutation in (lambda obj: obj["commands"][0].__setitem__("execution_status", "timeout"),
lambda obj: obj["commands"][0]["termination"].__setitem__("direct_child_reaped", False),
lambda obj: obj["commands"][0]["termination"].__setitem__("group_cleanup_completed", False),
lambda obj: obj["commands"][0]["termination"].__setitem__("term_sent", True),
lambda obj: obj["commands"][0]["termination"].__setitem__("kill_sent", True)):
manifest.write_text(json.dumps(original, sort_keys=True, separators=(",", ":")) + "\n")
obj = json.loads(manifest.read_text()); mutation(obj)
manifest.write_text(json.dumps(obj, sort_keys=True, separators=(",", ":")) + "\n")
self.assertEqual(self.invoke(plan, op="validate", manifest="manifest.json").returncode, 2)
def test_secret_prefix_after_safe_text_on_overflow_is_rejected(self):
secret = "SYNTHETIC_BOUNDARY_SECRET_9c2"
env = os.environ.copy(); env["SYNTHETIC_SECRET"] = secret
plan = self.make_plan([sys.executable, "-c", "import os; print('safe-banner:' + os.environ['SYNTHETIC_SECRET'] + 'x'*1000)"], max_output_bytes=20, extra={"secret_env_names": ["SYNTHETIC_SECRET"]})
result = self.invoke(plan, env=env)
self.assertEqual(result.returncode, 2, result.stderr)
self.assertFalse((self.artifacts / "manifest.json").exists())
self.assertNotIn(secret[:8], result.stdout + result.stderr)
def test_megabyte_invalid_utf8_processing_remains_bounded(self):
plan = self.make_plan([sys.executable, "-c", "import sys; sys.stdout.buffer.write(b'\\xff' * (2**21))"], timeout=2, max_output_bytes=2**20)
result = self.invoke(plan)
self.assertEqual(result.returncode, 1, result.stderr)
record = json.loads((self.artifacts / "manifest.json").read_text())["commands"][0]
self.assertEqual(record["execution_status"], "output_limit")
self.assertLessEqual(len(record["output"].encode()), 2**20)
def test_silent_command_timeout_is_bounded_and_persists_failure(self):
plan = self.make_plan([sys.executable, "-c", "import time; time.sleep(30)"])
started = time.monotonic()
result = self.invoke(plan)
elapsed = time.monotonic() - started
self.assertEqual(result.returncode, 1, result.stderr)
self.assertLess(elapsed, 6.0)
record = json.loads((self.artifacts / "manifest.json").read_text())["commands"][0]
self.assertEqual(record["execution_status"], "timeout")
self.assertTrue(record["termination"]["direct_child_reaped"])
self.assertLessEqual(len(record["output"].encode()), record["max_output_bytes"])
def test_partial_output_survives_timeout_and_is_utf8_bounded(self):
plan = self.make_plan([sys.executable, "-c", "import sys,time; sys.stdout.write('prefix\\n'); sys.stdout.flush(); time.sleep(30)"], timeout=1.5, max_output_bytes=64)
result = self.invoke(plan)
self.assertEqual(result.returncode, 1, result.stderr)
record = json.loads((self.artifacts / "manifest.json").read_text())["commands"][0]
self.assertEqual(record["output"], "prefix\n")
self.assertLessEqual(len(record["output"].encode("utf-8")), 64)
if __name__ == "__main__":
unittest.main()
+20 -8
View File
@@ -49,7 +49,7 @@ class EvidenceManifestTests(unittest.TestCase):
def plan(self, commands=None, **extra):
if commands is None:
commands = [{"id": "check", "purpose": "show input", "argv": ["git", "show", "HEAD:input.txt"], "expected_exit_code": 0, "claims": ["output"]}]
data = {"hash_paths": ["input.txt"], "commands": commands}
data = {"hash_paths": ["input.txt"], "commands": [dict({"timeout_seconds": 5}, **command) for command in commands]}
data.update(extra); p = self.artifacts / "plan.json"; p.write_text(json.dumps(data)); p.chmod(0o600); return p
def run_manifest(self, plan=None):
@@ -71,6 +71,9 @@ class EvidenceManifestTests(unittest.TestCase):
"/commands/0/output",
"/commands/0/started_at_utc",
"/commands/0/finished_at_utc",
"/commands/0/timeout_seconds",
"/commands/0/execution_status",
"/commands/0/termination",
],
)
self.assertEqual(manifest["files"][0]["sha256"], hashlib.sha256(b"hello\n").hexdigest())
@@ -115,9 +118,11 @@ class EvidenceManifestTests(unittest.TestCase):
"claims": ["bounded"],
}])
result = self.invoke("run", "--repo-root", self.repo, "--plan", plan.name, "--output", "overflow.json")
self.assertNotEqual(result.returncode, 0)
self.assertIn("exceeds max_output_bytes", result.stderr)
self.assertFalse((self.artifacts / "overflow.json").exists())
self.assertEqual(result.returncode, 1)
overflow = json.loads((self.artifacts / "overflow.json").read_text())
self.assertEqual(overflow["result"], "failure")
self.assertEqual(overflow["commands"][0]["execution_status"], "output_limit")
self.assertLessEqual(len(overflow["commands"][0]["output"].encode()), 16)
self.assertEqual(out.read_bytes(), prior)
def test_output_collision_and_password_argument_are_rejected(self):
@@ -231,12 +236,19 @@ class EvidenceManifestTests(unittest.TestCase):
obj["schema"]="evidence-manifest/v1"
del obj["artifact"]
obj["excluded_paths"]=["plan.json", "manifest.json"]
(self.repo / "plan.json").write_bytes((self.artifacts / "plan.json").read_bytes())
archived_plan=json.loads((self.artifacts / "plan.json").read_text())
archived_plan["commands"][0].pop("timeout_seconds", None)
(self.repo / "plan.json").write_text(json.dumps(archived_plan))
obj["plan"]["sha256"] = hashlib.sha256((self.repo / "plan.json").read_bytes()).hexdigest()
for key in ("timeout_seconds", "execution_status", "termination"):
obj["commands"][0].pop(key, None)
obj["claims"] = {key: pointers[:6] for key, pointers in obj["claims"].items()}
archived=self.repo / "manifest.json"
archived.write_text(json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)+"\n")
run_git(self.repo, "add", "plan.json", "manifest.json"); run_git(self.repo, "commit", "-qm", "historical evidence fixture")
before=archived.read_bytes()
self.assertEqual(self.invoke("validate-legacy", "--repo-root", self.repo, "--manifest", "manifest.json").returncode, 0)
archived_result = self.invoke("validate-legacy", "--repo-root", self.repo, "--manifest", "manifest.json")
self.assertEqual(archived_result.returncode, 0, archived_result.stderr)
self.assertEqual(archived.read_bytes(), before)
self.assertEqual(run_git(self.repo, "status", "--porcelain").stdout, "")
@@ -266,7 +278,7 @@ class ExternalEvidenceTests(unittest.TestCase):
run_git(self.repo, "add", "input.txt")
run_git(self.repo, "commit", "-qm", "initial")
self.plan = self.artifacts / "evidence-plan.json"
self.plan.write_text(json.dumps({"hash_paths": ["input.txt"], "commands": [{"id": "check", "argv": ["git", "show", "HEAD:input.txt"], "claims": ["output"]}]}))
self.plan.write_text(json.dumps({"hash_paths": ["input.txt"], "commands": [{"id": "check", "argv": ["git", "show", "HEAD:input.txt"], "timeout_seconds": 5, "claims": ["output"]}]}))
self.plan.chmod(0o600)
self.manifest = self.artifacts / "evidence-manifest.json"
@@ -459,7 +471,7 @@ class ExternalEvidenceTests(unittest.TestCase):
result = self.invoke("run", "--plan", self.plan.name, "--output", self.manifest.name)
self.assertEqual(result.returncode, 0, result.stderr)
data = json.loads(self.manifest.read_text())
self.assertEqual(data["schema"], "evidence-manifest/v2")
self.assertEqual(data["schema"], "evidence-manifest/v3")
self.assertEqual(data["source_revision"], head)
self.assertEqual(data["artifact"]["run_id"], "run-a")
self.assertEqual(data["artifact"]["issue"], 1)