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"]
@@ -1,7 +1,7 @@
---
name: gitea-implementation-spec-review
description: 승인된 계약에 대해 Gitea 구현 PR 적합성을 검토한다.
version: 0.2.3
version: 0.2.4
author: genichin, Hermes Agent
license: MIT
platforms: [linux]
@@ -296,3 +296,13 @@ terminal(command="python3 harness/agents/spec/skills/gitea-implementation-spec-r
- partial, blocked 또는 fail-closed 상태
Review write 응답, process exit, PR body의 test 주장, delegate 보고 또는 native state만으로 완료를 주장하지 않는다. Exact review body·commit binding과 authoritative collection readback, 외부 상태 불변성과 replay가 모두 확인된 경우에만 완료로 판정한다.
## External Issue verification evidence boundary
제품 검증 실행 evidence의 정본은 공통 [Repository cache 운영 지침](../../../../operations/repository-cache.md)의 `<harness-root>/repositories/<owner>/<repo>/worktrees/issue-<number>/verification/<run-id>`다. 구현·재작업의 `evidence-manifest/v2`와 plan/output을 read-only로 소비하며 제품 저장소에 회차별 증빙 파일을 새로 요구하지 않는다. 기존 genuine 정본 문서와 fixture의 존재 자체를 결함으로 판정하거나 삭제를 요구하지 않는다.
- 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]`로 기록한다.
+19 -2
View File
@@ -35,7 +35,9 @@ Repository cache 경로는 고정된 machine-local 절대경로나 현재 작업
└── worktrees/
└── issue-<issue-number>/
├── state.json
└── checkout/
├── checkout/
└── verification/
└── <run-id>/
```
- `.locks/<owner>/<repository>.bootstrap.lock`은 cache가 아직 없을 때도 사용할 수 있는 초기화 mutex다.
@@ -242,7 +244,7 @@ Dirty worktree, untracked 파일 또는 base 이후 commit은 진행 중 구현
정상 worktree 제거는 사용자가 exact worktree 폐기를 승인했거나, 승인된 delivery가 완료되어 remote branch SHA가 local HEAD와 일치함을 authoritative readback한 경우에만 수행한다. 제거 전 base SHA, final HEAD SHA, dirty·untracked 상태, 미전달 commit과 remote 보존 여부를 확인하고 보고한다. Dirty 상태, 미전달 commit 또는 판정 불가능한 파일이 있으면 `--force`, filesystem 재귀 삭제, reset 또는 clean으로 우회하지 않는다.
제거할 때는 broker Issue lease를 유지하고 같은 handle의 `acquire_repository` request로 repository lease를 중첩한 상태에서 worktree lock을 해제하고 Git operation으로 checkout과 registration을 먼저 제거한다. 두 대상의 부재를 확인한 뒤 exact `state.json`과 비어 있는 Issue worktree directory만 제거하며, 현재 run이 만든 state temporary file이 있으면 identity를 확인한 뒤 함께 제거한다. 다른 파일이나 하위 directory가 남아 있으면 재귀 삭제하지 않고 중단한다. 마지막으로 exact checkout, Git registration, `state.json`과 Issue worktree directory가 모두 없는지 다시 검증하고 `release_repository`를 request한다.
제거할 때는 broker Issue lease를 유지하고 같은 handle의 `acquire_repository` request로 repository lease를 중첩한 상태에서 worktree lock을 해제하고 Git operation으로 checkout과 registration을 먼저 제거한다. 제거 전에 아래 verification 계약에 따라 보존할 artifact의 identity, manifest·plan digest와 읽기 가능 여부를 확인한다. 두 대상의 부재를 확인한 뒤 exact `state.json`과 identity가 확인된 현재 run의 state temporary file만 제거한다. `verification/`이 남으면 Issue directory를 보존한다. 다른 알 수 없는 파일·directory가 남아 있으면 자동 정리하지 않고 partial로 보고한다. 비어 있는 Issue directory만 제거할 수 있다. 마지막으로 exact checkout, Git registration과 `state.json`의 부재 및 보존한 verification의 경로·digest가 제거 전과 같음을 다시 확인하고 `release_repository`를 request한다. Verification이 정상 보존돼 Issue directory가 남는 것은 checkout 제거 실패가 아니다.
## Credential 안전
@@ -301,3 +303,18 @@ Issue worktree workflow의 완료 보고에는 다음도 포함한다.
- partial 또는 fail-closed 상태
프로세스 exit와 clone/fetch 또는 worktree 명령의 성공 메시지만으로 완료를 주장하지 않는다. Cache workflow는 final cache path와 Gitea authoritative state를, Issue worktree workflow는 여기에 exact worktree registration, checkout, state와 Issue snapshot을 더해 모두 다시 읽은 경우에만 완료로 판정한다.
## Canonical external Issue verification artifacts
Issue별 검증 실행 기록의 정본은 `<harness-root>/repositories/<owner>/<repo>/worktrees/issue-<number>/verification/<run-id>`이다. `<harness-root>`는 실제로 읽은 `harness/operations/repository-cache.md`에서 결정한다. 제품 source는 형제 `checkout/`에 있고 verification은 제품 Git tree 밖에 있다. 구현·재작업의 회차별 plan, evidence manifest, command output, RED/GREEN 결과, finding matrix와 head별 provenance를 제품 `docs/verification/`에 새로 누적하거나 evidence-only commit을 만들지 않는다. 승인된 제품 요구사항이 명시적으로 요구하는 정본 문서와 지속적인 테스트·synthetic fixture·재현 스크립트는 제품에 유지할 수 있다. 기존 파일을 자동 이동·삭제하거나 광범위 `.gitignore` 변경으로 처리하지 않는다.
- 경로 identity는 인증된 exact owner, repository와 양의 Issue 번호에서 결정한다. 기존 cache identifier 규칙을 따르며 `issue-<number>`는 선행 0 없는 십진수다. `<run-id>`는 `[A-Za-z0-9][A-Za-z0-9_.-]{0,127}` 형식의 새 식별자다. Stage, source revision 또는 실행을 바꾸면 새 run-id를 사용한다. Dot segment, 절대 artifact file 경로, traversal, symlink와 임의 temporary/artifact root는 거부한다. `<owner>/<repo>`를 성공한 lookup 결과에 맞춰 자동 정규화하지 않는다.
- Caller가 인증된 Issue identity와 cache/worktree admission을 확인하고 **broker Issue lease** 아래 `verification/`과 새 run directory를 만든다. Directory는 실행 UID 소유 mode `0700`, plan·manifest·output 파일은 실행 UID 소유 mode `0600`으로 만들고 즉시 `lstat`으로 확인한다. 기존 cache bootstrap, repository lock과 lease 계약은 바꾸지 않는다. Artifact helper는 lease·Gitea identity·Git registration을 대신 인증하거나 directory를 만들고 cache/권한을 복구하지 않는다.
- `repo_root`는 canonical `checkout`, `artifact_root`는 exact `verification/<run-id>`로 분리한다. Plan의 `hash_paths`는 repository-relative source 경로이고 plan/output/manifest 인자는 run-relative artifact 경로다. 두 namespace는 이름이 같아도 서로 다른 파일이다. Source tree의 tracked·untracked·ignored 파일을 함께 검사하고 evidence 경로 exclusion을 허용하지 않는다. Python은 `-B` 또는 allowlist에 선언한 `PYTHONDONTWRITEBYTECODE=1`, pytest는 필요하면 `-p no:cacheprovider`로 checkout 안의 자동 cache 생성을 막는다. 테스트 임시 산출물·dependency environment도 checkout 밖 승인된 경로를 사용한다. Helper가 ignored cache를 자동 삭제하거나 clean 상태로 간주하지 않는다.
- 새 실행은 `evidence-manifest/v2`로 source full SHA, tracked source blob SHA·byte digest, owner/repository/Issue/run-id, plan path·digest, 실제 argv·exit code·환경·bounded output·시간과 claim pointer를 결합한다. Existing manifest는 no-clobber 원자 게시로 덮어쓰지 않는다. 완료 기록은 불변으로 취급하고 실패·재실행도 새 run-id에 보존한다. Helper의 `validate` 성공은 local artifact 정합성일 뿐 원격 PR head 또는 provider readback 검증을 대체하지 않는다.
- Baseline, integration, precommit과 remote-head 검증은 각 stage에서 검증한 **그 commit의 clean source**에 결합한다. Dirty 개발 tree는 보존하되 helper가 성공 증거로 게시하지 않는다. Final tracked 제품 산출물을 commit한 뒤 전달할 exact commit에서 검증하고, 실패하면 push하지 않는다. 모든 PR/결과 보고는 같은 immutable record의 command/result를 사용한다.
- Same-UID 배포에서는 인증된 reviewer가 producer 기록을 read-only로 읽는다. 다른 UID나 host에서는 승인된 credential-free read-only export/immutable artifact 전달 수단을 명시한다. 전달 receipt는 repository/Issue/run-id, source SHA, manifest·plan·output digest와 실제 읽기 검증을 묶는다. UID가 다르다고 chmod/chown하거나 임의 sibling path를 허용하지 않는다. 경로 문자열이나 구현자 요약만으로 reviewer-accessible이라고 판단하지 않으며, 접근/무결성/source binding 실패는 `evidence_gap`이다.
- Reviewer는 검증 source를 immutable provider/Git blob에 독립 결합한다. Producer manifest의 테스트 결과는 구현자 증거로 표시하고 reviewer 자체 실행으로 표현하지 않는다. Spec native-review snapshot/result와 운영 자료는 trusted `harness/.runtime`에 남겨도 되며 제품 실행 evidence와 혼동하지 않는다. Spec reviewer는 moving implementation checkout을 근거나 test 실행 장소로 재사용하지 않는다.
- Checkout 제거, broker release와 verification 폐기는 별개다. Checkout과 state를 제거해도 verification과 그 Issue directory는 보존한다. 보존 기간의 자동 만료나 재귀 삭제를 도입하지 않는다. Artifact 폐기는 별도의 exact 경로 승인, 활성 consumer·delivery/readback 의존성 조사와 digest 기록을 거쳐야 한다. 생성 실패의 미완성 run도 경로 identity가 입증된 현재 run 범위만 정리할 수 있고, 완료 기록은 자동 삭제하지 않는다.
- 인증정보를 사용했다는 사실은 남길 수 있지만 값·토큰·비밀번호·cookie·연결 문자열은 보존하지 않고 `[REDACTED]`로 기록한다. 새 증거를 만들기 위해 credential을 plan/argv/output에 넣지 않는다. Helper는 알려진 secret·header 유출을 fail-closed하고, 이것을 완전한 sandbox 또는 모든 비밀에 대한 자동 탐지로 과장하지 않는다.
- 기존 `evidence-manifest/v1`은 명시적 `validate-legacy --repo-root <historical-checkout> --manifest <repository-relative-manifest>`로 read-only 호환 확인만 한다. 새 `run`/`validate`에는 v1·repository-relative evidence 생성 fallback이 없다. Archived replay는 fresh source 실행·원격 head/readback 또는 새 publication 완료를 증명하지 않는다. Migration도 별도 승인 없이 기존 제품 evidence를 수정하지 않는다.
@@ -0,0 +1,82 @@
import re
import subprocess
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
SCRIPT = ROOT / "harness/agents/impl/skills/gitea-issue-rework/scripts/evidence_manifest.py"
CACHE = ROOT / "harness/operations/repository-cache.md"
SKILLS = {
"implementation": ROOT / "harness/agents/impl/skills/gitea-issue-implementation/SKILL.md",
"rework": ROOT / "harness/agents/impl/skills/gitea-issue-rework/SKILL.md",
"review": ROOT / "harness/agents/spec/skills/gitea-implementation-spec-review/SKILL.md",
}
CANONICAL = "<harness-root>/repositories/<owner>/<repo>/worktrees/issue-<number>/verification/<run-id>"
class VerificationContractDocumentationTests(unittest.TestCase):
def test_evidence_plan_disables_python_bytecode_in_clean_checkout(self):
import json
template = ROOT / "harness/agents/impl/skills/gitea-issue-rework/templates/evidence-plan.json"
plan = json.loads(template.read_text())
self.assertIn("-B", plan["commands"][0]["argv"])
self.assertIn("PYTHONDONTWRITEBYTECODE", plan["command_env_allowlist"])
def test_all_roles_reference_canonical_external_artifacts(self):
for path in (CACHE, *SKILLS.values()):
with self.subTest(path=path.name):
text = path.read_text(encoding="utf-8")
self.assertIn(CANONICAL, text)
self.assertIn("evidence_gap", text)
self.assertIn("[REDACTED]", text)
self.assertIn("evidence-manifest/v2", text)
def test_examples_match_real_helper_cli_not_unsupported_options(self):
for path in (SKILLS["implementation"], SKILLS["rework"]):
section = path.read_text().split("## Canonical external verification evidence", 1)[1]
match = re.search(r"```sh\n(.*?)```", section, re.S)
assert match is not None
block = match.group(1).replace("\\\n", " ")
commands = [line for line in block.splitlines() if line.startswith("python3 ")]
self.assertEqual(len(commands), 2)
for op, command in zip(("run", "validate"), commands):
with self.subTest(skill=path.parent.name, op=op):
help_result = subprocess.run([sys.executable, str(SCRIPT), op, "--help"], text=True, capture_output=True)
self.assertEqual(help_result.returncode, 0)
supported = set(re.findall(r"--[a-z-]+", help_result.stdout))
declared = set(re.findall(r"--[a-z-]+", command))
required = {"--contract-path", "--repo-root", "--artifact-root"} | ({"--plan", "--output"} if op == "run" else {"--manifest"})
self.assertEqual(declared, required)
self.assertTrue(declared <= supported)
self.assertIn("exact current HEAD", section)
self.assertIn("validate-legacy", section)
self.assertIn("exclusion 없음", section)
def test_checkout_cleanup_preserves_verification_without_recursive_delete(self):
text = CACHE.read_text()
cleanup = text.split("## Worktree 실패 처리와 제거", 1)[1].split("## Credential 안전", 1)[0]
self.assertIn("`verification/`이 남으면 Issue directory를 보존한다", cleanup)
self.assertIn("Issue directory가 남는 것은 checkout 제거 실패가 아니다", cleanup)
self.assertIn("digest가 제거 전과 같음", cleanup)
self.assertIn("Artifact 폐기는 별도의 exact 경로 승인", text)
self.assertIn("mode `0700`", text)
self.assertIn("mode `0600`", text)
def test_spec_review_uses_immutable_source_and_not_moving_checkout(self):
section = SKILLS["review"].read_text().split("## External Issue verification evidence boundary", 1)[1]
self.assertIn("제품 테스트를 실행하지 않", section)
self.assertIn("moving implementation checkout", section)
self.assertIn("authoritative blob evidence", section)
self.assertIn("trusted `harness/.runtime`", section)
self.assertIn("Producer evidence와 reviewer 실행을 구분", section)
def test_rework_no_longer_excludes_repo_evidence_from_clean_tree(self):
text = SKILLS["rework"].read_text()
self.assertNotIn("plan과 manifest를 제외한 clean tree", text)
self.assertIn("새 evidence는 checkout 밖", text)
self.assertIn("archived v1 read-only", text)
if __name__ == "__main__":
unittest.main()
+271 -16
View File
@@ -16,9 +16,22 @@ def run_git(cwd, *args):
class EvidenceManifestTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.repo = Path(self.tmp.name) / "repo"
self.repo.mkdir()
self.tmp = tempfile.TemporaryDirectory(dir=os.environ.get("TMPDIR", "/opt/data/cache/scratch"))
self.root = Path(self.tmp.name)
self.contract = self.root / "harness/operations/repository-cache.md"
self.contract.parent.mkdir(parents=True)
self.contract.write_text("# Repository cache 운영 지침\n")
self.repo = self.root / "repositories/owner/project/worktrees/issue-1/checkout"
self.artifacts = self.repo.parent / "verification/run-a"
self.repo.mkdir(parents=True)
self.artifacts.mkdir(parents=True)
for path in (self.root / "repositories", *self.repo.parents):
if path == self.root:
break
path.chmod(0o700)
self.repo.chmod(0o700)
self.artifacts.parent.chmod(0o700)
self.artifacts.chmod(0o700)
run_git(self.repo, "init", "-q")
run_git(self.repo, "config", "user.email", "test@example.invalid")
run_git(self.repo, "config", "user.name", "Test")
@@ -29,17 +42,19 @@ class EvidenceManifestTests(unittest.TestCase):
def tearDown(self): self.tmp.cleanup()
def invoke(self, *args, env=None):
return subprocess.run([sys.executable, str(SCRIPT), *map(str, args)], cwd=self.repo, text=True, capture_output=True, env=env)
op, *rest = args
common = [] if op == "validate-legacy" else ["--contract-path", str(self.contract), "--artifact-root", str(self.artifacts)]
return subprocess.run([sys.executable, str(SCRIPT), str(op), *common, *map(str, rest)], cwd=self.repo, text=True, capture_output=True, env=env)
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.update(extra); p = self.repo / "plan.json"; p.write_text(json.dumps(data)); return p
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):
if plan is None: plan = self.plan()
out = self.repo / "manifest.json"; result = self.invoke("run", "--repo-root", self.repo, "--plan", plan.name, "--output", "manifest.json"); return result, out
out = self.artifacts / "manifest.json"; result = self.invoke("run", "--repo-root", self.repo, "--plan", plan.name, "--output", "manifest.json"); return result, out
def test_run_captures_success_claim_and_hash(self):
result, out = self.run_manifest(); self.assertEqual(result.returncode, 0, result.stderr)
@@ -82,7 +97,7 @@ class EvidenceManifestTests(unittest.TestCase):
"claims": ["environment"],
}]
plan = self.plan(command)
out = self.repo / "manifest.json"
out = self.artifacts / "manifest.json"
result = self.invoke("run", "--repo-root", self.repo, "--plan", plan.name, "--output", out.name, env=env)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(out.read_text())["commands"][0]["output"], "None\n")
@@ -99,13 +114,15 @@ class EvidenceManifestTests(unittest.TestCase):
"max_output_bytes": 16,
"claims": ["bounded"],
}])
result = self.invoke("run", "--repo-root", self.repo, "--plan", plan.name, "--output", out.name)
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(out.read_bytes(), prior)
def test_output_collision_and_password_argument_are_rejected(self):
plan = self.plan()
result = self.invoke("run", "--repo-root", self.repo, "--plan", plan.name, "--output", "input.txt")
result = self.invoke("run", "--repo-root", self.repo, "--plan", plan.name, "--output", "../checkout/input.txt")
self.assertNotEqual(result.returncode, 0)
self.assertEqual((self.repo / "input.txt").read_text(), "hello\n")
@@ -141,7 +158,7 @@ class EvidenceManifestTests(unittest.TestCase):
env = os.environ.copy(); env["TEST_SECRET"] = "never-write-this"
p = self.plan([{"argv": [sys.executable, "-c", "import os; print(os.environ['TEST_SECRET'])"]}], secret_env_names=["TEST_SECRET"])
result = self.invoke("run", "--repo-root", self.repo, "--plan", "plan.json", "--output", "manifest.json", env=env)
self.assertNotEqual(result.returncode, 0); self.assertFalse((self.repo / "manifest.json").exists())
self.assertNotEqual(result.returncode, 0); self.assertFalse((self.artifacts / "manifest.json").exists())
def test_source_drift_hash_mismatch_rejected(self):
result, out = self.run_manifest(); self.assertEqual(result.returncode, 0)
@@ -186,13 +203,13 @@ class EvidenceManifestTests(unittest.TestCase):
data=json.loads(out.read_text()); data["claims"]["bad"]="/missing"; out.write_text(json.dumps(data, sort_keys=True, separators=(",", ":"))+"\n")
self.assertNotEqual(self.invoke("validate", "--repo-root", self.repo, "--manifest", out.name).returncode, 0)
def test_validate_rejects_forged_plan_exclusion(self):
def test_v2_validate_rejects_forged_plan_exclusion(self):
result, out = self.run_manifest()
self.assertEqual(result.returncode, 0, result.stderr)
(self.repo / "plan.json").unlink()
(self.artifacts / "plan.json").unlink()
(self.repo / "dirty.txt").write_text("dirty\n")
data = json.loads(out.read_text())
data["excluded_paths"][0] = "dirty.txt"
data["excluded_paths"] = ["dirty.txt", "manifest.json"]
out.write_text(json.dumps(data, sort_keys=True, separators=(",", ":")) + "\n")
self.assertNotEqual(self.invoke("validate", "--repo-root", self.repo, "--manifest", out.name).returncode, 0)
@@ -208,10 +225,248 @@ class EvidenceManifestTests(unittest.TestCase):
out.write_text(json.dumps(data, sort_keys=True, separators=(",", ":")) + "\n")
self.assertNotEqual(self.invoke("validate", "--repo-root", self.repo, "--manifest", out.name).returncode, 0)
def test_evidence_only_descendant_validates(self):
def test_archived_v1_descendant_validates_read_only(self):
result, out = self.run_manifest(); self.assertEqual(result.returncode, 0)
(self.repo / "evidence.txt").write_text("evidence\n"); run_git(self.repo, "add", "evidence.txt"); run_git(self.repo, "commit", "-qm", "evidence")
self.assertEqual(self.invoke("validate", "--repo-root", self.repo, "--manifest", "manifest.json").returncode, 0)
obj=json.loads(out.read_text())
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=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)
self.assertEqual(archived.read_bytes(), before)
self.assertEqual(run_git(self.repo, "status", "--porcelain").stdout, "")
class ExternalEvidenceTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(dir=os.environ.get("TMPDIR", "/opt/data/cache/scratch"))
self.root = Path(self.tmp.name)
self.contract = self.root / "harness/operations/repository-cache.md"
self.contract.parent.mkdir(parents=True)
self.contract.write_text("# Repository cache 운영 지침\n", encoding="utf-8")
self.repo = self.root / "repositories/owner/project/worktrees/issue-1/checkout"
self.artifacts = self.repo.parent / "verification/run-a"
self.repo.mkdir(parents=True)
self.artifacts.mkdir(parents=True)
for path in (self.root / "repositories", *self.repo.parents):
if path == self.root:
break
path.chmod(0o700)
self.repo.chmod(0o700)
self.artifacts.parent.chmod(0o700)
self.artifacts.chmod(0o700)
run_git(self.repo, "init", "-q")
run_git(self.repo, "config", "user.email", "test@example.invalid")
run_git(self.repo, "config", "user.name", "Test")
(self.repo / "input.txt").write_text("hello\n")
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.chmod(0o600)
self.manifest = self.artifacts / "evidence-manifest.json"
def tearDown(self):
self.tmp.cleanup()
def invoke(self, op, *args):
return subprocess.run([sys.executable, str(SCRIPT), op, "--contract-path", str(self.contract), "--repo-root", str(self.repo), "--artifact-root", str(self.artifacts), *map(str, args)], text=True, capture_output=True)
def test_ignored_source_input_is_rejected_for_run_and_validate(self):
(self.repo / ".gitignore").write_text("ignored.txt\n")
run_git(self.repo, "add", ".gitignore")
run_git(self.repo, "commit", "-qm", "ignore fixture")
ignored=self.repo / "ignored.txt"
ignored.write_text("unbound input\n")
result=self.invoke("run", "--plan", self.plan.name, "--output", self.manifest.name)
self.assertEqual(result.returncode, 2, result.stderr)
self.assertIn("not clean", result.stderr)
self.assertFalse(self.manifest.exists())
ignored.unlink()
self.assertEqual(self.invoke("run", "--plan", self.plan.name, "--output", self.manifest.name).returncode, 0)
prior=self.manifest.read_bytes()
ignored.write_text("unbound input again\n")
result=self.invoke("validate", "--manifest", self.manifest.name)
self.assertEqual(result.returncode, 2, result.stderr)
self.assertIn("not clean", result.stderr)
self.assertEqual(self.manifest.read_bytes(), prior)
def test_canonical_control_directory_modes_match_installed_harness(self):
# The cache contract permits a trusted NFS root with group write and
# imposes no runtime-directory mode on read-only harness code parents.
self.root.chmod(0o770)
self.contract.parent.parent.chmod(0o777)
result=self.invoke("run", "--plan", self.plan.name, "--output", self.manifest.name)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(self.invoke("validate", "--manifest", self.manifest.name).returncode, 0)
self.assertEqual(self.root.stat().st_mode & 0o777, 0o770)
self.assertEqual(self.contract.parent.parent.stat().st_mode & 0o777, 0o777)
def test_same_filename_in_source_and_artifacts_is_not_a_collision(self):
result=self.invoke("run", "--plan", self.plan.name, "--output", "input.txt")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual((self.repo / "input.txt").read_text(), "hello\n")
self.assertEqual(self.invoke("validate", "--manifest", "input.txt").returncode, 0)
def test_real_linked_detached_worktree_preserves_git_registration(self):
seed=self.root / "seed"
self.repo.rename(seed)
bare=self.repo.parents[2] / "repository.git"
subprocess.run(["git", "clone", "--bare", str(seed), str(bare)], check=True, capture_output=True)
subprocess.run(["git", "--git-dir", str(bare), "worktree", "add", "--detach", str(self.repo), "HEAD"], check=True, capture_output=True)
subprocess.run(["git", "--git-dir", str(bare), "worktree", "lock", str(self.repo)], check=True, capture_output=True)
before=run_git(self.repo, "worktree", "list", "--porcelain").stdout
result=self.invoke("run", "--plan", self.plan.name, "--output", self.manifest.name)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(self.invoke("validate", "--manifest", self.manifest.name).returncode, 0)
self.assertEqual(run_git(self.repo, "worktree", "list", "--porcelain").stdout, before)
self.assertNotEqual(run_git(self.repo, "rev-parse", "--git-common-dir").stdout.strip(), ".git")
self.assertEqual(run_git(self.repo, "status", "--porcelain").stdout, "")
def test_canonical_root_identity_and_traversal_denied_before_execution(self):
targets=(self.repo / "docs/verification/run-a", self.root / "scratch/run-a", self.repo.parent.parent / "issue-2/verification/run-a", self.artifacts.parent / "../run-a")
for target in targets:
with self.subTest(target=str(target)):
result=self.invoke("run", "--artifact-root", target, "--plan", self.plan.name, "--output", self.manifest.name)
self.assertEqual(result.returncode, 2)
self.assertFalse(self.manifest.exists())
for filename in ("../escape.json", "/absolute.json", "a/../escape.json", "./plan.json", "plan.json/"):
with self.subTest(filename=filename):
result=self.invoke("run", "--plan", filename, "--output", self.manifest.name)
self.assertEqual(result.returncode, 2)
result=self.invoke("run", "--contract-path", self.root / "wrong.md", "--plan", self.plan.name, "--output", self.manifest.name)
self.assertEqual(result.returncode, 2)
def test_artifact_file_and_directory_symlinks_denied(self):
alias=self.artifacts / "alias.json"
alias.symlink_to(self.plan.name)
result=self.invoke("run", "--plan", alias.name, "--output", self.manifest.name)
self.assertEqual(result.returncode, 2)
self.assertIn("unsafe path", result.stderr)
outside=self.root / "outside"
self.artifacts.rename(outside)
self.artifacts.symlink_to(outside, target_is_directory=True)
result=self.invoke("run", "--plan", self.plan.name, "--output", self.manifest.name)
self.assertEqual(result.returncode, 2)
self.assertIn("symlink", result.stderr)
self.assertFalse((outside / self.manifest.name).exists())
def test_private_permissions_fail_closed_without_repair(self):
self.plan.chmod(0o644)
result=self.invoke("run", "--plan", self.plan.name, "--output", self.manifest.name)
self.assertEqual(result.returncode, 2)
self.assertEqual(self.plan.stat().st_mode & 0o777, 0o644)
self.plan.chmod(0o600)
self.artifacts.chmod(0o755)
result=self.invoke("run", "--plan", self.plan.name, "--output", self.manifest.name)
self.assertEqual(result.returncode, 2)
self.assertEqual(self.artifacts.stat().st_mode & 0o777, 0o755)
self.assertFalse(self.manifest.exists())
def test_wrong_uid_is_denied(self):
import importlib.util
from unittest.mock import patch
spec=importlib.util.spec_from_file_location("evidence_manifest_uid_test", SCRIPT)
assert spec is not None and spec.loader is not None
module=importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
with patch.object(module.os, "getuid", return_value=self.artifacts.stat().st_uid+1):
with self.assertRaisesRegex(ValueError, "ownership"):
module.safe_entry(self.artifacts, directory=True, mode=0o700)
def test_group_writable_cache_parent_requires_exclusive_role_membership(self):
import importlib.util
from types import SimpleNamespace
from unittest.mock import patch
spec=importlib.util.spec_from_file_location("evidence_manifest_group_test", SCRIPT)
assert spec is not None and spec.loader is not None
module=importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
parent=self.root / "repositories"
parent.chmod(0o775)
uid=parent.stat().st_uid; gid=parent.stat().st_gid
role=SimpleNamespace(pw_name="role", pw_uid=uid, pw_gid=gid)
foreign=SimpleNamespace(pw_name="foreign", pw_uid=uid+1, pw_gid=gid)
with patch.object(module.grp, "getgrgid", return_value=SimpleNamespace(gr_mem=[])), patch.object(module.pwd, "getpwall", return_value=[role]), patch.object(module.pwd, "getpwnam", return_value=role):
module.safe_repositories(parent)
with patch.object(module.grp, "getgrgid", return_value=SimpleNamespace(gr_mem=["foreign"])), patch.object(module.pwd, "getpwall", return_value=[role, foreign]), patch.object(module.pwd, "getpwnam", side_effect=lambda name: role if name=="role" else foreign):
with self.assertRaisesRegex(ValueError, "exclusive"):
module.safe_repositories(parent)
with patch.object(module.grp, "getgrgid", side_effect=KeyError("unresolved")):
with self.assertRaisesRegex(ValueError, "unverifiable"):
module.safe_repositories(parent)
self.assertEqual(parent.stat().st_mode & 0o777, 0o775)
def test_no_clobber_rejects_rerun_before_command_execution(self):
self.assertEqual(self.invoke("run", "--plan", self.plan.name, "--output", self.manifest.name).returncode, 0)
prior=self.manifest.read_bytes()
obj=json.loads(self.plan.read_text())
marker=self.root / "rerun-executed"
obj["commands"][0]["argv"]=[sys.executable, "-c", "from pathlib import Path; Path("+repr(str(marker))+").write_text('ran')"]
self.plan.write_text(json.dumps(obj))
result=self.invoke("run", "--plan", self.plan.name, "--output", self.manifest.name)
self.assertEqual(result.returncode, 2)
self.assertIn("already exists", result.stderr)
self.assertEqual(self.manifest.read_bytes(), prior)
self.assertFalse(marker.exists())
def test_v2_rejects_forged_issue_binding_and_v1_normal_validation(self):
self.assertEqual(self.invoke("run", "--plan", self.plan.name, "--output", self.manifest.name).returncode, 0)
original=json.loads(self.manifest.read_text())
for replacement in (2, True, "1"):
obj=json.loads(json.dumps(original)); obj["artifact"]["issue"]=replacement
self.manifest.write_text(json.dumps(obj, sort_keys=True, separators=(",", ":"))+"\n")
self.assertEqual(self.invoke("validate", "--manifest", self.manifest.name).returncode, 2)
original["schema"]="evidence-manifest/v1"
self.manifest.write_text(json.dumps(original, sort_keys=True, separators=(",", ":"))+"\n")
self.assertEqual(self.invoke("validate", "--manifest", self.manifest.name).returncode, 2)
def test_artifact_boundary_mutation_by_command_rejected_before_write(self):
obj=json.loads(self.plan.read_text())
obj["commands"][0]["argv"]=[sys.executable, "-c", "from pathlib import Path; Path("+repr(str(self.artifacts))+").chmod(0o755)"]
self.plan.write_text(json.dumps(obj))
result=self.invoke("run", "--plan", self.plan.name, "--output", self.manifest.name)
self.assertEqual(result.returncode, 2, result.stderr)
self.assertIn("permission", result.stderr)
self.assertFalse(self.manifest.exists())
def test_v2_stale_head_rejected_even_when_bound_file_is_unchanged(self):
self.assertEqual(self.invoke("run", "--plan", self.plan.name, "--output", self.manifest.name).returncode, 0)
(self.repo / "other.txt").write_text("new source revision\n")
run_git(self.repo, "add", "other.txt")
run_git(self.repo, "commit", "-qm", "advance head")
result=self.invoke("validate", "--manifest", self.manifest.name)
self.assertEqual(result.returncode, 2, result.stderr)
self.assertIn("source revision is not current HEAD", result.stderr)
def test_v2_rejects_source_symlink_alias(self):
(self.repo / "alias.txt").symlink_to("input.txt")
run_git(self.repo, "add", "alias.txt")
run_git(self.repo, "commit", "-qm", "source symlink fixture")
obj=json.loads(self.plan.read_text())
obj["hash_paths"]=["alias.txt"]
self.plan.write_text(json.dumps(obj))
result=self.invoke("run", "--plan", self.plan.name, "--output", self.manifest.name)
self.assertEqual(result.returncode, 2, result.stderr)
self.assertIn("symlink", result.stderr)
self.assertFalse(self.manifest.exists())
def test_external_artifacts_leave_checkout_and_head_unchanged(self):
head = run_git(self.repo, "rev-parse", "HEAD").stdout.strip()
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["source_revision"], head)
self.assertEqual(data["artifact"]["run_id"], "run-a")
self.assertEqual(data["artifact"]["issue"], 1)
self.assertEqual(run_git(self.repo, "status", "--porcelain").stdout, "")
self.assertEqual(run_git(self.repo, "rev-parse", "HEAD").stdout.strip(), head)
self.assertEqual(self.manifest.stat().st_mode & 0o777, 0o600)
self.assertEqual(self.invoke("validate", "--manifest", self.manifest.name).returncode, 0)
if __name__ == "__main__": unittest.main()