harness: Issue 검증 증빙을 checkout 밖에 보관

This commit is contained in:
2026-10-08 01:06:16 +00:00
parent e7bcfef532
commit 6ae993887c
8 changed files with 590 additions and 56 deletions
@@ -1,7 +1,7 @@
---
name: gitea-issue-implementation
description: 승인된 Gitea Issue를 구현·검증하고 Gitea PR로 전달한다.
version: 0.4.6
version: 0.4.7
author: genichin, Hermes Agent
license: MIT
platforms: [linux]
@@ -186,7 +186,7 @@ Discovery가 `신규 작성` 또는 `수정`으로 판정한 문서만 작성한
5. Remote branch 이름은 repository convention이 있으면 이를 따르고, 없으면 `gitea-agent/issue-<number>-<short-slug>`처럼 deterministic Issue 전용 이름을 사용한다. Gitea와 remote에서 동일 이름의 branch와 열린 PR을 먼저 조회해 중복 생성하지 않는다. 이 이름으로 canonical mirror의 `refs/heads/*`에 local branch를 만들거나 Issue worktree를 attach하지 않는다.
6. 기존 requirement revision이면 final tree에 Contract Preservation Inventory를 다시 대응해 `missing=0`, `ambiguous=0`을 확인한다. 충족하지 못하면 stage하거나 commit하지 않는다.
7. 승인된 modified·untracked file을 exact allowlist로 stage하고 staged diff를 다시 읽는다. 범위 밖 파일, credential, generated artifact 또는 예상하지 못한 삭제가 있으면 commit하지 않는다.
8. Repository convention에 맞는 non-amended commit을 생성하고 base ancestry, commit tree와 worktree 상태를 검증한다.
8. Repository convention에 맞는 non-amended commit을 생성하고 base ancestry, commit tree와 worktree 상태를 검증한다. Push 전에 그 exact commit의 strict clean checkout에서 canonical external verification helper로 최종 검증을 실행·validate하고 manifest의 source SHA가 전달할 commit과 일치함을 확인한다. 실패하면 local commit을 보존하고 push하지 않는다.
9. Issue worktree는 delivery 전후 모두 detached HEAD로 유지한다. Issue 전용 remote branch가 없으면 detached final commit을 explicit repository URL과 `<final-sha>:refs/heads/<issue-branch>` refspec으로 push해 생성하고, 있으면 현재 작업이 소유한 branch이며 fast-forward 가능한 경우에만 같은 방식으로 갱신한다. `git switch -c`, `git checkout -b`, `git branch` 또는 동등한 명령으로 canonical mirror의 `refs/heads/*`에 구현용 local branch를 만들지 않는다. Default branch에는 직접 push하지 않고 force-push하지 않으며 canonical cache의 mirror remote 설정을 변경하지 않는다.
10. Target repository write 권한이 없으면 같은 push를 반복하지 않는다. Authenticated identity가 fork를 생성할 수 있으면 기존 fork·동명 branch·PR 유무와 fork parent/base SHA를 검증한 뒤 fork의 Issue 전용 branch로 non-force push하고 cross-repository PR을 사용한다. Fork도 쓸 수 없으면 permission blocker와 local commit SHA를 보고한다.
11. Remote readback으로 branch owner, repository, branch SHA가 local commit SHA와 exact-match하는지 확인한다.
@@ -246,3 +246,28 @@ Main agent는 구현 범위와 최종 결과에 책임을 진다. 독립적인 r
- blocker, 기존 실패, unresolved risk 또는 partial 상태
파일 작성, process exit, test 일부 통과, local commit 또는 delegate 보고만으로 완료를 주장하지 않는다. 모든 승인된 산출물이 worktree에 존재하고 acceptance matrix, 실제 test output, final diff, remote branch와 열린 PR의 fresh Gitea readback이 일치할 때만 구현 완료로 판정한다. Exact Issue 구현 요청에서 PR 생성은 별도 승인 대상이 아니라 완료 자체의 일부다.
## Canonical external verification evidence
검증 실행 기록은 공통 [Repository cache 운영 지침](../../../../operations/repository-cache.md)의 정본 경로 `<harness-root>/repositories/<owner>/<repo>/worktrees/issue-<number>/verification/<run-id>`에 보존한다. Product `checkout`과 artifact root를 분리하며, 회차별 evidence를 제품 `docs/verification/`에 새로 커밋하지 않는다. 기존 정본 문서·테스트·fixture·추적 파일은 자동 이동·삭제·ignore하지 않는다.
인증된 Issue/cache/worktree admission을 마친 caller가 broker Issue lease 아래 새 run directory를 mode `0700`으로 만들고 계획 파일을 mode `0600`으로 준비한다. Stage별 새 run-id를 사용한다. Helper는 directory/lease/cache를 생성·획득·복구하지 않는다. `<harness-root>`는 실제로 읽은 contract path로 고정하고 다음 변수는 그 root와 인증된 Issue/run identity에서 결정한다. 아래 명령은 harness root에서 호출한다.
```sh
python3 harness/agents/impl/skills/gitea-issue-rework/scripts/evidence_manifest.py run \
--contract-path "$HARNESS_ROOT/harness/operations/repository-cache.md" \
--repo-root "$HARNESS_ROOT/repositories/$OWNER/$REPOSITORY/worktrees/issue-$ISSUE/checkout" \
--artifact-root "$VERIFICATION_RUN_ROOT" \
--plan evidence-plan.json --output evidence-manifest.json
python3 harness/agents/impl/skills/gitea-issue-rework/scripts/evidence_manifest.py validate \
--contract-path "$HARNESS_ROOT/harness/operations/repository-cache.md" \
--repo-root "$HARNESS_ROOT/repositories/$OWNER/$REPOSITORY/worktrees/issue-$ISSUE/checkout" \
--artifact-root "$VERIFICATION_RUN_ROOT" \
--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하지 않는다.
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로 사용하지 않는다.
@@ -1,7 +1,7 @@
---
name: gitea-issue-rework
description: Gitea PR 검토 지적을 계약에 맞춰 재작업하고 검증한다.
version: 0.2.12
version: 0.2.13
author: genichin, Hermes Agent
license: MIT
platforms: [linux]
@@ -172,7 +172,7 @@ Reviewer가 rework head를 새 snapshot으로 다시 검토해야 하며 이 com
### Evidence manifest automation
모든 baseline, 통합·precommit 및 readback 검증은 `scripts/evidence_manifest.py`와 `templates/evidence-plan.json`을 사용해 exact argv, exit code, 제한된 output, 허용된 환경, source revision, 파일 hash와 표준 JSON-pointer claims를 기록한다. 명령은 argv 배열만 허용하고 명시된 최소 child-environment allowlist에서 실행하며 output 상한을 넘는 process는 중단한다. Secret·header·private-key·password 값이나 plan 금지 패턴이 발견되면 redaction하지 않고 fail-closed한다. `run`은 baseline부터 최종 precommit까지 사용하고, `validate`는 plan과 manifest를 제외한 clean tree에서 PR claim 생성 전에 실행한다. `validate`는 source ancestry와 hash를 다시 검증하며 evidence-only descendant에서는 기록된 hash path가 source blob과 동일해야 한다. 수동으로 매칭되지 않는 PR 성공·검증 주장을 만들지 않는다. Comment publish 전 remote immutable artifact의 digest를 authoritative readback으로 비교한다.
모든 baseline, 통합·precommit 및 readback 검증은 `scripts/evidence_manifest.py`와 `templates/evidence-plan.json`을 사용해 exact argv, exit code, 제한된 output, 허용된 환경, source revision, 파일 hash와 표준 JSON-pointer claims를 기록한다. 명령은 argv 배열만 허용하고 명시된 최소 child-environment allowlist에서 실행하며 output 상한을 넘는 process는 중단한다. Secret·header·private-key·password 값이나 plan 금지 패턴이 발견되면 redaction하지 않고 fail-closed한다. `run`은 baseline부터 최종 precommit까지 사용하고, `validate`는 제품 tree의 evidence exclusion 없이 strict clean checkout에서 PR claim 생성 전에 실행한다. 새 기록은 external verification root의 v2이며 exact current HEAD와 source blob·hash를 다시 검증한다. 과거 evidence-only descendant는 명시적 archived v1 read-only 호환 경로에 한정한다. 수동으로 매칭되지 않는 PR 성공·검증 주장을 만들지 않는다. Comment publish 전 remote immutable artifact의 digest를 authoritative readback으로 비교한다.
### 1. Target, identity와 review 고정
@@ -244,7 +244,7 @@ Reviewer가 rework head를 새 snapshot으로 다시 검토해야 하며 이 com
5. Exact allowlist만 stage하고 staged diff를 다시 읽는다.
6. Repository convention에 맞는 새 non-amended rework commit을 생성한다. Review별 traceability가 필요하면 commit message나 검증 기록에 PR과 review ID를 포함하되 review body를 그대로 복제하지 않는다.
7. Commit 후 clean HEAD에서 최종 formatter, test와 필요한 runtime·browser 검증을 다시 실행한다. 이 뒤 repository file을 바꾸면 영향을 받은 검증을 다시 실행한다.
8. 최종 검증 기록은 exact HEAD 또는 검증한 source tree, command, exit code, pass·skip·fail 수치, 환경, relevant output과 reviewer가 접근 가능한 evidence location을 포함하며, PR body·결과 comment의 각 실행 주장과 수치는 해당 location에 일대일 대응시킨다. Hermes 내부 tool transcript, delegate 자기보고, 삭제 예정 임시 파일, reviewer가 접근할 수 없는 local path와 요약 수치만 있는 기록은 evidence로 인정하지 않고 `evidence_gap` 또는 `partial`로 유지한다. Tracked evidence가 final HEAD 자체를 포함할 수 없어 evidence-only descendant가 생기면 검증한 commit과 final head의 ancestry 및 검증 대상 path가 불변임을 명시하고 숨기지 않는다.
8. 최종 검증 기록은 exact HEAD 또는 검증한 source tree, command, exit code, pass·skip·fail 수치, 환경, relevant output과 reviewer가 접근 가능한 evidence location을 포함하며, PR body·결과 comment의 각 실행 주장과 수치는 해당 location에 일대일 대응시킨다. Hermes 내부 tool transcript, delegate 자기보고, 삭제 예정 임시 파일, reviewer가 접근할 수 없는 local path와 요약 수치만 있는 기록은 evidence로 인정하지 않고 `evidence_gap` 또는 `partial`로 유지한다. 새 evidence는 checkout 밖 정본 verification root에 보존하여 final HEAD를 기록하며 evidence-only descendant를 만들지 않는다. 과거 tracked evidence의 archived 분석은 fresh 실행·원격 head 검증과 구분한다.
완료 조건: final local HEAD, clean state, finding별 해결 evidence와 단일 검증 기록이 일치하며, 필요한 Contract Preservation Inventory는 `missing=0`, `ambiguous=0`이다.
@@ -316,3 +316,28 @@ Reviewer가 rework head를 새 snapshot으로 다시 검토해야 하며 이 com
- 임시 resource 정리와 lock 해제 확인
모든 finding이 해결되고 검증된 final commit과 canonical 결과 comment가 기존 PR에 전달돼도 후속 reviewer가 새 head를 검토하기 전에는 `승인 완료`라고 보고하지 않는다. Final head와 exact comment readback, 전체 diff, 실제 검증 evidence와 resource·lock 정리가 일치할 때만 `재작업 전달 및 comment handoff 완료`로 판정한다.
## Canonical external verification evidence
검증 실행 기록은 공통 [Repository cache 운영 지침](../../../../operations/repository-cache.md)의 정본 경로 `<harness-root>/repositories/<owner>/<repo>/worktrees/issue-<number>/verification/<run-id>`에 보존한다. Product `checkout`과 artifact root를 분리하며, 회차별 evidence를 제품 `docs/verification/`에 새로 커밋하지 않는다. 기존 정본 문서·테스트·fixture·추적 파일은 자동 이동·삭제·ignore하지 않는다.
인증된 Issue/cache/worktree admission을 마친 caller가 broker Issue lease 아래 새 run directory를 mode `0700`으로 만들고 계획 파일을 mode `0600`으로 준비한다. Stage별 새 run-id를 사용한다. Helper는 directory/lease/cache를 생성·획득·복구하지 않는다. `<harness-root>`는 실제로 읽은 contract path로 고정하고 다음 변수는 그 root와 인증된 Issue/run identity에서 결정한다. 아래 명령은 harness root에서 호출한다.
```sh
python3 harness/agents/impl/skills/gitea-issue-rework/scripts/evidence_manifest.py run \
--contract-path "$HARNESS_ROOT/harness/operations/repository-cache.md" \
--repo-root "$HARNESS_ROOT/repositories/$OWNER/$REPOSITORY/worktrees/issue-$ISSUE/checkout" \
--artifact-root "$VERIFICATION_RUN_ROOT" \
--plan evidence-plan.json --output evidence-manifest.json
python3 harness/agents/impl/skills/gitea-issue-rework/scripts/evidence_manifest.py validate \
--contract-path "$HARNESS_ROOT/harness/operations/repository-cache.md" \
--repo-root "$HARNESS_ROOT/repositories/$OWNER/$REPOSITORY/worktrees/issue-$ISSUE/checkout" \
--artifact-root "$VERIFICATION_RUN_ROOT" \
--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하지 않는다.
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로 사용하지 않는다.
@@ -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, hashlib, json, math, os, platform, re, shutil, subprocess, sys, tempfile, time
import argparse, datetime as dt, grp, hashlib, json, math, os, platform, pwd, re, shutil, stat, subprocess, sys, tempfile, time
from pathlib import Path
HEX40 = re.compile(r"^[0-9a-f]{40}$")
@@ -16,7 +16,7 @@ SECRET_PATTERNS = [
re.compile(r"\b(?:password|passwd|secret|token)\s*[:=]", re.I),
re.compile(r"(?:^|\s)--?(?:password|passwd|secret|token)(?:\s+|=)\S+", re.I),
]
SAFE_CHILD_ENV = ("PATH", "HOME", "TMPDIR", "TMP", "TEMP", "LANG", "LC_ALL", "VIRTUAL_ENV", "UV_CACHE_DIR")
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"}
@@ -43,7 +43,100 @@ def inside(root, value, *, allow_absolute=False):
return candidate, candidate.relative_to(root.resolve()).as_posix()
def safe_entry(path, *, directory=False, mode=None, owned=True, writable_mask=0o022):
info = path.lstat()
if stat.S_ISLNK(info.st_mode) or not (stat.S_ISDIR(info.st_mode) if directory else stat.S_ISREG(info.st_mode)):
raise ValueError("unsafe path entry")
if owned and info.st_uid != os.getuid():
raise ValueError("path ownership mismatch")
actual = stat.S_IMODE(info.st_mode)
if mode is not None and actual != mode:
raise ValueError("path permission mismatch")
if directory and actual & writable_mask:
raise ValueError("writable directory boundary")
def safe_repositories(path):
safe_entry(path, directory=True, writable_mask=0o002)
info=path.lstat()
if info.st_mode & 0o020:
try:
group=grp.getgrgid(info.st_gid)
members=set(group.gr_mem) | {entry.pw_name for entry in pwd.getpwall() if entry.pw_gid==info.st_gid}
member_uids={pwd.getpwnam(name).pw_uid for name in members}
except KeyError:
raise ValueError("unverifiable repositories group")
if member_uids!={os.getuid()}:
raise ValueError("repositories group is not exclusive to executing role")
def lexical_absolute(value):
path = Path(value)
if not path.is_absolute() or any(x in ("", ".", "..") for x in str(value).split("/")[1:]):
raise ValueError("canonical absolute path required")
for ancestor in reversed((path, *path.parents)):
if ancestor.is_symlink():
raise ValueError("symlink path boundary")
return path
def artifact_context(args):
contract = lexical_absolute(args.contract_path)
if contract.name != "repository-cache.md" or contract.parent.name != "operations" or contract.parent.parent.name != "harness":
raise ValueError("invalid canonical contract path")
harness_root = contract.parents[2]
# Read-only harness code parents are not private runtime directories.
# Their type/symlink gates and the trusted NFS root follow the contract.
safe_entry(harness_root, directory=True, owned=False, writable_mask=0o002)
for directory in (contract.parent.parent, contract.parent):
safe_entry(directory, directory=True, owned=False, writable_mask=0)
safe_entry(contract, owned=False)
repo = lexical_absolute(args.repo_root)
artifact = lexical_absolute(args.artifact_root)
relative = repo.relative_to(harness_root).parts
if len(relative) != 6 or relative[0] != "repositories" or relative[3] != "worktrees" or relative[5] != "checkout":
raise ValueError("repository is not canonical Issue checkout")
owner, repository, issue_name = relative[1], relative[2], relative[4]
identifier = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,254}")
if not identifier.fullmatch(owner) or not identifier.fullmatch(repository) or not re.fullmatch(r"issue-[1-9][0-9]*", issue_name):
raise ValueError("invalid Issue identity")
if artifact.parent != repo.parent / "verification" or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", artifact.name):
raise ValueError("artifact root is not canonical verification run")
safe_repositories(harness_root / "repositories")
for directory in (repo.parents[3], repo.parents[2], repo.parents[1], repo.parent, artifact.parent, artifact):
safe_entry(directory, directory=True, mode=0o700)
safe_entry(repo, directory=True)
identity = {"owner": owner, "repository": repository, "issue": int(issue_name[6:]), "run_id": artifact.name}
return repo, artifact, identity
def artifact_file(root, value, *, must_exist=True):
if not isinstance(value, str) or not value or any(ord(c) < 32 or ord(c) == 127 for c in value) or "\\" in value:
raise ValueError("invalid artifact path")
if Path(value).is_absolute() or any(part in ("", ".", "..") for part in value.split("/")):
raise ValueError("artifact path must be run-relative")
path = root / value
for parent in reversed(path.parents):
if parent == root or parent.is_relative_to(root):
safe_entry(parent, directory=True, mode=0o700)
if must_exist:
safe_entry(path, mode=0o600)
elif path.exists() or path.is_symlink():
raise ValueError("completed artifact already exists")
return path, value
def utc_now(): return dt.datetime.now(dt.timezone.utc).isoformat().replace("+00:00", "Z")
def source_file(root, value):
if not isinstance(value, str) or not value or Path(value).is_absolute() or "\\" in value or any(ord(c) < 32 or ord(c) == 127 for c in value):
raise ValueError("invalid source path")
if any(part in ("", ".", "..", ".git") for part in value.split("/")):
raise ValueError("source path traversal or metadata path")
lexical_absolute(str(root) + "/" + value)
path, rel = inside(root, value)
safe_entry(path)
return path, rel
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")
@@ -88,8 +181,9 @@ def run_bounded(argv, root, env, max_bytes):
proc.kill(); proc.wait()
def status_paths(root):
raw = git(root, "status", "--porcelain=v1", "-z").stdout
def status_paths(root, *, include_ignored=True):
flags=("--ignored=matching", "--untracked-files=all") if include_ignored else ()
raw = git(root, "status", "--porcelain=v1", "-z", *flags).stdout
out=[]
for entry in raw.split(b"\0"):
if not entry: continue
@@ -100,8 +194,8 @@ def status_paths(root):
return out
def check_clean(root, allowed):
bad = [p for p in status_paths(root) if p not in allowed]
def check_clean(root, allowed, *, include_ignored=True):
bad = [p for p in status_paths(root, include_ignored=include_ignored) if p not in allowed]
if bad: raise ValueError("source tree is not clean: " + ", ".join(bad))
@@ -151,7 +245,8 @@ def atomic_write(path, data):
try:
with os.fdopen(fd,"w",encoding="utf-8",newline="") as f:
f.write(data); f.flush(); os.fsync(f.fileno())
os.replace(tmp,path)
os.link(tmp,path)
os.unlink(tmp)
dir_fd = os.open(path.parent, os.O_RDONLY)
try: os.fsync(dir_fd)
finally: os.close(dir_fd)
@@ -161,7 +256,7 @@ def atomic_write(path, data):
def run(args):
root=Path(args.repo_root).resolve(); plan_path,plan_rel=inside(root,args.plan); out,out_rel=inside(root,args.output)
root,artifact,identity=artifact_context(args); plan_path,plan_rel=artifact_file(artifact,args.plan); out,out_rel=artifact_file(artifact,args.output,must_exist=False)
if plan_path.suffix.lower()!=".json": raise ValueError("plan must be a JSON file")
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()
@@ -169,18 +264,17 @@ def run(args):
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,{plan_rel,out_rel})
check_clean(root,set())
source=git(root,"rev-parse","HEAD").stdout.decode().strip()
if not HEX40.fullmatch(source): raise ValueError("source revision is not full SHA")
files=[]
for item in plan.get("hash_paths",[]):
path,rel=inside(root,item)
path,rel=source_file(root,item)
if not path.is_file() or path.is_symlink(): raise ValueError(f"hash path is not a regular file: {item}")
oid,sha=tracked_blob(root,source,rel)
if hashlib.sha256(path.read_bytes()).hexdigest()!=sha: raise ValueError(f"source hash mismatch: {rel}")
files.append({"path":rel,"sha256":sha,"blob_oid":oid})
bound_paths={record["path"] for record in files}
if plan_rel==out_rel or plan_rel in bound_paths or out_rel in bound_paths: raise ValueError("plan/output paths must not overlap bound source paths")
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):
@@ -198,7 +292,7 @@ def run(args):
f"/commands/{i}/started_at_utc",
f"/commands/{i}/finished_at_utc",
]
env={k:None for k in env_keys}
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()
if "architecture" in env: env["architecture"]=platform.machine()
@@ -208,14 +302,18 @@ def run(args):
env["uv"]=text_bytes((p.stdout+p.stderr).strip()) if p and p.returncode==0 else None
# Commands are evidence producers, not trusted source mutators. Recheck the
# tree and every bound blob before persisting their claims.
check_clean(root,{plan_rel,out_rel})
if artifact_context(args)!=(root,artifact,identity): raise ValueError("artifact boundary changed during verification")
artifact_file(artifact,args.plan)
artifact_file(artifact,args.output,must_exist=False)
check_clean(root,set())
if git(root,"rev-parse","HEAD").stdout.decode().strip()!=source: raise ValueError("HEAD changed during verification")
if not plan_path.is_file() or hashlib.sha256(plan_path.read_bytes()).hexdigest()!=plan_sha256: raise ValueError("plan changed during verification")
for record in files:
if hashlib.sha256((root/record["path"]).read_bytes()).hexdigest()!=record["sha256"]:
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/v1","source_revision":source,"source_tree_clean":True,"excluded_paths":[plan_rel,out_rel],"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"}
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"}
atomic_write(out,canonical(manifest))
return 0 if success else 1
@@ -232,25 +330,40 @@ def pointer(obj,ptr):
def validate(args):
root=Path(args.repo_root).resolve(); mp,mp_rel=inside(root,args.manifest); raw=mp.read_text(encoding="utf-8"); obj=json.loads(raw)
legacy = args.op == "validate-legacy"
identity = None
if legacy:
root=Path(args.repo_root).resolve(); artifact=root; mp,mp_rel=inside(root,args.manifest)
else:
root,artifact,identity=artifact_context(args); mp,mp_rel=artifact_file(artifact,args.manifest)
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","excluded_paths","plan","generated_at","commands","files","claims","redaction","environment","limitations","result"}
if set(obj)!=required or obj["schema"]!="evidence-manifest/v1" or obj["result"] not in ("success","failure"): raise ValueError("invalid manifest structure")
if obj["source_tree_clean"] is not True or not isinstance(obj["excluded_paths"],list): raise ValueError("source tree was not clean")
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")
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 len(obj["excluded_paths"])!=2 or len(set(obj["excluded_paths"]))!=2 or any(not isinstance(x,str) for x in obj["excluded_paths"]) or obj["excluded_paths"][1]!=mp_rel: raise ValueError("invalid excluded paths")
for excluded in obj["excluded_paths"]: inside(root,excluded)
if not isinstance(obj["plan"],dict) or set(obj["plan"])!={"path","sha256"} or obj["plan"]["path"]!=obj["excluded_paths"][0] or not isinstance(obj["plan"]["sha256"],str) or not HEX64.fullmatch(obj["plan"]["sha256"]): raise ValueError("invalid plan binding")
plan_path,plan_rel=inside(root,obj["plan"]["path"])
if legacy:
if not isinstance(obj["excluded_paths"],list) or len(obj["excluded_paths"])!=2 or len(set(obj["excluded_paths"]))!=2 or any(not isinstance(x,str) for x in obj["excluded_paths"]) or obj["excluded_paths"][1]!=mp_rel: raise ValueError("invalid excluded paths")
for excluded in obj["excluded_paths"]: inside(root,excluded)
elif not isinstance(obj["artifact"],dict) or obj["artifact"] != identity or type(obj["artifact"].get("issue")) is not int:
raise ValueError("artifact identity mismatch")
if not isinstance(obj["plan"],dict) or set(obj["plan"])!={"path","sha256"} or not isinstance(obj["plan"]["sha256"],str) or not HEX64.fullmatch(obj["plan"]["sha256"]): raise ValueError("invalid plan binding")
if legacy:
if obj["plan"]["path"]!=obj["excluded_paths"][0]: raise ValueError("invalid plan binding")
plan_path,plan_rel=inside(root,obj["plan"]["path"])
else:
plan_path,plan_rel=artifact_file(artifact,obj["plan"]["path"])
if plan_rel==mp_rel: raise ValueError("plan and manifest overlap")
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)
check_clean(root,set(obj["excluded_paths"]))
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")
if git(root,"cat-file","-e",src,check=False).returncode: raise ValueError("missing source revision")
head=git(root,"rev-parse","HEAD").stdout.decode().strip()
if not legacy and src!=head: raise ValueError("source revision is not current HEAD")
if git(root,"merge-base","--is-ancestor",src,head,check=False).returncode: raise ValueError("source is not ancestor")
if not isinstance(obj["environment"],dict) or not obj["environment"] or any(not isinstance(k,str) or k not in SAFE_ENV or not (isinstance(v,str) or v is None) for k,v in obj["environment"].items()): raise ValueError("invalid environment")
if set(obj["environment"])!=set(requested_env): raise ValueError("environment does not match plan")
@@ -262,16 +375,17 @@ def validate(args):
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")
planned_paths=[inside(root,item)[1] for item in plan.get("hash_paths",[])]
path_reader=inside if legacy else source_file
planned_paths=[path_reader(root,item)[1] for item in plan.get("hash_paths",[])]
if [f.get("path") if isinstance(f,dict) else None for f in obj["files"]]!=planned_paths: raise ValueError("file records do not match plan")
claim_seen=set()
for f in obj["files"]:
if not isinstance(f,dict) or set(f)!={"path","sha256","blob_oid"}: raise ValueError("invalid file record")
_,rel=inside(root,f["path"]); path=root/rel
path,rel=path_reader(root,f["path"])
if not HEX64.fullmatch(f["sha256"]) or not HEX40.fullmatch(f["blob_oid"]): raise ValueError("invalid file identity")
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 any(f["path"] in set(obj["excluded_paths"]) for f in obj["files"]): raise ValueError("evidence paths overlap bound source paths")
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")
command_ids=set()
for i,c in enumerate(obj["commands"]):
@@ -311,8 +425,14 @@ def validate(args):
def main():
p=argparse.ArgumentParser(); sub=p.add_subparsers(dest="op",required=True)
r=sub.add_parser("run"); r.add_argument("--repo-root",required=True); r.add_argument("--plan",required=True); r.add_argument("--output",required=True)
v=sub.add_parser("validate"); v.add_argument("--repo-root",required=True); v.add_argument("--manifest",required=True)
r=sub.add_parser("run"); r.add_argument("--plan",required=True); r.add_argument("--output",required=True)
v=sub.add_parser("validate"); v.add_argument("--manifest",required=True)
for parser in (r,v):
parser.add_argument("--contract-path",required=True)
parser.add_argument("--repo-root",required=True)
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)
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)
@@ -1,6 +1,6 @@
{
"environment": ["os", "kernel", "architecture", "python"],
"command_env_allowlist": ["PATH", "HOME", "TMPDIR", "LANG", "VIRTUAL_ENV", "UV_CACHE_DIR"],
"command_env_allowlist": ["PATH", "HOME", "TMPDIR", "LANG", "VIRTUAL_ENV", "UV_CACHE_DIR", "PYTHONDONTWRITEBYTECODE"],
"secret_env_names": ["PROJECT_TEST_TOKEN"],
"forbidden_regexes": [],
"hash_paths": ["src/project.py", "tests/test_project.py"],
@@ -8,7 +8,7 @@
{
"id": "focused-tests",
"purpose": "Run the focused verification",
"argv": ["python3", "-m", "unittest", "tests.test_project", "-v"],
"argv": ["python3", "-B", "-m", "unittest", "tests.test_project", "-v"],
"expected_exit_code": 0,
"max_output_bytes": 65536,
"claims": ["focused-result"]