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 @@
#!/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)