diff --git a/harness/agents/impl/skills/gitea-issue-implementation/SKILL.md b/harness/agents/impl/skills/gitea-issue-implementation/SKILL.md index 59a9cf3..d71ac6a 100644 --- a/harness/agents/impl/skills/gitea-issue-implementation/SKILL.md +++ b/harness/agents/impl/skills/gitea-issue-implementation/SKILL.md @@ -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 --repo-root --artifact-root --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로 자동 변환하지 않는다. diff --git a/harness/agents/impl/skills/gitea-issue-rework/SKILL.md b/harness/agents/impl/skills/gitea-issue-rework/SKILL.md index 729879e..c4d9735 100644 --- a/harness/agents/impl/skills/gitea-issue-rework/SKILL.md +++ b/harness/agents/impl/skills/gitea-issue-rework/SKILL.md @@ -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 --repo-root --artifact-root --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로 자동 변환하지 않는다. diff --git a/harness/agents/impl/skills/gitea-issue-rework/scripts/evidence_manifest.py b/harness/agents/impl/skills/gitea-issue-rework/scripts/evidence_manifest.py index d87b9b5..7c64ef3 100644 --- a/harness/agents/impl/skills/gitea-issue-rework/scripts/evidence_manifest.py +++ b/harness/agents/impl/skills/gitea-issue-rework/scripts/evidence_manifest.py @@ -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) diff --git a/harness/agents/impl/skills/gitea-issue-rework/templates/evidence-plan.json b/harness/agents/impl/skills/gitea-issue-rework/templates/evidence-plan.json index c41646d..82551da 100644 --- a/harness/agents/impl/skills/gitea-issue-rework/templates/evidence-plan.json +++ b/harness/agents/impl/skills/gitea-issue-rework/templates/evidence-plan.json @@ -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"] } ], diff --git a/harness/agents/spec/skills/gitea-implementation-spec-review/SKILL.md b/harness/agents/spec/skills/gitea-implementation-spec-review/SKILL.md index 40867f2..5174c15 100644 --- a/harness/agents/spec/skills/gitea-implementation-spec-review/SKILL.md +++ b/harness/agents/spec/skills/gitea-implementation-spec-review/SKILL.md @@ -311,10 +311,17 @@ Review write 응답, process exit, PR body의 test 주장, delegate 보고 또 ## External Issue verification evidence boundary -제품 검증 실행 evidence의 정본은 공통 [Repository cache 운영 지침](../../../../operations/repository-cache.md)의 `/repositories///worktrees/issue-/verification/`다. 구현·재작업의 `evidence-manifest/v2`와 plan/output을 read-only로 소비하며 제품 저장소에 회차별 증빙 파일을 새로 요구하지 않는다. 기존 genuine 정본 문서와 fixture의 존재 자체를 결함으로 판정하거나 삭제를 요구하지 않는다. +제품 검증 실행 evidence의 정본은 공통 [Repository cache 운영 지침](../../../../operations/repository-cache.md)의 `/repositories///worktrees/issue-/verification/`다. 구현·재작업의 `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 --repo-root --artifact-root --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로 자동 변환하지 않는다. diff --git a/harness/operations/repository-cache.md b/harness/operations/repository-cache.md index 11b73fe..e95073b 100644 --- a/harness/operations/repository-cache.md +++ b/harness/operations/repository-cache.md @@ -555,10 +555,17 @@ Issue별 검증 실행 기록의 정본은 `/repositories// - evidence `run` 전에 다음 명령으로 live broker lease와 exact harness root·Issue identity를 인증한다: `python3 harness/lib/verification_root.py prepare --root --owner --repository --issue --run-id --handle `. 이 helper는 `verification/`과 `/`를 각각 별도 생성하고, 각 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/`로 분리한다. 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 --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 --repo-root --artifact-root --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로 자동 변환하지 않는다. diff --git a/harness/tests/common/test_verification_contract_docs.py b/harness/tests/common/test_verification_contract_docs.py index a783af0..5eb3346 100644 --- a/harness/tests/common/test_verification_contract_docs.py +++ b/harness/tests/common/test_verification_contract_docs.py @@ -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"]): diff --git a/harness/tests/impl/test_evidence_command_deadlines.py b/harness/tests/impl/test_evidence_command_deadlines.py new file mode 100644 index 0000000..a11c9d0 --- /dev/null +++ b/harness/tests/impl/test_evidence_command_deadlines.py @@ -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() diff --git a/harness/tests/impl/test_evidence_manifest.py b/harness/tests/impl/test_evidence_manifest.py index 72cd276..72adc6e 100644 --- a/harness/tests/impl/test_evidence_manifest.py +++ b/harness/tests/impl/test_evidence_manifest.py @@ -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)