[verified] Gate adoption of delegated evidence

This commit is contained in:
2026-10-11 06:08:32 +00:00
parent da7598b1c3
commit 9367d5a3a1
9 changed files with 1095 additions and 4 deletions
@@ -1,7 +1,7 @@
---
name: gitea-issue-implementation
description: 승인된 Gitea Issue를 구현·검증하고 Gitea PR로 전달한다.
version: 0.4.8
version: 0.4.9
author: genichin, Hermes Agent
license: MIT
platforms: [linux]
@@ -20,6 +20,10 @@ metadata:
Baseline/current inventory를 생성·전달·채택할 때 `check-tests 이전`에 `harness/lib/test_inventory_binding.py`를 실행한다. Inventory의 자기 선언을 신뢰하지 않고 독립적으로 고정한 revision과 stage를 expected 입력으로 제공한다. Exact Git blob의 파일 존재·SHA-256 및 collection scope 결속을 [공통 source-binding 정본](../../../../operations/repository-cache.md#test-inventory-git-source-binding-preflight)에 따라 확인하고, 통과한 ID/source가 이후 구조 guard 입력과 같은지도 대조한다. 불일치 증빙을 채택하거나 현재 collection을 baseline으로 재명명하지 않는다. PASS가 named test의 존재·collection 실행·의미 보존을 인증하지는 않으며 main의 실제 실행 provenance 검토와 기존 revision evidence 영향 평가를 유지한다. 이 gate는 제품 실행·별도 checkout 권한이나 자동 제품 변경 요청을 추가하지 않는다.
## Delegation evidence acceptance gate
위임 결과 채택 이전에 `harness/lib/delegation_evidence.py`를 [공통 증빙 채택 정본](../../../../operations/repository-cache.md#delegation-evidence-acceptance-preflight)에 따라 실행한다. main이 독립적으로 고정한 context에 source/bundle digest, 허용 작업, 검증 항목과 독립 실행 catalog를 넣으며 worker 자기 선언으로 context를 만들지 않는다. Source line/named test anchor와 실제 보존된 회귀 테스트·RED/GREEN 기록을 대조하고, bundle 추출 범위 밖 부재 주장은 authoritative Git 조회 없이 채택하지 않는다. Import·환경 오류를 RED로 취급하거나 확인되지 않은 위임 주장을 최종 판정·delivery·publication 근거로 옮기지 않는다. Static-only worker가 기존 독립 실행 근거를 인용하는 것과 직접 실행하는 것을 구분한다. BLOCK/입력 거부이면 해당 증빙 채택을 중단하고 main이 실제 기록과 의미를 재검증한다. Gate는 sandbox, 실행 인증 또는 assertion 의미 판정기가 아니며 기존 compare-source/check-tests와 role의 제품 실행 제한을 대체하지 않는다.
## Managed-lock broker gate
Issue workflow는 bootstrap 이후 반드시 [Repository cache 운영 지침](../../../../operations/repository-cache.md)의 하나의 role-neutral managed-lock broker를 사용한다. `serve`에 canonical Issue identity, role, workflow run-id와 TTL을 전달해 broker-generated runtime을 만들고, ready JSON의 `handle`/`socket` 경로만 사용한다. 경로는 `<issue>/runtime/<workflow-run-id>/broker.handle`과 `broker.sock`이며 verification stage run-id와 독립적이다. Runtime 생성은 approval이나 worktree admission을 대신하지 않는다. Cache bootstrap 이후 broker가 Issue lock 아래 runtime을 준비하고, ready 이후 각 workflow의 진입 검증·승인 조건을 확인한다. Client는 raw token을 inspect/log하지 않으며 public `request` API가 token을 캡슐화한다. Repository lock은 같은 handle의 `acquire_repository`/`release_repository` request로만 중첩하고, TTL 전에 `renew`한다. 정상·오류·cancel 모두 `release_repository`(보유 시) → `release` → broker wait/reap → `probe`의 `free=true` 확인 순서를 완료하기 전에는 완료·로그·보고하지 않는다. Raw `flock ... sleep`과 lock-file unlink는 금지한다.
@@ -1,7 +1,7 @@
---
name: gitea-issue-rework
description: Gitea PR 검토 지적을 계약에 맞춰 재작업하고 검증한다.
version: 0.2.14
version: 0.2.15
author: genichin, Hermes Agent
license: MIT
platforms: [linux]
@@ -22,6 +22,10 @@ Gitea 인증과 authoritative readback은 [Gitea authenticated access 운영 지
Baseline/current inventory를 생성·전달·채택할 때 `check-tests 이전`에 `harness/lib/test_inventory_binding.py`를 실행한다. Inventory의 자기 선언을 신뢰하지 않고 독립적으로 고정한 revision과 stage를 expected 입력으로 제공한다. Exact Git blob의 파일 존재·SHA-256 및 collection scope 결속을 [공통 source-binding 정본](../../../../operations/repository-cache.md#test-inventory-git-source-binding-preflight)에 따라 확인하고, 통과한 ID/source가 이후 구조 guard 입력과 같은지도 대조한다. 불일치 증빙을 채택하거나 현재 collection을 baseline으로 재명명하지 않는다. PASS가 named test의 존재·collection 실행·의미 보존을 인증하지는 않으며 main의 실제 실행 provenance 검토와 기존 revision evidence 영향 평가를 유지한다. 이 gate는 제품 실행·별도 checkout 권한이나 자동 제품 변경 요청을 추가하지 않는다.
## Delegation evidence acceptance gate
위임 결과 채택 이전에 `harness/lib/delegation_evidence.py`를 [공통 증빙 채택 정본](../../../../operations/repository-cache.md#delegation-evidence-acceptance-preflight)에 따라 실행한다. main이 독립적으로 고정한 context에 source/bundle digest, 허용 작업, 검증 항목과 독립 실행 catalog를 넣으며 worker 자기 선언으로 context를 만들지 않는다. Source line/named test anchor와 실제 보존된 회귀 테스트·RED/GREEN 기록을 대조하고, bundle 추출 범위 밖 부재 주장은 authoritative Git 조회 없이 채택하지 않는다. Import·환경 오류를 RED로 취급하거나 확인되지 않은 위임 주장을 최종 판정·delivery·publication 근거로 옮기지 않는다. Static-only worker가 기존 독립 실행 근거를 인용하는 것과 직접 실행하는 것을 구분한다. BLOCK/입력 거부이면 해당 증빙 채택을 중단하고 main이 실제 기록과 의미를 재검증한다. Gate는 sandbox, 실행 인증 또는 assertion 의미 판정기가 아니며 기존 compare-source/check-tests와 role의 제품 실행 제한을 대체하지 않는다.
## Managed-lock broker gate
Bootstrap 이후 이 Issue 재작업도 canonical [Repository cache 운영 지침](../../../../operations/repository-cache.md)의 공통 managed-lock broker 하나만 사용한다. `serve`에 canonical Issue identity, role, workflow run-id와 TTL을 전달해 broker-generated runtime을 만들고 ready JSON의 `handle`/`socket` 경로만 사용한다. 경로는 `<issue>/runtime/<workflow-run-id>/broker.handle`과 `broker.sock`이며 verification run-id와 독립적이다. Runtime 생성은 approval이나 worktree admission을 대신하지 않는다. Cache bootstrap 이후 broker가 Issue lock 아래 runtime을 준비하고, ready 이후 각 workflow의 진입 검증·승인 조건을 확인한다. 같은 handle로만 public `request` API의 `acquire_repository`/`release_repository`를 요청하고 TTL 전에 `renew`한다. Client는 raw token을 inspect/log하지 않으며 broker request API가 token을 캡슐화한다. 정상·오류·cancel cleanup은 보유한 repository lease release, Issue `release`, broker wait/reap, `probe`의 `free=true` 확인까지 동일하며 그 전에는 completed/log/report를 주장하지 않는다. Raw `flock ... sleep`, lock-file unlink와 client-side lock holder는 금지한다.
@@ -1,7 +1,7 @@
---
name: gitea-code-review
description: Spec pass된 Gitea PR의 코드 품질과 안전성을 검토한다.
version: 0.1.3
version: 0.1.4
author: genichin, Hermes Agent
license: MIT
platforms: [linux]
@@ -20,6 +20,10 @@ metadata:
Baseline/current inventory를 생성·전달·채택할 때 `check-tests 이전`에 `harness/lib/test_inventory_binding.py`를 실행한다. Inventory의 자기 선언을 신뢰하지 않고 독립적으로 고정한 revision과 stage를 expected 입력으로 제공한다. Exact Git blob의 파일 존재·SHA-256 및 collection scope 결속을 [공통 source-binding 정본](../../../../operations/repository-cache.md#test-inventory-git-source-binding-preflight)에 따라 확인하고, 통과한 ID/source가 이후 구조 guard 입력과 같은지도 대조한다. 불일치 증빙을 채택하거나 현재 collection을 baseline으로 재명명하지 않는다. PASS가 named test의 존재·collection 실행·의미 보존을 인증하지는 않으며 main의 실제 실행 provenance 검토와 기존 revision evidence 영향 평가를 유지한다. 이 gate는 제품 실행·별도 checkout 권한이나 자동 제품 변경 요청을 추가하지 않는다.
## Delegation evidence acceptance gate
위임 결과 채택 이전에 `harness/lib/delegation_evidence.py`를 [공통 증빙 채택 정본](../../../../operations/repository-cache.md#delegation-evidence-acceptance-preflight)에 따라 실행한다. main이 독립적으로 고정한 context에 source/bundle digest, 허용 작업, 검증 항목과 독립 실행 catalog를 넣으며 worker 자기 선언으로 context를 만들지 않는다. Source line/named test anchor와 실제 보존된 회귀 테스트·RED/GREEN 기록을 대조하고, bundle 추출 범위 밖 부재 주장은 authoritative Git 조회 없이 채택하지 않는다. Import·환경 오류를 RED로 취급하거나 확인되지 않은 위임 주장을 최종 판정·delivery·publication 근거로 옮기지 않는다. Static-only worker가 기존 독립 실행 근거를 인용하는 것과 직접 실행하는 것을 구분한다. BLOCK/입력 거부이면 해당 증빙 채택을 중단하고 main이 실제 기록과 의미를 재검증한다. Gate는 sandbox, 실행 인증 또는 assertion 의미 판정기가 아니며 기존 compare-source/check-tests와 role의 제품 실행 제한을 대체하지 않는다.
## Managed-lock broker gate
Bootstrap 이후 code review는 canonical [Repository cache 운영 지침](../../../../operations/repository-cache.md)의 공통 managed-lock broker 하나를 사용한다. Canonical Issue identity, role, workflow run-id와 TTL로 broker-generated runtime을 요청하고 ready JSON의 `handle`/`socket`을 사용한다. Runtime은 `<issue>/runtime/<workflow-run-id>/`이며 verification run-id와 독립적이다. Runtime 생성은 approval이나 worktree admission을 대신하지 않는다. Cache bootstrap 이후 broker가 Issue lock 아래 runtime을 준비하고, ready 이후 각 workflow의 진입 검증·승인 조건을 확인한다. Client는 raw token을 inspect/log하지 않으며 public `request` API가 token을 캡슐화한다. Nested repository lease는 같은 handle의 `acquire_repository`/`release_repository` request로만 관리하고 TTL 전에 `renew`한다. 정상·오류·cancel에서 repository release(보유 시) → Issue `release` → broker wait/reap → `probe` `free=true` 확인을 검증하기 전에는 완료·로그·보고하지 않는다. Raw `flock ... sleep`, lock-file unlink와 client-side lock holder는 금지한다. Native publication은 이 broker lease를 사용하며 adapter의 compatibility bridge는 canonical protocol을 우회하지 않는다. Reviewer의 persistent harness/.runtime repository-local storage는 role/workflow runtime 경로와 별개이며 이 예외를 유지한다.
@@ -1,7 +1,7 @@
---
name: gitea-implementation-spec-review
description: 승인된 계약에 대해 Gitea 구현 PR 적합성을 검토한다.
version: 0.2.5
version: 0.2.6
author: genichin, Hermes Agent
license: MIT
platforms: [linux]
@@ -20,6 +20,10 @@ metadata:
Baseline/current inventory를 생성·전달·채택할 때 `check-tests 이전`에 `harness/lib/test_inventory_binding.py`를 실행한다. Inventory의 자기 선언을 신뢰하지 않고 독립적으로 고정한 revision과 stage를 expected 입력으로 제공한다. Exact Git blob의 파일 존재·SHA-256 및 collection scope 결속을 [공통 source-binding 정본](../../../../operations/repository-cache.md#test-inventory-git-source-binding-preflight)에 따라 확인하고, 통과한 ID/source가 이후 구조 guard 입력과 같은지도 대조한다. 불일치 증빙을 채택하거나 현재 collection을 baseline으로 재명명하지 않는다. PASS가 named test의 존재·collection 실행·의미 보존을 인증하지는 않으며 main의 실제 실행 provenance 검토와 기존 revision evidence 영향 평가를 유지한다. 이 gate는 제품 실행·별도 checkout 권한이나 자동 제품 변경 요청을 추가하지 않는다.
## Delegation evidence acceptance gate
위임 결과 채택 이전에 `harness/lib/delegation_evidence.py`를 [공통 증빙 채택 정본](../../../../operations/repository-cache.md#delegation-evidence-acceptance-preflight)에 따라 실행한다. main이 독립적으로 고정한 context에 source/bundle digest, 허용 작업, 검증 항목과 독립 실행 catalog를 넣으며 worker 자기 선언으로 context를 만들지 않는다. Source line/named test anchor와 실제 보존된 회귀 테스트·RED/GREEN 기록을 대조하고, bundle 추출 범위 밖 부재 주장은 authoritative Git 조회 없이 채택하지 않는다. Import·환경 오류를 RED로 취급하거나 확인되지 않은 위임 주장을 최종 판정·delivery·publication 근거로 옮기지 않는다. Static-only worker가 기존 독립 실행 근거를 인용하는 것과 직접 실행하는 것을 구분한다. BLOCK/입력 거부이면 해당 증빙 채택을 중단하고 main이 실제 기록과 의미를 재검증한다. Gate는 sandbox, 실행 인증 또는 assertion 의미 판정기가 아니며 기존 compare-source/check-tests와 role의 제품 실행 제한을 대체하지 않는다.
## Managed-lock broker gate
Cache와 Issue workflow의 lock은 canonical [Repository cache 운영 지침](../../../../operations/repository-cache.md)을 따른다. Bootstrap 이후에는 role-neutral broker 하나를 `serve`로 tracked background process로 시작하고 ready JSON을 기다린다. Canonical Issue identity, role, workflow run-id와 TTL로 broker-generated runtime을 요청하고 ready JSON의 `handle`/`socket`을 사용한다. Runtime은 `<issue>/runtime/<workflow-run-id>/`이며 verification run-id와 독립적이다. Runtime 생성은 approval이나 worktree admission을 대신하지 않는다. Cache bootstrap 이후 broker가 Issue lock 아래 runtime을 준비하고, ready 이후 각 workflow의 진입 검증·승인 조건을 확인한다. Client는 raw token을 inspect/log하지 않고 public `request` API를 사용한다. Nested repository lock은 같은 handle의 `acquire_repository`/`release_repository` request만 사용하고 TTL 전에 `renew`한다. 정상·no-op·검증 실패·API 오류·cancel을 포함한 모든 종료 경로는 `release_repository`(보유 시) → `release` → wait/reap → `probe`에서 `free=true` 확인을 거쳐야 하며, 그 전에는 completed/log/report를 주장하지 않는다. Raw `flock ... sleep`과 lock-file unlink는 금지한다.
+278
View File
@@ -0,0 +1,278 @@
#!/usr/bin/env python3
"""Bounded, read-only acceptance of independently captured delegation evidence."""
from __future__ import annotations
import argparse, ast, hashlib, json, os, re, selectors, signal, stat, subprocess, time
from typing import NoReturn
class EvidenceError(ValueError):
pass
_HEX40=re.compile(r"^[0-9a-f]{40}$"); _HEX64=re.compile(r"^[0-9a-f]{64}$")
_CONTEXT="gitea-agent.delegation-evidence-context/v1"; _RESULT="gitea-agent.delegation-evidence-result/v1"
_MAX_JSON=1024*1024; _MAX_SOURCE=1024*1024; _MAX_LIST=512; _GIT="/usr/bin/git"
def _bad() -> NoReturn: raise EvidenceError("rejected evidence input")
def _keys(v, names):
if not isinstance(v,dict) or set(v)!=set(names): _bad()
def _nonempty(v): return isinstance(v,str) and bool(v.strip()) and "\x00" not in v
def _path(v):
if not isinstance(v,str) or not v or v.startswith("/") or "\\" in v or "\x00" in v or any(x in ("",".","..") for x in v.split("/")): _bad()
return v
def _digest(v, rx=_HEX64):
if not isinstance(v,str) or rx.fullmatch(v) is None: _bad()
return v
def _bounded_list(v):
if not isinstance(v,list) or len(v)>_MAX_LIST: _bad()
return v
def _abs(v):
if not isinstance(v,str) or not os.path.isabs(v) or v!=os.path.normpath(v) or "\x00" in v: _bad()
return v
def _directory_fd(path):
_abs(path); fd=os.open("/",os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW|os.O_CLOEXEC)
try:
for part in path.split("/")[1:]:
if part:
n=os.open(part,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW|os.O_CLOEXEC,dir_fd=fd); os.close(fd); fd=n
if not stat.S_ISDIR(os.fstat(fd).st_mode): _bad()
return fd
except (OSError,EvidenceError):
try: os.close(fd)
except OSError: pass
_bad()
def _open_rel(root_fd,path):
cur=os.dup(root_fd)
try:
parts=path.split("/")
for part in parts[:-1]:
n=os.open(part,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW|os.O_CLOEXEC,dir_fd=cur); os.close(cur); cur=n
fd=os.open(parts[-1],os.O_RDONLY|os.O_NOFOLLOW|os.O_CLOEXEC|os.O_NONBLOCK,dir_fd=cur)
if not stat.S_ISREG(os.fstat(fd).st_mode): os.close(fd); _bad()
return fd
except (OSError,EvidenceError): _bad()
finally:
try: os.close(cur)
except OSError: pass
def _read(root_fd,path,limit=_MAX_SOURCE):
fd=_open_rel(root_fd,path)
try: data=os.read(fd,limit+1)
finally: os.close(fd)
if len(data)>limit: _bad()
return data
def _json_file(path):
rawpath=_abs(path); parent=_directory_fd(os.path.dirname(rawpath))
try:
fd=_open_rel(parent,os.path.basename(rawpath))
try: raw=os.read(fd,_MAX_JSON+1)
finally: os.close(fd)
finally: os.close(parent)
if len(raw)>_MAX_JSON: _bad()
def pairs(items):
out={}
for k,v in items:
if k in out: _bad()
out[k]=v
return out
try: return json.loads(raw.decode(),object_pairs_hook=pairs,parse_constant=lambda _: _bad())
except EvidenceError: raise
except (UnicodeError,ValueError,TypeError,RecursionError): _bad()
def _canon(v): return hashlib.sha256(json.dumps(v,sort_keys=True,separators=(",",":"),ensure_ascii=True).encode()).hexdigest()
def _env(): return {"PATH":"/usr/bin:/bin","HOME":"/nonexistent","GIT_CONFIG_NOSYSTEM":"1","GIT_CONFIG_GLOBAL":os.devnull,"GIT_CONFIG_SYSTEM":os.devnull,"GIT_NO_LAZY_FETCH":"1","GIT_NO_REPLACE_OBJECTS":"1","GIT_TERMINAL_PROMPT":"0"}
def _git(gitdir,args,fd):
cmd=[_GIT,"--git-dir",gitdir,"--literal-pathspecs",*args]; p=None
try:
p=subprocess.Popen(cmd,env=_env(),stdin=subprocess.DEVNULL,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,start_new_session=True,pass_fds=(fd,)); out=bytearray(); deadline=time.monotonic()+5
with selectors.DefaultSelector() as s:
s.register(p.stdout,selectors.EVENT_READ)
while True:
left=deadline-time.monotonic()
if left<=0 or not s.select(left): raise subprocess.TimeoutExpired(cmd,5)
chunk=os.read(p.stdout.fileno(),min(65536,_MAX_SOURCE-len(out)+1))
if not chunk: break
out.extend(chunk)
if len(out)>_MAX_SOURCE: raise subprocess.SubprocessError()
return p.wait(timeout=max(.001,deadline-time.monotonic())),bytes(out)
except (OSError,subprocess.SubprocessError):
if p and p.returncode is None:
try: os.killpg(p.pid,signal.SIGKILL)
except ProcessLookupError: pass
p.wait()
return None
finally:
if p and p.stdout: p.stdout.close()
def _context(c):
_keys(c,("schema","source","stage","bundle_sha256","files","allowed_actions","obligations","executions"))
if c["schema"]!=_CONTEXT or not _nonempty(c["stage"]): _bad()
src=c["source"]
if not isinstance(src,dict) or src.get("kind") not in ("git_tree","captured_worktree"): _bad()
if src["kind"]=="git_tree":
_keys(src,("kind","revision"))
if not _HEX40.fullmatch(src["revision"]): _bad()
else:
_keys(src,("kind","base_revision"))
if not _HEX40.fullmatch(src["base_revision"]): _bad()
files=c["files"]
if not isinstance(files,dict) or len(files)>_MAX_LIST: _bad()
for p,h in files.items(): _path(p); _digest(h)
if c["bundle_sha256"]!=_canon(files): _bad()
actions=_bounded_list(c["allowed_actions"])
if len(set(actions))!=len(actions) or any(a not in ("static","execute") for a in actions): _bad()
obs=_bounded_list(c["obligations"]); ids=set()
for o in obs:
_keys(o,("id","kind"))
if not _nonempty(o["id"]) or o["id"] in ids or o["kind"] not in ("static","execution","red_green"): _bad()
ids.add(o["id"])
exs=_bounded_list(c["executions"]); eids=set()
for e in exs:
_keys(e,("id","test_id","bundle_sha256","test_file_sha256","argv","exit_code","failure_kind","evidence_path","evidence_sha256","origin"))
if not _nonempty(e["id"]) or e["id"] in eids or not isinstance(e["test_id"],str) or e["origin"] not in ("direct","inherited"): _bad()
eids.add(e["id"]); _digest(e["bundle_sha256"]); _digest(e["test_file_sha256"]); av=_bounded_list(e["argv"])
if not av or any(not _nonempty(x) for x in av): _bad()
if isinstance(e["exit_code"],bool) or not isinstance(e["exit_code"],int) or e["failure_kind"] not in ("none","assertion","approved_behavior","import","environment","timeout"): _bad()
_path(e["evidence_path"]); _digest(e["evidence_sha256"])
return src,files,actions,obs,exs
def _result(r,c):
_keys(r,("schema","source","stage","bundle_sha256","claims"))
if r["schema"]!=_RESULT or not _nonempty(r["stage"]): _bad()
source=r["source"]
if not isinstance(source,dict) or source.get("kind") not in ("git_tree","captured_worktree"): _bad()
field="revision" if source["kind"]=="git_tree" else "base_revision"
_keys(source,("kind",field)); _digest(source[field],_HEX40); _digest(r["bundle_sha256"])
claims=_bounded_list(r["claims"]); seen=set()
for q in claims:
_keys(q,("id","status","anchors","execution_ids"))
if not _nonempty(q["id"]) or q["id"] in seen or q["status"] not in ("supported","indeterminate"): _bad()
seen.add(q["id"]); _bounded_list(q["anchors"]); ids=_bounded_list(q["execution_ids"])
if len(set(ids))!=len(ids) or any(not _nonempty(i) for i in ids): _bad()
return claims
def _has_test(raw,symbol):
if not isinstance(symbol,str) or not symbol or any(not x.isidentifier() for x in symbol.split(".")): return False
try: tree=ast.parse(raw.decode())
except (SyntaxError,UnicodeError,RecursionError): return False
want=tuple(symbol.split(".")); found=[]
def walk(nodes,prefix=()):
for n in nodes:
if isinstance(n,(ast.FunctionDef,ast.AsyncFunctionDef,ast.ClassDef)):
cur=prefix+(n.name,)
if cur==want and isinstance(n,(ast.FunctionDef,ast.AsyncFunctionDef)) and n.name.startswith("test"): found.append(n)
if isinstance(n,ast.ClassDef): walk(n.body,cur)
walk(tree.body); return len(found)==1
def _git_files(src,files,git_fd,root_fd):
if git_fd is None: return False,{}
gd=f"/proc/self/fd/{git_fd}"; revision=src["revision"]
frozen={p:_read(root_fd,p) for p in files}
if any(len(frozen[p])>_MAX_SOURCE or hashlib.sha256(frozen[p]).hexdigest()!=h for p,h in files.items()): return False, frozen
typ=_git(gd,["cat-file","-t",revision],git_fd)
if typ is None or typ[0]!=0 or typ[1].strip()!=b"commit": return False, frozen
if not files: return True, frozen
tree=_git(gd,["ls-tree","-rz","-r","--full-tree",revision,"--",*sorted(files)],git_fd)
if tree is None or tree[0]!=0: return False, frozen
out={}
try:
rows=tree[1].split(b"\0")
for row in rows:
if not row: continue
head,pathb=row.split(b"\t",1); mode,kind,obj=head.decode().split(" "); path=pathb.decode()
if mode not in ("100644","100755") or kind!="blob" or path not in files: return False, frozen
blob=_git(gd,["show",f"{revision}:{path}"],git_fd)
if blob is None or blob[0]!=0 or len(blob[1])>_MAX_SOURCE: return False, frozen
if hashlib.sha1(b"blob "+str(len(blob[1])).encode()+b"\0"+blob[1]).hexdigest()!=obj: return False, frozen
if hashlib.sha256(blob[1]).hexdigest()!=files[path] or blob[1]!=frozen[path]: return False, frozen
out[path]=blob[1]
except (UnicodeError,ValueError):
return False, frozen
return set(out)==set(files), frozen
def _git_has_path(src,path,git_fd):
out=_git(f"/proc/self/fd/{git_fd}",["ls-tree","-rz","-r","--full-tree",src["revision"],"--",path],git_fd)
if out is None or out[0]!=0: return None
return bool(out[1].rstrip(b"\0"))
def verify_evidence(bundle_root,context,result,git_dir=None):
rootfd=_directory_fd(_abs(bundle_root))
gitfd=None
try:
src,files,actions,obs,exs=_context(context); claims=_result(result,context); cmap={q["id"]:q for q in claims}; omap={o["id"]:o for o in obs}
if set(cmap)!=set(omap): _bad()
if src["kind"]=="git_tree":
if git_dir is None: ok,raw=False,{}
else:
try: gitfd=_directory_fd(git_dir)
except EvidenceError: ok,raw=False,{}
else: ok,raw=_git_files(src,files,gitfd,rootfd)
else: raw={p:_read(rootfd,p) for p in files}; ok=all(hashlib.sha256(raw[p]).hexdigest()==h for p,h in files.items())
reasons=[]
for field in ("source","stage","bundle_sha256"):
if result[field]!=context[field]: reasons.append("result_"+field+"_mismatch")
if not ok: reasons.append("source_hash_mismatch")
emap={e["id"]:e for e in exs}; anchors_n=0
for o in obs:
q=cmap[o["id"]]; anchors=q["anchors"]; anchors_n+=len(anchors)
if q["status"]=="indeterminate": reasons.append("indeterminate_claim")
if o["kind"]=="static" and "static" not in actions: reasons.append("unauthorized_static")
if o["kind"] in ("execution","red_green") and "execute" not in actions and any(emap.get(i,{}).get("origin")=="direct" for i in q["execution_ids"]): reasons.append("unauthorized_execute")
if o["kind"]=="static" and not anchors: reasons.append("missing_static_anchor")
if o["kind"] in ("execution","red_green") and not q["execution_ids"]: reasons.append("missing_execution_record")
for a in anchors:
if not isinstance(a,dict) or "kind" not in a: _bad()
if a["kind"]=="lines":
_keys(a,("kind","path","start_line","end_line","text_sha256")); p=_path(a["path"]); _digest(a["text_sha256"])
if p not in files or any(isinstance(a[x],bool) or not isinstance(a[x],int) for x in ("start_line","end_line")) or a["start_line"]<=0 or a["end_line"]<a["start_line"]: _bad()
ls=raw[p].splitlines(keepends=True)
if a["end_line"]>len(ls): reasons.append("anchor_range_invalid")
elif hashlib.sha256(b"".join(ls[a["start_line"]-1:a["end_line"]])).hexdigest()!=a["text_sha256"]: reasons.append("anchor_hash_mismatch")
elif a["kind"]=="test":
_keys(a,("kind","path","symbol")); p=_path(a["path"])
if p not in files or not p.endswith(".py") or not _has_test(raw[p],a["symbol"]): reasons.append("test_anchor_missing")
elif a["kind"]=="absence":
_keys(a,("kind","path")); p=_path(a["path"])
if src["kind"]!="git_tree": reasons.append("absence_indeterminate")
else:
present=None if gitfd is None else _git_has_path(src,p,gitfd)
if p in files or present is True: reasons.append("absence_not_proven")
elif present is None: reasons.append("absence_indeterminate")
else: _bad()
for eid in q["execution_ids"]:
if eid not in emap: reasons.append("unknown_execution_id"); continue
e=emap[eid]
if e["origin"]=="direct" and "execute" not in actions: reasons.append("unauthorized_execute")
if e["bundle_sha256"]!=context["bundle_sha256"] and not (o["kind"]=="red_green" and e["exit_code"]!=0 and e["failure_kind"] in ("assertion","approved_behavior")): reasons.append("execution_scope_mismatch")
if hashlib.sha256(_read(rootfd,e["evidence_path"])).hexdigest()!=e["evidence_sha256"]: reasons.append("evidence_hash_mismatch")
if o["kind"]=="red_green":
tests=[a for a in anchors if isinstance(a,dict) and a.get("kind")=="test"]
if len(tests)!=1: reasons.append("red_green_test_anchor_required")
else:
test_id=tests[0]["path"]+"::"+tests[0]["symbol"]; red=False; green=False
for eid in q["execution_ids"]:
if eid not in emap: continue
e=emap[eid]
if e["test_id"]!=test_id or e["test_file_sha256"]!=files.get(tests[0]["path"]): reasons.append("red_green_test_scope_mismatch")
if e["exit_code"]!=0 and e["failure_kind"] in ("assertion","approved_behavior"): red=True
if e["exit_code"]==0 and e["failure_kind"] in ("none","approved_behavior") and e["bundle_sha256"]==context["bundle_sha256"]: green=True
if not red: reasons.append("red_green_missing_red")
if not green: reasons.append("red_green_missing_green")
counts={"files":len(files),"obligations":len(obs),"claims":len(claims),"anchors":anchors_n,"executions":len(exs)}
return {"status":"block" if reasons else "pass","reason_codes":sorted(set(reasons)),"counts":counts}
except EvidenceError: raise
except (OSError,TypeError,ValueError,KeyError,RecursionError): _bad()
finally:
if gitfd is not None: os.close(gitfd)
os.close(rootfd)
class _Parser(argparse.ArgumentParser):
def error(self,message): _bad()
def main(argv=None):
p=_Parser(add_help=False,allow_abbrev=False); p.add_argument("--bundle-root",required=True); p.add_argument("--context",required=True); p.add_argument("--result",required=True); p.add_argument("--git-dir")
try:
a=p.parse_args(argv); out=verify_evidence(a.bundle_root,_json_file(a.context),_json_file(a.result),a.git_dir); print(json.dumps(out,sort_keys=True,separators=(",",":"))); return 0 if out["status"]=="pass" else 1
except Exception: print('{"status":"block","reason_codes":["rejected_input"],"counts":{}}'); return 2
if __name__=="__main__": raise SystemExit(main())
+40
View File
@@ -371,6 +371,46 @@ python3 <harness-root>/harness/lib/workflow_guard.py check-tests --input GUARD_I
File/hash binding PASS는 named symbol·parameter 존재나 실제 collection 실행을 인증하지 않는다. `argv`/`evidence`의 형식과 source scope 결속을 확인해도 명령이 실행됐다는 증명은 아니다. Main이 실제 command/output/exit·환경·collection ID/status·범위의 적절성과 manifest 결속을 기존 provenance 규칙으로 별도 확인한다. 필요한 근거가 부족하면 해당 증빙을 채택하지 않으며 현재 collection을 baseline으로 재명명하지 않는다. 기존 revision evidence 재사용은 원래 source에 먼저 결속한 뒤 source-to-head 영향 평가를 유지한다. 별도 checkout이나 전체 테스트 재실행을 요구하지 않는다. Source-binding 실패를 제품 결함이나 자동 `REQUEST_CHANGES`로 전환하지 않는다.
### Delegation evidence acceptance preflight
위임 결과를 판정·delivery·publication 증빙으로 채택하기 **이전**에 main이 `harness/lib/delegation_evidence.py`를 실행한다. 기존 source-preservation snapshot/compare-source, test inventory source-binding, `check-tests`는 각각 유지한다. 이 gate는 보호 범위 변경 검출이나 collection completeness 검사를 대신하지 않는다.
```sh
python3 <harness-root>/harness/lib/delegation_evidence.py \
--bundle-root ABS_FROZEN_BUNDLE --context ABS_MAIN_CONTEXT_JSON \
--result ABS_WORKER_RESULT_JSON --git-dir ABS_ADMITTED_GIT_DIRECTORY
```
`--git-dir`는 `git_tree` source에서 필수이며 `captured_worktree`에서는 Git provenance를 주장하지 않으므로 생략할 수 있다. Linux의 신뢰된 `/usr/bin/git`, pinned directory FD와 `/proc/self/fd/`를 사용한다. Helper는 기존 자료만 읽으며 file/ref write, source import, eval, checkout/fetch, test 실행, credential 조회, 환경 repair를 하지 않는다. Context/result input, frozen bundle source와 evidence는 canonical absolute root 아래의 bounded regular files여야 한다. Alias·symlink ancestor·FIFO·duplicate/unknown JSON keys·잘못된 type을 거부한다.
**독립 trust 입력:** main이 source identity, 실제 bundle bytes, stage, 허용 작업, 승인된 검증 항목, 실제 command/output/exit와 실패 원인을 검토하여 context와 독립 실행 catalog를 고정한다. Worker의 주장·테스트 개수·자체 failure_kind를 그대로 복사하여 trust 입력을 만들지 않는다. 위임 시작 시점 bundle과 채택 대상 source가 다르면 새 source의 입력·기록을 main이 독립적으로 다시 결속한다. 이전 snapshot의 PASS를 다른 source에 재사용하지 않는다. 이 gate는 context의 신뢰성을 스스로 인증하지 않는다. Context/catalog의 인증·독립성, evidence의 실제 실행 provenance와 assertion 검출력은 main 책임이다. Secret-bearing source/output를 무작정 bundle로 복사하지 않으며 record와 digest가 필요해도 credential 값은 기록하지 않는다.
**Main context 계약:** exact top-level keys는 `schema`, `source`, `stage`, `bundle_sha256`, `files`, `allowed_actions`, `obligations`, `executions`이다.
- `schema`: exact `gitea-agent.delegation-evidence-context/v1`.
- `source`: exact variant `{kind: "git_tree", revision: lowercase40}` 또는 `{kind: "captured_worktree", base_revision: lowercase40}`. `git_tree`는 exact commit과 regular Git blob까지 확인한다. `captured_worktree`는 main이 별도로 고정한 pending-change snapshot의 bytes를 검사하며 base revision과 동일한 내용 또는 commit된 source라고 주장하지 않는다. 두 mode를 서로 재명명하지 않는다.
- `stage`: 독립적으로 고정한 nonempty 단계 문자열.
- `files`: 추출한 source의 안전한 repository-relative path → lowercase SHA-256 exact map. 실제 frozen file bytes와 대조한다. 추출 범위는 **listed files only**이고 bundle에 없다는 이유로 repository 부재로 판정하지 않는다. `git_tree`에서는 snapshot bytes와 같은 pinned commit blob 모두에 결속한다.
- `bundle_sha256`: `SHA256(json.dumps(files, sort_keys=True, separators=(',', ':'), ensure_ascii=True).encode('utf-8'))`. Mutable 실행 metadata는 source map에 억지로 포함하지 않으며 별도 catalog/evidence digest로 결속한다.
- `allowed_actions`: 중복 없는 `static`/`execute` subset. Context에서 지정한 worker의 허용 작업이며 자동 실행 권한을 부여하거나 숨겨진 실행을 탐지하는 sandbox가 아니다.
- `obligations`: unique `{id: nonempty, kind: "static" | "execution" | "red_green"}` array. Main이 승인된 범위에 필요한 항목만 지정한다. 모든 제품 입력의 Cartesian product나 새 제품 요구사항을 만들지 않는다.
- `executions`: main이 독립적으로 검토한 실행 catalog array. 각 record의 exact keys는 `id`, `origin`, `test_id`, `bundle_sha256`, `test_file_sha256`, `argv`, `exit_code`, `failure_kind`, `evidence_path`, `evidence_sha256`이다. `id`는 unique nonempty string, `test_id`는 named regression의 `path::symbol` ID이며 일반 실행-only record에서는 빈 문자열을 허용한다. 빈 ID/placeholder test digest는 named-test 검증 근거가 아니고 `red_green`에는 사용할 수 없다. Digest는 lowercase64, `argv`는 nonempty string array, exit는 bool이 아닌 integer다. `failure_kind`는 `none`/`assertion`/`approved_behavior`/`import`/`environment`/`timeout`이다. Relative `evidence_path`는 bundle root 아래 별도 immutable 실행 기록을 참조하며 실제 regular file bytes가 `evidence_sha256`와 같아야 한다. Source map에 실행 metadata를 넣어 source를 순환 결속하지 않는다.
- Catalog `origin`은 `direct`/`inherited`이며 main이 실제 actor·관찰 출처를 대조한다. `direct` 실행 인용은 `execute` 허용이 있어야 한다. `inherited`는 이미 검토된 다른 producer의 기록을 static-only worker가 인용하는 경우이며 직접 실행했다고 인정하지 않는다. Worker는 catalog origin을 재정의할 수 없다. Hash와 metadata 형식의 PASS 자체는 실행 인증이 아니다.
**Worker result 계약:** exact top-level keys는 `schema`, `source`, `stage`, `bundle_sha256`, `claims`이다. `schema`는 exact `gitea-agent.delegation-evidence-result/v1`이고 source/stage/bundle digest는 main context와 exact-match해야 한다. `claims`의 exact record는 `{id, status, anchors, execution_ids}`이다. Claim ID set은 main obligations와 exact-match해야 하고 누락·추가·중복은 거부한다. `status`는 `supported`/`indeterminate`; indeterminate는 PASS로 채택하지 않는다. `execution_ids`는 중복 없는 기존 독립 catalog ID 참조이며 worker가 새 실행 record를 스스로 만들어 넣는 형식이 아니다.
Anchor variant의 exact keys와 검증은 다음과 같다.
- `{kind: "lines", path, start_line, end_line, text_sha256}`: 파일 범위 내 positive integer line range와 raw `splitlines(keepends=True)` byte slice의 SHA-256을 대조한다. 인용 text의 존재를 검증할 뿐 주장한 동작과 text가 의미적으로 일치한다는 판정은 main이 한다.
- `{kind: "test", path, symbol}`: Python 파일을 실행/import하지 않고 AST에서 qualified named test function/method의 존재를 확인한다. Local nested function·dynamic 생성·파싱할 수 없는 source를 실제 수집된 test로 추정하지 않는다. named symbol 존재는 assertion 검출력을 인증하지 않는다. Pytest parameter instance/collection completeness도 인증하지 않는다.
- `{kind: "absence", path}`: `git_tree`의 authoritative exact Git tree에서만 부재를 확인한다. Git 조회 실패나 bundle 누락을 repository 부재로 판정하지 않는다. `captured_worktree`의 부재 주장은 authoritative current-tree 근거가 없어 indeterminate/BLOCK으로 남긴다.
Static obligation은 유효 anchor를 요구하고 execution obligation은 독립 catalog 참조를 요구한다. `red_green`은 실제 snapshot에 저장된 named test와 같은 ID/file SHA-256의 RED·GREEN을 연결한다. RED는 nonzero `assertion`/`approved_behavior`, GREEN은 exit 0이며 GREEN source bundle digest가 채택 대상과 일치해야 한다. TDD의 RED bundle digest는 수정 전 source일 수 있으므로 GREEN으로 재명명하지 않고 독립 기록의 원래 scope를 유지한다. import·환경 오류는 RED로 인정하지 않는다. Missing regression/record, 잘못된 test/hash/scope, unauthorized direct execution과 출처 불명 기록은 채택을 차단한다.
**판정:** CLI exit 0은 증빙 연결 PASS, 1은 불일치·indeterminate BLOCK, 2는 입력 거부다. 고정된 reason codes/counts의 bounded JSON만 출력하며 source·argv·입력값·Git stderr·credential을 echo하지 않는다. BLOCK/입력 거부를 자동 제품 결함이나 `REQUEST_CHANGES`로 바꾸지 않는다. 해당 주장을 판정 근거에서 제외하고 main이 원본을 직접 재검증하거나 미검증으로 보고한다. Gate의 PASS는 의미 검토, 실제 실행 관찰, 외부 게시 readback 또는 전체 작업 완료의 증명이 아니다.
이 JSON은 기존 자료를 잇는 harness adapter 입력이다. 정본 verification run 또는 허용된 reviewer workflow artifact에 보존하며 제품의 추가 evidence commit이나 전체 테스트 재실행 의무를 만들지 않는다. 별도 collector 실행, 제품별 테스트 정책, manifest v2 변경 또는 role의 제품 실행 제한 완화를 추가하지 않는다. Skill/document wiring 테스트는 소비 workflow가 실제 이 절차를 실행했다는 증명이 아니다.
#### Nested input contracts
All object keys below are required and exact; unknown/missing keys, duplicate JSON keys, duplicate list identities, incorrect scalar types and non-regular/unbounded JSON inputs block. Empty strings are allowed only where explicitly stated. References are nonempty strings identifying existing verification command/result or approval records, not their secret contents. Full source revision is lowercase 40-hex; byte/scope digests are lowercase 64-hex. Integer fields reject booleans. CLI exit codes are 0 for structural PASS, 1 for detected violations/drift, 2 for rejected input; APIs raise `GuardError` for rejected input or return a status/violations result.
@@ -0,0 +1,322 @@
import copy
import hashlib
import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from harness.lib import delegation_evidence as evidence
ROOT = Path(__file__).resolve().parents[3]
SCHEMA_C = "gitea-agent.delegation-evidence-context/v1"
SCHEMA_R = "gitea-agent.delegation-evidence-result/v1"
class DelegationEvidenceTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name) / "bundle"
self.root.mkdir()
(self.root / "tests").mkdir()
(self.root / "src").mkdir()
(self.root / "tests/sample_test.py").write_bytes(b"def test_sample():\n assert True\n")
(self.root / "src/app.py").write_bytes(b"VALUE = 1\n")
self.files = {p: hashlib.sha256((self.root / p).read_bytes()).hexdigest()
for p in ("src/app.py", "tests/sample_test.py")}
self.bundle_sha = self.digest(self.files)
(self.root / "evidence").mkdir()
(self.root / "evidence/run.txt").write_bytes(b"exit 0\n")
evidence_sha = hashlib.sha256((self.root / "evidence/run.txt").read_bytes()).hexdigest()
self.context = {
"schema": SCHEMA_C,
"source": {"kind": "captured_worktree", "base_revision": "a" * 40},
"stage": "implementation",
"bundle_sha256": self.bundle_sha,
"files": self.files,
"allowed_actions": ["static", "execute"],
"obligations": [
{"id": "static-source", "kind": "static"},
{"id": "run-sample", "kind": "execution"},
],
"executions": [{"id": "exec-1", "test_id": "", "bundle_sha256": self.bundle_sha,
"test_file_sha256": "0" * 64, "argv": ["python3", "-c", "pass"],
"exit_code": 0, "failure_kind": "none", "evidence_path": "evidence/run.txt",
"evidence_sha256": evidence_sha, "origin": "direct"}],
}
self.result = {
"schema": SCHEMA_R,
"source": copy.deepcopy(self.context["source"]),
"stage": self.context["stage"],
"bundle_sha256": self.bundle_sha,
"claims": [
{"id": "static-source", "status": "supported", "anchors": [
{"kind": "lines", "path": "src/app.py", "start_line": 1,
"end_line": 1, "text_sha256": self.line_hash("src/app.py", 1, 1)}
], "execution_ids": []},
{"id": "run-sample", "status": "supported", "anchors": [], "execution_ids": ["exec-1"]},
],
}
def tearDown(self):
self.tmp.cleanup()
@staticmethod
def digest(value):
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
def line_hash(self, path, start, end):
lines = (self.root / path).read_bytes().splitlines(keepends=True)
return hashlib.sha256(b"".join(lines[start - 1:end])).hexdigest()
def make_git_repo(self, files=None, name="repo"):
repo = Path(self.tmp.name) / name
repo.mkdir()
for path in (files or self.files):
target = repo / path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes((self.root / path).read_bytes())
subprocess.run(["/usr/bin/git", "init", "-q", str(repo)], check=True)
subprocess.run(["/usr/bin/git", "-C", str(repo), "config", "user.email", "test@example.invalid"], check=True)
subprocess.run(["/usr/bin/git", "-C", str(repo), "config", "user.name", "Test"], check=True)
subprocess.run(["/usr/bin/git", "-C", str(repo), "add", "."], check=True)
subprocess.run(["/usr/bin/git", "-C", str(repo), "commit", "-q", "-m", "fixture"], check=True)
revision = subprocess.check_output(["/usr/bin/git", "-C", str(repo), "rev-parse", "HEAD"], text=True).strip()
return repo / ".git", revision
def git_context(self, git_revision):
context = copy.deepcopy(self.context)
context["source"] = {"kind": "git_tree", "revision": git_revision}
result = copy.deepcopy(self.result)
result["source"] = copy.deepcopy(context["source"])
return context, result
def test_worker_source_stage_bundle_mismatches_are_detected_blocks(self):
for key, value in [('source', {'kind': 'captured_worktree', 'base_revision': 'b' * 40}),
('stage', 'stale-stage'), ('bundle_sha256', '0' * 64)]:
bad = copy.deepcopy(self.result)
bad[key] = value
with self.subTest(field=key):
try:
out = evidence.verify_evidence(str(self.root), self.context, bad)
except evidence.EvidenceError:
self.fail('syntactically valid provenance disagreement must return BLOCK')
self.assertEqual(out['status'], 'block')
self.assertIn('result_' + key + '_mismatch', out['reason_codes'])
def test_valid_static_lines_and_bounded_counts(self):
out = evidence.verify_evidence(str(self.root), self.context, self.result)
self.assertEqual(out["status"], "pass")
self.assertEqual(out["reason_codes"], [])
self.assertEqual(out["counts"], {"files": 2, "obligations": 2, "claims": 2, "anchors": 1, "executions": 1})
def test_wrong_line_hash_blocks(self):
bad = copy.deepcopy(self.result)
bad["claims"][0]["anchors"][0]["text_sha256"] = "0" * 64
out = evidence.verify_evidence(str(self.root), self.context, bad)
self.assertEqual(out["status"], "block")
self.assertIn("anchor_hash_mismatch", out["reason_codes"])
def test_indeterminate_claim_blocks(self):
result = copy.deepcopy(self.result)
result["claims"][0]["status"] = "indeterminate"
out = evidence.verify_evidence(str(self.root), self.context, result)
self.assertEqual(out["status"], "block")
self.assertIn("indeterminate_claim", out["reason_codes"])
def test_test_anchor_requires_real_python_function(self):
result = copy.deepcopy(self.result)
result["claims"][0]["anchors"] = [{"kind": "test", "path": "tests/sample_test.py", "symbol": "test_sample"}]
out = evidence.verify_evidence(str(self.root), self.context, result)
self.assertEqual(out["status"], "pass")
result["claims"][0]["anchors"][0]["symbol"] = "missing"
self.assertIn("test_anchor_missing", evidence.verify_evidence(str(self.root), self.context, result)["reason_codes"])
def test_claim_ids_must_exactly_match_obligations(self):
bad = copy.deepcopy(self.result)
bad["claims"][1]["id"] = "extra"
with self.assertRaises(evidence.EvidenceError):
evidence.verify_evidence(str(self.root), self.context, bad)
def test_captured_worktree_absence_is_indeterminate(self):
context = copy.deepcopy(self.context)
context["files"] = {"src/app.py": self.files["src/app.py"]}
context["bundle_sha256"] = self.digest(context["files"])
context["obligations"] = [{"id": "absent", "kind": "static"}]
result = {"schema": SCHEMA_R, "source": context["source"], "stage": context["stage"],
"bundle_sha256": context["bundle_sha256"], "claims": [{"id": "absent", "status": "supported",
"anchors": [{"kind": "absence", "path": "tests/missing.py"}], "execution_ids": []}]}
out = evidence.verify_evidence(str(self.root), context, result)
self.assertEqual(out["status"], "block")
self.assertIn("absence_indeterminate", out["reason_codes"])
def test_git_tree_matching_frozen_bundle_passes(self):
git_dir, revision = self.make_git_repo()
context, result = self.git_context(revision)
out = evidence.verify_evidence(str(self.root), context, result, str(git_dir))
self.assertEqual(out["status"], "pass")
def test_git_tree_altered_frozen_snapshot_blocks(self):
git_dir, revision = self.make_git_repo()
(self.root / "src/app.py").write_bytes(b"VALUE = 2\n")
context, result = self.git_context(revision)
out = evidence.verify_evidence(str(self.root), context, result, str(git_dir))
self.assertEqual(out["status"], "block")
self.assertIn("source_hash_mismatch", out["reason_codes"])
def test_git_tree_source_mismatch_blocks(self):
git_dir, revision = self.make_git_repo()
repo = Path(self.tmp.name) / "other"
repo.mkdir()
for path in self.files:
target = repo / path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes((self.root / path).read_bytes())
(repo / "src/app.py").write_bytes(b"VALUE = 9\n")
subprocess.run(["/usr/bin/git", "init", "-q", str(repo)], check=True)
subprocess.run(["/usr/bin/git", "-C", str(repo), "config", "user.email", "test@example.invalid"], check=True)
subprocess.run(["/usr/bin/git", "-C", str(repo), "config", "user.name", "Test"], check=True)
subprocess.run(["/usr/bin/git", "-C", str(repo), "add", "."], check=True)
subprocess.run(["/usr/bin/git", "-C", str(repo), "commit", "-q", "-m", "other"], check=True)
other_revision = subprocess.check_output(["/usr/bin/git", "-C", str(repo), "rev-parse", "HEAD"], text=True).strip()
context, result = self.git_context(other_revision)
out = evidence.verify_evidence(str(self.root), context, result, str(git_dir))
self.assertEqual(out["status"], "block")
self.assertIn("source_hash_mismatch", out["reason_codes"])
context_path = self.root / "context.json"
result_path = self.root / "result.json"
context_path.write_text(json.dumps(context))
result_path.write_text(json.dumps(result))
proc = subprocess.run([sys.executable, str(ROOT / "harness/lib/delegation_evidence.py"),
"--bundle-root", str(self.root), "--context", str(context_path),
"--result", str(result_path), "--git-dir", str(git_dir)],
text=True, capture_output=True)
self.assertEqual(proc.returncode, 1)
self.assertEqual(json.loads(proc.stdout)["status"], "block")
def test_git_tree_present_path_cannot_be_claimed_absent(self):
git_dir, revision = self.make_git_repo()
context, result = self.git_context(revision)
context["obligations"] = [{"id": "absent", "kind": "static"}]
result["claims"] = [{"id": "absent", "status": "supported",
"anchors": [{"kind": "absence", "path": "tests/sample_test.py"}],
"execution_ids": []}]
out = evidence.verify_evidence(str(self.root), context, result, str(git_dir))
self.assertEqual(out["status"], "block")
self.assertIn("absence_not_proven", out["reason_codes"])
def test_git_tree_genuine_missing_path_is_authoritative(self):
git_dir, revision = self.make_git_repo()
context, result = self.git_context(revision)
context["obligations"] = [{"id": "absent", "kind": "static"}]
result["claims"] = [{"id": "absent", "status": "supported",
"anchors": [{"kind": "absence", "path": "tests/missing.py"}],
"execution_ids": []}]
out = evidence.verify_evidence(str(self.root), context, result, str(git_dir))
self.assertEqual(out["status"], "pass")
def test_git_tree_absence_uses_original_directory_fd_after_replacement(self):
git_dir, revision = self.make_git_repo()
replacement, _ = self.make_git_repo(name="replacement")
replacement_repo = replacement.parent
(replacement_repo / "tests/missing.py").write_bytes(b"present\n")
subprocess.run(["/usr/bin/git", "-C", str(replacement_repo), "add", "."], check=True)
subprocess.run(["/usr/bin/git", "-C", str(replacement_repo), "commit", "-q", "-m", "replacement"], check=True)
context, result = self.git_context(revision)
context["obligations"] = [{"id": "absent", "kind": "static"}]
result["claims"] = [{"id": "absent", "status": "supported",
"anchors": [{"kind": "absence", "path": "tests/missing.py"}],
"execution_ids": []}]
original_git = evidence._git
moved = {"done": False}
def replacing_git(git_path, args, fd):
if args[0] == "ls-tree" and args[-1] == "tests/missing.py" and not moved["done"]:
moved["done"] = True
backup = Path(self.tmp.name) / "original.git"
os.rename(git_dir, backup)
os.rename(replacement, git_dir)
return original_git(git_path, args, fd)
evidence._git = replacing_git
try:
out = evidence.verify_evidence(str(self.root), context, result, str(git_dir))
finally:
evidence._git = original_git
self.assertEqual(out["status"], "pass")
def test_inherited_execution_is_allowed_for_static_only_worker_but_direct_is_not(self):
context = copy.deepcopy(self.context)
context["allowed_actions"] = ["static"]
context["executions"][0]["origin"] = "inherited"
out = evidence.verify_evidence(str(self.root), context, self.result)
self.assertEqual(out["status"], "pass")
context["executions"][0]["origin"] = "direct"
out = evidence.verify_evidence(str(self.root), context, self.result)
self.assertEqual(out["status"], "block")
self.assertIn("unauthorized_execute", out["reason_codes"])
def test_execution_origin_is_required_and_closed(self):
for origin in ("worker", None):
context = copy.deepcopy(self.context)
if origin is None:
del context["executions"][0]["origin"]
else:
context["executions"][0]["origin"] = origin
with self.subTest(origin=origin), self.assertRaises(evidence.EvidenceError):
evidence.verify_evidence(str(self.root), context, self.result)
def test_inherited_red_green_is_accepted_without_execute_permission(self):
context = copy.deepcopy(self.context)
context["allowed_actions"] = ["static"]
context["obligations"] = [{"id": "tdd", "kind": "red_green"}]
test_sha = self.files["tests/sample_test.py"]
context["executions"] = [
{"id": "red", "test_id": "tests/sample_test.py::test_sample", "bundle_sha256": "b" * 64,
"test_file_sha256": test_sha, "argv": ["python3", "-m", "unittest"], "exit_code": 1,
"failure_kind": "assertion", "evidence_path": "evidence/run.txt",
"evidence_sha256": hashlib.sha256((self.root / "evidence/run.txt").read_bytes()).hexdigest(),
"origin": "inherited"},
{"id": "green", "test_id": "tests/sample_test.py::test_sample", "bundle_sha256": self.bundle_sha,
"test_file_sha256": test_sha, "argv": ["python3", "-m", "unittest"], "exit_code": 0,
"failure_kind": "none", "evidence_path": "evidence/run.txt",
"evidence_sha256": hashlib.sha256((self.root / "evidence/run.txt").read_bytes()).hexdigest(),
"origin": "inherited"},
]
result = {"schema": SCHEMA_R, "source": context["source"], "stage": context["stage"],
"bundle_sha256": self.bundle_sha, "claims": [{"id": "tdd", "status": "supported",
"anchors": [{"kind": "test", "path": "tests/sample_test.py", "symbol": "test_sample"}],
"execution_ids": ["red", "green"]}]}
self.assertEqual(evidence.verify_evidence(str(self.root), context, result)["status"], "pass")
def test_rejected_input_and_unknown_cli_are_bounded(self):
bad = copy.deepcopy(self.context)
bad["files"]["../secret"] = "0" * 64
with self.assertRaises(evidence.EvidenceError):
evidence.verify_evidence(str(self.root), bad, self.result)
c = self.root / "context.json"; r = self.root / "result.json"
c.write_text(json.dumps(self.context)); r.write_text(json.dumps(self.result))
proc = subprocess.run([sys.executable, str(ROOT / "harness/lib/delegation_evidence.py"),
"--bundle-root", str(self.root), "--context", str(c), "--result", str(r), "--unknown=secret"],
text=True, capture_output=True)
self.assertEqual(proc.returncode, 2)
self.assertNotIn("secret", proc.stdout + proc.stderr)
self.assertEqual(json.loads(proc.stdout), {"status": "block", "reason_codes": ["rejected_input"], "counts": {}})
def test_duplicate_json_keys_and_symlink_are_rejected(self):
c = self.root / "context.json"; r = self.root / "result.json"
c.write_text('{"schema":"x","schema":"secret"}')
r.write_text(json.dumps(self.result))
proc = subprocess.run([sys.executable, str(ROOT / "harness/lib/delegation_evidence.py"),
"--bundle-root", str(self.root), "--context", str(c), "--result", str(r)], capture_output=True, text=True)
self.assertEqual(proc.returncode, 2); self.assertNotIn("secret", proc.stdout + proc.stderr)
real = self.root / "real.json"; real.write_text(json.dumps(self.context))
alias = self.root / "alias.json"; alias.symlink_to(real)
with self.assertRaises(evidence.EvidenceError):
evidence._json_file(str(alias))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,50 @@
"""Procedural consumer wiring is not proof of live agent execution."""
from pathlib import Path
import unittest
ROOT = Path(__file__).resolve().parents[2]
ROLES = [
'agents/impl/skills/gitea-issue-implementation/SKILL.md',
'agents/impl/skills/gitea-issue-rework/SKILL.md',
'agents/spec/skills/gitea-implementation-spec-review/SKILL.md',
'agents/review/skills/gitea-code-review/SKILL.md',
]
class DelegationEvidenceContractTests(unittest.TestCase):
def test_consumers_require_gate_before_adoption(self):
for path in ROLES:
with self.subTest(path=path):
text = (ROOT / path).read_text()
for required in ['## Delegation evidence acceptance gate',
'delegation_evidence.py', '위임 결과 채택 이전',
'main이 독립적으로 고정한 context',
'worker 자기 선언으로 context를 만들지 않는다']:
self.assertIn(required, text)
def test_canonical_contract_names_cli_and_schema(self):
text = (ROOT / 'operations/repository-cache.md').read_text()
for required in ['### Delegation evidence acceptance preflight',
'--bundle-root', '--context', '--result', '--git-dir',
'gitea-agent.delegation-evidence-context/v1',
'gitea-agent.delegation-evidence-result/v1']:
self.assertIn(required, text)
def test_source_modes_and_coverage_limits_are_explicit(self):
text = (ROOT / 'operations/repository-cache.md').read_text()
for required in ['captured_worktree', 'base_revision', 'git_tree',
'bundle에 없다는 이유로 repository 부재로 판정하지 않는다',
'named symbol 존재는 assertion 검출력을 인증하지 않는다',
'제품의 추가 evidence commit이나 전체 테스트 재실행 의무를 만들지 않는다']:
self.assertIn(required, text)
def test_trusted_execution_catalog_and_failure_origin_limits(self):
text = (ROOT / 'operations/repository-cache.md').read_text()
for required in ['독립 실행 catalog', 'import·환경 오류는 RED로 인정하지 않는다',
'허용 작업', '실제 command/output/exit와 실패 원인',
'gate는 context의 신뢰성을 스스로 인증하지 않는다']:
self.assertIn(required, text)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,385 @@
"""Independent safety/regression coverage for delegation evidence acceptance."""
import copy
import hashlib
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from harness.lib import delegation_evidence as evidence
ROOT = Path(__file__).resolve().parents[3]
HELPER = ROOT / "harness" / "lib" / "delegation_evidence.py"
CONTEXT_SCHEMA = "gitea-agent.delegation-evidence-context/v1"
RESULT_SCHEMA = "gitea-agent.delegation-evidence-result/v1"
REVISION = "a" * 40
class DelegationEvidenceSafetyTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(dir="/opt/data/cache/scratch")
self.base = Path(self.tmp.name)
self.bundle = self.base / "bundle"
(self.bundle / "tests").mkdir(parents=True)
(self.bundle / "src").mkdir()
(self.bundle / "evidence").mkdir()
(self.bundle / "tests/example.py").write_bytes(
b"def test_fn():\n assert True\n"
)
(self.bundle / "src/value.py").write_bytes(b"VALUE = 1\n")
(self.bundle / "evidence/run.txt").write_bytes(b"recorded output\n")
self.files = {
path: hashlib.sha256((self.bundle / path).read_bytes()).hexdigest()
for path in ("src/value.py", "tests/example.py")
}
self.bundle_sha = self.digest(self.files)
self.evidence_sha = hashlib.sha256(
(self.bundle / "evidence/run.txt").read_bytes()
).hexdigest()
self.context = self.make_context()
self.result = self.make_result()
def tearDown(self):
self.tmp.cleanup()
@staticmethod
def digest(value):
return hashlib.sha256(
json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
).hexdigest()
def make_context(self, *, actions=None, obligations=None, executions=None):
return {
"schema": CONTEXT_SCHEMA,
"source": {"kind": "captured_worktree", "base_revision": REVISION},
"stage": "implementation",
"bundle_sha256": self.bundle_sha,
"files": copy.deepcopy(self.files),
"allowed_actions": ["static", "execute"] if actions is None else actions,
"obligations": [
{"id": "static-src", "kind": "static"},
{"id": "run-test", "kind": "execution"},
] if obligations is None else obligations,
"executions": [{
"id": "exec-1", "origin": "direct", "test_id": "",
"bundle_sha256": self.bundle_sha, "test_file_sha256": "0" * 64,
"argv": ["python3", "-B", "-c", "pass"], "exit_code": 0,
"failure_kind": "none", "evidence_path": "evidence/run.txt",
"evidence_sha256": self.evidence_sha,
}] if executions is None else executions,
}
def make_result(self, *, source=None, stage=None, bundle_sha=None, claims=None):
return {
"schema": RESULT_SCHEMA,
"source": copy.deepcopy(self.context["source"] if source is None else source),
"stage": self.context["stage"] if stage is None else stage,
"bundle_sha256": self.bundle_sha if bundle_sha is None else bundle_sha,
"claims": [
{"id": "static-src", "status": "supported", "anchors": [{
"kind": "lines", "path": "src/value.py", "start_line": 1,
"end_line": 1, "text_sha256": self.line_hash("src/value.py", 1, 1),
}], "execution_ids": []},
{"id": "run-test", "status": "supported", "anchors": [],
"execution_ids": ["exec-1"]},
] if claims is None else claims,
}
def line_hash(self, path, start, end):
lines = (self.bundle / path).read_bytes().splitlines(keepends=True)
return hashlib.sha256(b"".join(lines[start - 1:end])).hexdigest()
def api(self, context=None, result=None, root=None):
return evidence.verify_evidence(
str(self.bundle if root is None else root),
self.context if context is None else context,
self.result if result is None else result,
)
def cli(self, context_text=None, result=None, *, root=None, context_path=None,
result_path=None, extra=None, timeout=3):
root = self.bundle if root is None else Path(root)
context_path = self.base / "context.json" if context_path is None else Path(context_path)
result_path = self.base / "result.json" if result_path is None else Path(result_path)
if context_text is None:
context_path.write_text(json.dumps(self.context), encoding="utf-8")
elif isinstance(context_text, bytes):
context_path.write_bytes(context_text)
else:
context_path.write_text(context_text, encoding="utf-8")
if result is not None:
result_path.write_text(json.dumps(result), encoding="utf-8")
else:
result_path.write_text(json.dumps(self.result), encoding="utf-8")
command = [sys.executable, "-B", str(HELPER), "--bundle-root", str(root),
"--context", str(context_path), "--result", str(result_path)]
if extra:
command.extend(extra)
return subprocess.run(command, capture_output=True, text=True, timeout=timeout)
def assert_rejected(self, value, *, field=None):
bad = copy.deepcopy(self.context)
if field is None:
bad = value
else:
target = bad
for part in field[:-1]:
target = target[part]
target[field[-1]] = value
with self.assertRaises(evidence.EvidenceError):
self.api(context=bad)
def test_real_paths_reject_root_file_ancestor_and_canonical_aliases(self):
alias = self.base / "bundle-alias"
alias.symlink_to(self.bundle, target_is_directory=True)
with self.assertRaises(evidence.EvidenceError):
self.api(root=alias)
with self.assertRaises(evidence.EvidenceError):
self.api(root=self.base / "missing" / ".." / "bundle")
outside = self.base / "outside.txt"
outside.write_bytes(b"outside")
(self.bundle / "src" / "alias.py").symlink_to(outside)
context = copy.deepcopy(self.context)
context["files"]["src/alias.py"] = hashlib.sha256(outside.read_bytes()).hexdigest()
context["bundle_sha256"] = self.digest(context["files"])
with self.assertRaises(evidence.EvidenceError):
self.api(context=context)
def test_json_input_symlink_fifo_and_source_fifo_are_rejected_without_hang(self):
real_context = self.base / "real-context.json"
real_context.write_text(json.dumps(self.context), encoding="utf-8")
alias = self.base / "context.json"
alias.symlink_to(real_context)
result_path = self.base / "result.json"
result_path.write_text(json.dumps(self.result), encoding="utf-8")
with self.assertRaises(evidence.EvidenceError):
evidence._json_file(str(alias))
fifo = self.base / "context-fifo"
os.mkfifo(fifo)
proc = subprocess.Popen(
[sys.executable, "-B", str(HELPER), "--bundle-root", str(self.bundle),
"--context", str(fifo), "--result", str(result_path)],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
)
stdout, stderr = proc.communicate(timeout=3)
self.assertEqual(proc.returncode, 2, (stdout, stderr))
self.assertEqual(json.loads(stdout), {"status": "block", "reason_codes": ["rejected_input"], "counts": {}})
source_fifo = self.bundle / "src" / "fifo.py"
os.mkfifo(source_fifo)
context = copy.deepcopy(self.context)
context["files"]["src/fifo.py"] = "0" * 64
context["bundle_sha256"] = self.digest(context["files"])
with self.assertRaises(evidence.EvidenceError):
self.api(context=context)
def test_oversized_source_and_json_are_bounded(self):
huge = self.bundle / "src" / "huge.py"
huge.write_bytes(b"x" * (1024 * 1024 + 1))
context = copy.deepcopy(self.context)
context["files"]["src/huge.py"] = hashlib.sha256(huge.read_bytes()).hexdigest()
context["bundle_sha256"] = self.digest(context["files"])
with self.assertRaises(evidence.EvidenceError):
self.api(context=context)
proc = self.cli(context_text=b"{" + b"\"x\":\"" + b"a" * (1024 * 1024) + b"\"}")
self.assertEqual(proc.returncode, 2)
self.assertEqual(json.loads(proc.stdout)["reason_codes"], ["rejected_input"])
def test_duplicate_unknown_and_wrong_typed_json_fields_are_rejected(self):
cases = [
b'{"schema":"x","schema":"y"}',
json.dumps({**self.context, "unknown": 1}).encode(),
]
for payload in cases:
with self.subTest(case=payload[:20]):
proc = self.cli(context_text=payload)
self.assertEqual(proc.returncode, 2)
for path, value in [
(("stage",), 1), (("source", "revision"), True),
(("files",), []), (("allowed_actions",), "static"),
(("obligations",), {}), (("executions",), "none"),
]:
context = copy.deepcopy(self.context)
target = context
for part in path[:-1]:
target = target[part]
if path == ("source", "revision"):
context["source"] = {"kind": "git_tree", "revision": value}
else:
target[path[-1]] = value
with self.subTest(path=path):
with self.assertRaises(evidence.EvidenceError):
self.api(context=context)
def test_malformed_digests_revisions_and_boolean_integer_fields_are_rejected(self):
cases = [
(("bundle_sha256",), "not-a-digest"),
(("source", "base_revision"), "b" * 39),
(("files", "src/value.py"), "A" * 64),
]
for path, value in cases:
context = copy.deepcopy(self.context)
if path[0] == "files":
context[path[0]][path[1]] = value
elif len(path) == 2:
context[path[0]][path[1]] = value
else:
context[path[0]] = value
with self.subTest(path=path):
with self.assertRaises(evidence.EvidenceError):
self.api(context=context)
context = copy.deepcopy(self.context)
context["executions"][0]["exit_code"] = True
with self.assertRaises(evidence.EvidenceError):
self.api(context=context)
def test_duplicate_missing_and_extra_claim_or_obligation_ids_block_input(self):
cases = []
duplicate_claim = copy.deepcopy(self.result)
duplicate_claim["claims"][1]["id"] = duplicate_claim["claims"][0]["id"]
cases.append(duplicate_claim)
missing_claim = copy.deepcopy(self.result)
missing_claim["claims"] = missing_claim["claims"][:1]
cases.append(missing_claim)
extra_claim = copy.deepcopy(self.result)
extra_claim["claims"].append({"id": "extra", "status": "supported", "anchors": [], "execution_ids": []})
cases.append(extra_claim)
for result in cases:
with self.subTest(ids=[claim["id"] for claim in result["claims"]]):
with self.assertRaises(evidence.EvidenceError):
self.api(result=result)
context = copy.deepcopy(self.context)
context["obligations"].append({"id": "static-src", "kind": "static"})
with self.assertRaises(evidence.EvidenceError):
self.api(context=context)
def test_stale_source_stage_and_bundle_claims_block(self):
stale_context = copy.deepcopy(self.context)
stale_context["stage"] = "review"
self.assertIn("result_stage_mismatch", self.api(context=stale_context)["reason_codes"])
for field, value in [("stage", "review"), ("bundle_sha256", "f" * 64),
("source", {"kind": "captured_worktree", "base_revision": "b" * 40})]:
stale_result = copy.deepcopy(self.result)
stale_result[field] = value
with self.subTest(field=field):
out = self.api(result=stale_result)
self.assertEqual(out["status"], "block")
self.assertIn("result_" + field + "_mismatch", out["reason_codes"])
def test_line_anchor_rejects_zero_negative_reversed_out_of_range_and_wrong_hash(self):
for start, end in [(0, 1), (-1, 1), (2, 1), (1, 99)]:
result = copy.deepcopy(self.result)
result["claims"][0]["anchors"][0].update(start_line=start, end_line=end)
with self.subTest(start=start, end=end):
if end == 99:
out = self.api(result=result)
self.assertIn("anchor_range_invalid", out["reason_codes"])
else:
with self.assertRaises(evidence.EvidenceError):
self.api(result=result)
result = copy.deepcopy(self.result)
result["claims"][0]["anchors"][0]["text_sha256"] = "0" * 64
out = self.api(result=result)
self.assertEqual(out["status"], "block")
self.assertIn("anchor_hash_mismatch", out["reason_codes"])
def test_ast_anchor_rejects_nested_local_dynamic_and_syntax_error_sources(self):
variants = {
"nested": b"def outer():\n def test_fn():\n pass\n",
"dynamic": b"globals()['test_fn'] = lambda: None\n",
"syntax": b"def test_fn(:\n",
}
for name, source in variants.items():
path = self.bundle / "tests/example.py"
path.write_bytes(source)
context = copy.deepcopy(self.context)
context["files"]["tests/example.py"] = hashlib.sha256(source).hexdigest()
context["bundle_sha256"] = self.digest(context["files"])
result = copy.deepcopy(self.result)
result["bundle_sha256"] = context["bundle_sha256"]
result["claims"][0]["anchors"] = [{
"kind": "test", "path": "tests/example.py", "symbol": "test_fn",
}]
with self.subTest(source=name):
out = self.api(context=context, result=result)
self.assertEqual(out["status"], "block")
self.assertIn("test_anchor_missing", out["reason_codes"])
def test_unknown_execution_reference_and_authorization_origin_are_blocked(self):
result = copy.deepcopy(self.result)
result["claims"][1]["execution_ids"] = ["missing"]
out = self.api(result=result)
self.assertEqual(out["status"], "block")
self.assertIn("unknown_execution_id", out["reason_codes"])
context = copy.deepcopy(self.context)
context["allowed_actions"] = ["static"]
out = self.api(context=context)
self.assertEqual(out["status"], "block")
self.assertIn("unauthorized_execute", out["reason_codes"])
context["executions"][0]["origin"] = "inherited"
self.assertEqual(self.api(context=context)["status"], "pass")
def test_red_green_rejects_import_environment_timeout_and_mismatch_records(self):
test_sha = self.files["tests/example.py"]
obligations = [{"id": "tdd", "kind": "red_green"}]
def execution(eid, exit_code, failure_kind, bundle, test_file=test_sha):
return {
"id": eid, "origin": "inherited", "test_id": "tests/example.py::test_fn",
"bundle_sha256": bundle, "test_file_sha256": test_file,
"argv": ["python3", "-B", "-m", "unittest"], "exit_code": exit_code,
"failure_kind": failure_kind, "evidence_path": "evidence/run.txt",
"evidence_sha256": self.evidence_sha,
}
for failure_kind in ("import", "environment", "timeout"):
context = self.make_context(actions=["static"], obligations=obligations,
executions=[execution("red", 1, failure_kind, "b" * 64),
execution("green", 0, "none", self.bundle_sha)])
result = self.make_result(claims=[{
"id": "tdd", "status": "supported", "anchors": [{
"kind": "test", "path": "tests/example.py", "symbol": "test_fn",
}], "execution_ids": ["red", "green"],
}])
with self.subTest(failure_kind=failure_kind):
out = self.api(context=context, result=result)
self.assertEqual(out["status"], "block")
self.assertIn("red_green_missing_red", out["reason_codes"])
context = self.make_context(actions=["static"], obligations=obligations,
executions=[execution("red", 1, "assertion", "b" * 64, "c" * 64),
execution("green", 0, "none", self.bundle_sha)])
result = self.make_result(claims=[{
"id": "tdd", "status": "supported", "anchors": [{
"kind": "test", "path": "tests/example.py", "symbol": "test_fn",
}], "execution_ids": ["red", "green"],
}])
out = self.api(context=context, result=result)
self.assertIn("red_green_test_scope_mismatch", out["reason_codes"])
def test_missing_green_and_evidence_hash_mismatch_block(self):
obligations = [{"id": "tdd", "kind": "red_green"}]
test_sha = self.files["tests/example.py"]
executions = [{
"id": "red", "origin": "inherited", "test_id": "tests/example.py::test_fn",
"bundle_sha256": "b" * 64, "test_file_sha256": test_sha,
"argv": ["python3", "-B", "-m", "unittest"], "exit_code": 1,
"failure_kind": "assertion", "evidence_path": "evidence/run.txt",
"evidence_sha256": "0" * 64,
}]
context = self.make_context(actions=["static"], obligations=obligations, executions=executions)
result = self.make_result(claims=[{
"id": "tdd", "status": "supported", "anchors": [{
"kind": "test", "path": "tests/example.py", "symbol": "test_fn",
}], "execution_ids": ["red"],
}])
out = self.api(context=context, result=result)
self.assertIn("red_green_missing_green", out["reason_codes"])
self.assertIn("evidence_hash_mismatch", out["reason_codes"])
if __name__ == "__main__":
unittest.main()