harness: Issue 검증 증빙을 checkout 밖에 보관
This commit is contained in:
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user