diff --git a/harness/agents/impl/skills/gitea-issue-implementation/SKILL.md b/harness/agents/impl/skills/gitea-issue-implementation/SKILL.md index 1526fc7..399bb32 100644 --- a/harness/agents/impl/skills/gitea-issue-implementation/SKILL.md +++ b/harness/agents/impl/skills/gitea-issue-implementation/SKILL.md @@ -1,7 +1,7 @@ --- name: gitea-issue-implementation description: 승인된 Gitea Issue를 구현·검증하고 Gitea PR로 전달한다. -version: 0.4.7 +version: 0.4.8 author: genichin, Hermes Agent license: MIT platforms: [linux] @@ -16,6 +16,10 @@ metadata: 승인된 Gitea Discovery와 pinned repository revision을 근거로 Issue별 persistent worktree에서 요구 문서, source와 test를 구현하고 검증한 뒤 Issue 전용 branch와 Gitea PR로 전달한다. Gitea 인증과 readback은 [Gitea authenticated access 운영 지침](../../../../operations/gitea-access.md), repository cache와 worktree 규칙은 [Repository cache 운영 지침](../../../../operations/repository-cache.md), 문서 형식은 `docs/` 아래 각 문서 유형의 README와 TEMPLATE만 정본으로 사용한다. Exact Issue 구현 요청은 승인된 범위의 commit, 일반 push와 PR 생성을 승인하며, 열린 PR의 authoritative readback까지가 이 스킬의 기본 완료 경계다. PR 전달을 별도 승인 단계로 미루지 않는다. 다만 merge, force-push, Issue close, release 또는 deployment는 승인하지 않는다. +## Test inventory source-binding gate + +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 권한이나 자동 제품 변경 요청을 추가하지 않는다. + ## 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` 경로만 사용한다. 경로는 `/runtime//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는 금지한다. diff --git a/harness/agents/impl/skills/gitea-issue-rework/SKILL.md b/harness/agents/impl/skills/gitea-issue-rework/SKILL.md index e785f60..3ea00e8 100644 --- a/harness/agents/impl/skills/gitea-issue-rework/SKILL.md +++ b/harness/agents/impl/skills/gitea-issue-rework/SKILL.md @@ -1,7 +1,7 @@ --- name: gitea-issue-rework description: Gitea PR 검토 지적을 계약에 맞춰 재작업하고 검증한다. -version: 0.2.13 +version: 0.2.14 author: genichin, Hermes Agent license: MIT platforms: [linux] @@ -18,6 +18,10 @@ metadata: Gitea 인증과 authoritative readback은 [Gitea authenticated access 운영 지침](../../../../operations/gitea-access.md), cache·lock·worktree 규칙은 [Repository cache 운영 지침](../../../../operations/repository-cache.md), 문서 형식은 `docs/` 아래 각 문서 유형의 README와 TEMPLATE만 정본으로 사용한다. +## Test inventory source-binding gate + +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 권한이나 자동 제품 변경 요청을 추가하지 않는다. + ## 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` 경로만 사용한다. 경로는 `/runtime//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는 금지한다. diff --git a/harness/agents/review/skills/gitea-code-review/SKILL.md b/harness/agents/review/skills/gitea-code-review/SKILL.md index 670462c..9eed85d 100644 --- a/harness/agents/review/skills/gitea-code-review/SKILL.md +++ b/harness/agents/review/skills/gitea-code-review/SKILL.md @@ -1,7 +1,7 @@ --- name: gitea-code-review description: Spec pass된 Gitea PR의 코드 품질과 안전성을 검토한다. -version: 0.1.2 +version: 0.1.3 author: genichin, Hermes Agent license: MIT platforms: [linux] @@ -16,6 +16,10 @@ metadata: `구현 → spec pass → code review`의 독립 2차 리뷰를 수행한다. 승인된 요구사항을 바꾸거나 직접 구현하지 않고, 코드 결함과 검증 부족을 근거로 판정한다. +## Test inventory source-binding gate + +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 권한이나 자동 제품 변경 요청을 추가하지 않는다. + ## 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은 `/runtime//`이며 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 경로와 별개이며 이 예외를 유지한다. diff --git a/harness/agents/spec/skills/gitea-implementation-spec-review/SKILL.md b/harness/agents/spec/skills/gitea-implementation-spec-review/SKILL.md index c4be36c..e0eea2d 100644 --- a/harness/agents/spec/skills/gitea-implementation-spec-review/SKILL.md +++ b/harness/agents/spec/skills/gitea-implementation-spec-review/SKILL.md @@ -1,7 +1,7 @@ --- name: gitea-implementation-spec-review description: 승인된 계약에 대해 Gitea 구현 PR 적합성을 검토한다. -version: 0.2.4 +version: 0.2.5 author: genichin, Hermes Agent license: MIT platforms: [linux] @@ -16,6 +16,10 @@ metadata: `gitea-impl-agent`가 만든 Gitea PR을 승인된 Discovery 계약과 대조해 요구사항 충족, 범위 누락·초과, acceptance criteria와 test evidence의 대응을 판정한다. 검토 결과는 구현 branch의 Git commit이나 Issue comment가 아니라 검토한 immutable PR head commit에 바인딩된 native Gitea review로 기록한다. Gitea 인증과 readback은 [Gitea authenticated access 운영 지침](../../../../operations/gitea-access.md)을 정본으로 사용한다. 일반 코드 품질·보안 검토, source 수정, Discovery 변경과 merge는 수행하지 않는다. +## Test inventory source-binding gate + +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 권한이나 자동 제품 변경 요청을 추가하지 않는다. + ## 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은 `/runtime//`이며 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는 금지한다. diff --git a/harness/lib/test_inventory_binding.py b/harness/lib/test_inventory_binding.py new file mode 100644 index 0000000..b5c345d --- /dev/null +++ b/harness/lib/test_inventory_binding.py @@ -0,0 +1,339 @@ +"""Read-only binding preflight for recorded test inventories.""" +import argparse +import hashlib +import json +import os +import re +import selectors +import signal +import stat +import subprocess +import time +import sys +from typing import NoReturn + + +class BindingError(ValueError): + pass + + +_HEX40 = re.compile(r"^[0-9a-f]{40}$") +_HEX64 = re.compile(r"^[0-9a-f]{64}$") +_SCHEMA = "gitea-agent.test-inventory-binding/v1" +_MAX_JSON = 1024 * 1024 +_MAX_OUTPUT = 64 * 1024 * 1024 +_GIT_EXECUTABLE = "/usr/bin/git" + + +def _reject(message="rejected inventory binding input") -> NoReturn: + raise BindingError(message) + + +def _keys(value, expected): + if not isinstance(value, dict) or set(value) != set(expected): + _reject() + + +def _nonempty(value): + return isinstance(value, str) and bool(value.strip()) and "\x00" not in value + + +def _sha(value, pattern): + return isinstance(value, str) and pattern.fullmatch(value) is not None + + +def _safe_path(value, allow_empty=False): + if not isinstance(value, str) or (not value and not allow_empty): + _reject() + if value.startswith("/") or "\\" in value or "\x00" in value: + _reject() + parts = value.split("/") + if any(part in ("", ".", "..") for part in parts): + _reject() + return value + + +def _safe_paths(values): + if not isinstance(values, list) or len(set(values)) != len(values): + _reject() + return [_safe_path(v) for v in values] + + +def _directory_fd(raw): + if not isinstance(raw, str) or not os.path.isabs(raw) or raw != os.path.normpath(raw): + _reject() + if raw != "/" and any(p in ("", ".", "..") for p in raw.split("/")[1:]): + _reject() + fd = os.open("/", os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + for part in raw.split("/")[1:]: + if not part: + continue + nxt = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd) + os.close(fd) + fd = nxt + st = os.fstat(fd) + if not stat.S_ISDIR(st.st_mode): + _reject() + except (BindingError, OSError, ValueError): + os.close(fd) + _reject() + return fd + + +def _canonical_git_dir(raw): + return _directory_fd(raw) + + +def _env(): + return { + "PATH": os.environ.get("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(git_dir, args, check=False, git_fd=None): + command = [_GIT_EXECUTABLE, "--git-dir", git_dir, *args] + process = None + try: + deadline = time.monotonic() + 5 + process = subprocess.Popen( + command, env=_env(), stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + start_new_session=True, + pass_fds=() if git_fd is None else (git_fd,), + ) + if process.stdout is None: + raise OSError("Git output pipe unavailable") + output = bytearray() + with selectors.DefaultSelector() as selector: + selector.register(process.stdout, selectors.EVENT_READ) + while True: + remaining = deadline - time.monotonic() + if remaining <= 0 or not selector.select(remaining): + raise subprocess.TimeoutExpired(command, 5) + chunk = os.read(process.stdout.fileno(), min(65536, _MAX_OUTPUT - len(output) + 1)) + if not chunk: + break + output.extend(chunk) + if len(output) > _MAX_OUTPUT: + raise subprocess.SubprocessError("bounded Git output exceeded") + returncode = process.wait(timeout=max(0.001, deadline - time.monotonic())) + result = subprocess.CompletedProcess(command, returncode, bytes(output)) + except (OSError, subprocess.SubprocessError): + if check: + _reject() + return None + finally: + if process is not None: + if process.returncode is None: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait() + if process.stdout is not None: + process.stdout.close() + if check and result.returncode != 0: + _reject() + return result + + +def _json_file(path): + if not isinstance(path, str) or not os.path.isabs(path) or path != os.path.normpath(path): + _reject() + try: + parent = _directory_fd(os.path.dirname(path)) + try: + fd = os.open(os.path.basename(path), os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent) + finally: + os.close(parent) + try: + st = os.fstat(fd) + if not stat.S_ISREG(st.st_mode): + _reject() + raw = os.read(fd, _MAX_JSON + 1) + finally: + os.close(fd) + except BindingError: + raise + except (OSError, ValueError): + _reject() + if len(raw) > _MAX_JSON: + _reject() + + def pairs(items): + out = {} + for key, value in items: + if key in out: + _reject() + out[key] = value + return out + + try: + return json.loads(raw.decode("utf-8"), object_pairs_hook=pairs, + parse_constant=lambda _: _reject()) + except BindingError: + raise + except (UnicodeError, ValueError, TypeError, RecursionError): + _reject() + + +def _parse(data): + _keys(data, ("schema", "source_revision", "stage", "ids", "file_sha256", "collection")) + if data["schema"] != _SCHEMA or not _sha(data["source_revision"], _HEX40) or not _nonempty(data["stage"]): + _reject() + ids = data["ids"] + if not isinstance(ids, list) or len(set(ids)) != len(ids): + _reject() + id_paths = [] + for item in ids: + if not _nonempty(item) or "::" not in item: + _reject() + path, symbol = item.split("::", 1) + _safe_path(path) + if not _nonempty(symbol): + _reject() + id_paths.append(path) + files = data["file_sha256"] + if not isinstance(files, dict) or set(files) != set(id_paths): + _reject() + for path, digest in files.items(): + _safe_path(path) + if not _sha(digest, _HEX64): + _reject() + c = data["collection"] + _keys(c, ("source_revision", "stage", "argv", "scope_paths", "scope_sha256", "evidence")) + if c["source_revision"] != data["source_revision"] or c["stage"] != data["stage"]: + _reject() + if not isinstance(c["argv"], list) or not c["argv"] or any(not _nonempty(x) for x in c["argv"]): + _reject() + # This is recorded argv, not a shell command; it is never executed here. + scope = _safe_paths(c["scope_paths"]) + if not _sha(c["scope_sha256"], _HEX64) or not _nonempty(c["evidence"]): + _reject() + if not set(id_paths).issubset(scope): + _reject() + return data, ids, id_paths, files, c, scope + + +def _tree(git_dir, revision, paths, git_fd=None): + if not paths: + return {} + p = _git(git_dir, ["ls-tree", "-rz", "-r", "--full-tree", revision, "--", *paths], git_fd=git_fd) + if p is None or p.returncode != 0: + return None + found = {} + for record in p.stdout.split(b"\0"): + if not record: + continue + try: + header, pathb = record.split(b"\t", 1) + mode, kind, obj = header.decode("ascii").split(" ") + path = pathb.decode("utf-8") + except (ValueError, UnicodeError): + _reject() + if path in paths: + found[path] = (mode, kind, obj) + return found + + +def _blob(git_dir, revision, path, git_fd=None): + p = _git(git_dir, ["show", f"{revision}:{path}"], git_fd=git_fd) + if p is None or p.returncode != 0: + return None + return p.stdout + + +def _result(status, reasons, counts): + return {"status": status, "reason_codes": sorted(set(reasons)), "counts": counts} + + +def verify_inventory(git_dir, data, expected_revision, expected_stage): + git_fd = _canonical_git_dir(git_dir) + try: + if not _sha(expected_revision, _HEX40) or not _nonempty(expected_stage): + _reject() + data, ids, id_paths, files, collection, scope = _parse(data) + counts = {"ids": len(ids), "files": len(files), "scope_paths": len(scope)} + reasons = [] + if data["source_revision"] != expected_revision: + reasons.append("source_revision_mismatch") + if data["stage"] != expected_stage: + reasons.append("stage_mismatch") + if reasons: + return _result("block", reasons, counts) + + # Validate repository kind without consulting a work tree or refs. + pinned_git_dir = f"/proc/self/fd/{git_fd}" + bare = _git(pinned_git_dir, ["rev-parse", "--is-bare-repository"], git_fd=git_fd) + if bare is None or bare.returncode != 0 or bare.stdout.strip() not in (b"true", b"false"): + return _result("block", ["git_directory_unavailable"], counts) + commit = _git(pinned_git_dir, ["cat-file", "-t", expected_revision], git_fd=git_fd) + if commit is None or commit.returncode != 0 or commit.stdout.strip() != b"commit": + return _result("block", ["revision_not_found"], counts) + tree = _tree(pinned_git_dir, expected_revision, sorted(set(scope)), git_fd=git_fd) + if tree is None: + return _result("block", ["scope_unavailable"], counts) + digest_map = {} + for path in scope: + entry = tree.get(path) + if entry is None: + reasons.append("scope_file_missing") + continue + mode, kind, obj = entry + if mode == "120000" or kind != "blob" or mode not in ("100644", "100755"): + reasons.append("scope_file_not_regular") + continue + raw = _blob(pinned_git_dir, expected_revision, path, git_fd=git_fd) + if raw is None: + reasons.append("scope_blob_unavailable") + continue + git_blob = hashlib.sha1(b"blob " + str(len(raw)).encode() + bytes([0]) + raw).hexdigest() + if git_blob != obj: + reasons.append("scope_blob_identity_mismatch") + digest_map[path] = hashlib.sha256(raw).hexdigest() + if path in files and digest_map[path] != files[path]: + reasons.append("recorded_file_hash_mismatch") + digest = hashlib.sha256(json.dumps(digest_map, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + if digest != collection["scope_sha256"]: + reasons.append("scope_digest_mismatch") + return _result("block" if reasons else "pass", reasons, counts) + except BindingError: + raise + except (OSError, TypeError, ValueError, KeyError, RecursionError): + _reject() + finally: + os.close(git_fd) + + +class _Parser(argparse.ArgumentParser): + def error(self, message) -> NoReturn: + # argparse normally echoes rejected argument values to stderr. + _reject() + + +def main(argv=None): + parser = _Parser(add_help=True, allow_abbrev=False) + parser.add_argument("--git-dir", required=True) + parser.add_argument("--input", required=True) + parser.add_argument("--expected-revision", required=True) + parser.add_argument("--expected-stage", required=True) + try: + args = parser.parse_args(argv) + result = verify_inventory(args.git_dir, _json_file(args.input), args.expected_revision, args.expected_stage) + print(json.dumps(result, sort_keys=True, separators=(",", ":"))) + return 0 if result["status"] == "pass" else 1 + except (BindingError, OSError, ValueError, TypeError, KeyError, RecursionError): + print(json.dumps({"status": "block", "reason_codes": ["rejected_input"], "counts": {}}, separators=(",", ":"))) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/operations/repository-cache.md b/harness/operations/repository-cache.md index 808fb41..4457032 100644 --- a/harness/operations/repository-cache.md +++ b/harness/operations/repository-cache.md @@ -343,6 +343,34 @@ python3 /harness/lib/workflow_guard.py check-contracts --input JSO Input schema와 엄격한 type/key 규칙은 `harness/lib/workflow_guard.py`의 API 계약을 따른다. `paths-json`은 중복 없는 기존 repository-relative protected 파일 array다. `check-tests`는 `baseline_ids/current_ids`, `baseline_status/current_status`, source·collection argv·inventory digest·evidence를 가진 `baseline_collection/current_collection`, `authorized_changes`, `changed_tests`, `findings`를 연결한다. `check-contracts`는 `baseline/current/mappings`와 `stages/precedence`를 연결한다. 파일별 의미를 임의 추론하지 말고 helper의 실제 schema와 기록을 검증한다. 이 문서의 정적 contract test 통과는 agent가 실행 중 절차를 준수했다는 증명이 아니다. +### Test inventory Git source-binding preflight + +Test inventory를 생성·전달하거나 채택하는 impl/spec/review main은 `check-tests` 이전에 `harness/lib/test_inventory_binding.py`로 collection의 Git source binding을 확인한다. 구조 guard는 그대로 유지한다. Expected revision과 stage는 inventory 자체에서 복사하지 않고 workflow가 독립적으로 고정한 baseline/current source와 collection 단계에서 가져온다. 사용이 이미 승인된 bare mirror 또는 `.git` directory만 사용하며 helper가 cache admission·lock·authenticated Git provenance를 대신하지 않는다. + +Helper는 Linux의 신뢰된 `/usr/bin/git`와 `/proc/self/fd/`를 사용한다. PATH에서 Git을 탐색하지 않으며, admitted Git directory FD를 검증 전체와 모든 Git child에서 유지해 경로 치환이 읽기 대상을 바꾸지 않게 한다. 이 실행 환경이 없으면 fallback이나 repair 없이 차단한다. + +```sh +python3 /harness/lib/test_inventory_binding.py \ + --git-dir ABS_GIT_DIRECTORY --input BINDING_INPUT \ + --expected-revision FULL_SOURCE_SHA --expected-stage COLLECTION_STAGE +python3 /harness/lib/workflow_guard.py check-tests --input GUARD_INPUT +``` + +**Input 계약:** 기존 inventory/manifest/collection 기록의 검증된 필드를 다음 bounded JSON input으로 연결한다. 이 adapter input은 제품의 새 문서·증빙 commit 의무나 기존 v2 evidence-manifest schema 변경이 아니다. 정본 verification run 또는 허용된 reviewer workflow artifact에 보존한다. Main은 binding input의 `ids`와 `source_revision`이 뒤의 `check-tests` baseline/current collection에 exact-match하는지도 확인한다. 입력 값의 자동 추정·재수집·권한 repair는 하지 않는다. + +- Exact top-level keys: `schema`, `source_revision`, `stage`, `ids`, `file_sha256`, `collection`. +- `schema`: exact `gitea-agent.test-inventory-binding/v1`. +- `source_revision`: lowercase 40자리 commit SHA. `stage`: 기존 collection 단계의 nonempty string. Baseline/current 명칭을 새로 강제하지 않고 independently expected stage와 대조한다. +- `ids`: 중복 없는 `repository-relative-path::symbol` test ID array. Parameterized ID의 suffix는 보존한다. 실제 empty collection이면 빈 array를 허용하며 완전성은 main이 확인한다. +- `file_sha256`: 모든 ID에서 첫 `::` 앞의 파일 경로를 추출한 exact unique path set에 대응하는 lowercase SHA-256 map. 없는 파일·추가 파일·Git symlink/gitlink/tree를 일반 test blob으로 채택하지 않는다. +- `collection`: exact keys `source_revision`, `stage`, `argv`, `scope_paths`, `scope_sha256`, `evidence`. Revision·stage는 top-level 및 independently expected source와 일치해야 한다. `argv`는 실제 collection 기록의 nonempty string array, `evidence`는 nonempty 실행 근거 참조다. +- `scope_paths`: 안전한 unique repository-relative path array이며 모든 test ID 파일을 포함한다. Collector 설정·fixture·dependency 등 실제 수집에 영향을 주는 입력도 main이 기록의 출처와 함께 검토한다. +- `scope_sha256`: exact commit의 각 scope regular blob에 대해 `SHA256(raw_bytes)`를 계산한 `{path: digest}` map을 `json.dumps(map, sort_keys=True, separators=(',', ':'), ensure_ascii=True)`의 UTF-8 bytes로 직렬화한 SHA-256이다. Git object ID와 파일 SHA-256을 혼동하지 않는다. + +**판정 및 한계:** CLI exit 0은 source binding PASS, 1은 revision/stage/file/hash/scope의 불일치 BLOCK, 2는 malformed input·안전하지 않은 경로 등 입력 거부다. Helper는 bounded JSON과 고정된 error/reason code만 반환하고 입력 본문·Git stderr·credential을 출력하지 않는다. Duplicate/unknown JSON keys, unsafe path, malformed SHA와 비정규 input은 거부한다. Git tree/blob만 읽고 checkout·fetch·test 실행·file/ref write를 하지 않는다. Dirty worktree 대신 pinned commit의 blob을 비교한다. + +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`로 전환하지 않는다. + #### 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. diff --git a/harness/tests/common/test_inventory_binding_contract_docs.py b/harness/tests/common/test_inventory_binding_contract_docs.py new file mode 100644 index 0000000..d1eddb6 --- /dev/null +++ b/harness/tests/common/test_inventory_binding_contract_docs.py @@ -0,0 +1,40 @@ +"""Contract wiring for the read-only inventory source-binding preflight.""" +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +class InventoryBindingDocumentationTests(unittest.TestCase): + def test_all_consumer_roles_require_binding_before_check_tests(self): + paths = [ + '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', + ] + for path in paths: + with self.subTest(path=path): + text = (ROOT / path).read_text() + self.assertIn('test_inventory_binding.py', text) + self.assertIn('check-tests 이전', text) + self.assertIn('독립적으로 고정한 revision과 stage', text) + + def test_canonical_reference_defines_exact_cli_and_scope_digest(self): + text = (ROOT / 'operations/repository-cache.md').read_text() + self.assertIn('### Test inventory Git source-binding preflight', text) + for value in ['--git-dir', '--expected-revision', '--expected-stage', + 'gitea-agent.test-inventory-binding/v1', 'file_sha256', + 'scope_paths', 'scope_sha256', 'ensure_ascii=True', + '/usr/bin/git', '/proc/self/fd/']: + self.assertIn(value, text) + + def test_binding_limits_do_not_create_product_or_execution_requirements(self): + text = (ROOT / 'operations/repository-cache.md').read_text() + self.assertIn('named symbol·parameter 존재나 실제 collection 실행을 인증하지 않는다', text) + self.assertIn('현재 collection을 baseline으로 재명명하지 않는다', text) + self.assertIn('별도 checkout이나 전체 테스트 재실행을 요구하지 않는다', text) + + +if __name__ == '__main__': + unittest.main() diff --git a/harness/tests/common/test_test_inventory_binding.py b/harness/tests/common/test_test_inventory_binding.py new file mode 100644 index 0000000..adac9ed --- /dev/null +++ b/harness/tests/common/test_test_inventory_binding.py @@ -0,0 +1,286 @@ +"""Real-Git regressions; no pytest or product execution dependency.""" +import copy +import hashlib +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from harness.lib import test_inventory_binding as binding + +ROOT = Path(__file__).resolve().parents[3] + + +class InventoryBindingTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.work = self.root / 'work' + self.work.mkdir() + self.git('init', '-q') + self.git('config', 'user.email', 'fixture@example.invalid') + self.git('config', 'user.name', 'Fixture') + (self.work / 'tests').mkdir() + (self.work / 'tests/sample.py').write_text('def test_sample():\n assert True\n') + (self.work / 'config.ini').write_text('[test]\nvalue=1\n') + self.git('add', '.') + self.git('commit', '-qm', 'baseline') + self.rev = self.git('rev-parse', 'HEAD').strip() + self.gitdir = self.work / '.git' + self.data = self.make_data(self.rev) + + def git(self, *args): + return subprocess.check_output(['git', '-C', str(self.work), *args], text=True, stderr=subprocess.DEVNULL) + + def make_data(self, revision): + hashes = {p: hashlib.sha256((self.work / p).read_bytes()).hexdigest() + for p in ['config.ini', 'tests/sample.py']} + return {'schema': 'gitea-agent.test-inventory-binding/v1', + 'source_revision': revision, 'stage': 'baseline', + 'ids': ['tests/sample.py::test_sample'], + 'file_sha256': {'tests/sample.py': hashes['tests/sample.py']}, + 'collection': {'source_revision': revision, 'stage': 'baseline', + 'argv': ['python3', '-m', 'pytest', '--collect-only'], + 'scope_paths': list(hashes), 'scope_sha256': self.scope_digest(hashes), + 'evidence': 'retained-collection-record'}} + + @staticmethod + def scope_digest(hashes): + return hashlib.sha256(json.dumps(hashes, sort_keys=True, separators=(',', ':')).encode()).hexdigest() + + def verify(self, data=None, revision=None, stage='baseline', gitdir=None): + return binding.verify_inventory(str(gitdir or self.gitdir), self.data if data is None else data, + self.rev if revision is None else revision, stage) + + def cli(self, raw=None, extra=(), input_path=None): + inp = self.root / 'input.json' + if input_path is None: + inp.write_text(json.dumps(self.data) if raw is None else raw) + else: + inp = input_path + return subprocess.run([sys.executable, '-B', str(ROOT / 'harness/lib/test_inventory_binding.py'), + '--git-dir', str(self.gitdir), '--input', str(inp), + '--expected-revision', self.rev, '--expected-stage', 'baseline', *extra], + capture_output=True, text=True, timeout=10) + + def test_valid_normal_and_bare_without_state_or_ref_changes(self): + before = (self.git('status', '--porcelain'), self.git('show-ref')) + (self.work / 'tests/sample.py').write_text('uncommitted edit') + dirty = self.git('status', '--porcelain') + self.assertEqual(self.verify()['status'], 'pass') + bare = self.root / 'mirror.git' + subprocess.run(['git', 'clone', '-q', '--bare', str(self.work), str(bare)], check=True) + self.assertEqual(self.verify(gitdir=bare)['status'], 'pass') + self.assertEqual(self.git('status', '--porcelain'), dirty) + self.assertEqual(self.git('show-ref'), before[1]) + + def test_expected_revision_and_stage_cannot_be_self_attested(self): + self.assertEqual(self.verify(revision='0' * 40)['reason_codes'], ['source_revision_mismatch']) + self.assertEqual(self.verify(stage='current')['reason_codes'], ['stage_mismatch']) + newer = copy.deepcopy(self.data) + newer['stage'] = newer['collection']['stage'] = 'current' + self.assertEqual(self.verify(newer)['status'], 'block') + + def test_tag_object_is_not_an_exact_commit_revision(self): + self.git('tag', '-a', '-m', 'fixture tag', 'fixture-tag') + tag = self.git('rev-parse', 'fixture-tag').strip() + changed = copy.deepcopy(self.data) + changed['source_revision'] = changed['collection']['source_revision'] = tag + self.assertEqual(self.verify(changed, revision=tag)['status'], 'block') + + def test_missing_commit_and_malformed_sha(self): + data = copy.deepcopy(self.data) + data['source_revision'] = data['collection']['source_revision'] = '0' * 40 + self.assertEqual(self.verify(data, revision='0' * 40)['reason_codes'], ['revision_not_found']) + for rev in [self.rev.upper(), 'HEAD', self.rev + ' ', True]: + with self.subTest(rev=rev), self.assertRaises(binding.BindingError): + self.verify(revision=rev) + + def test_absent_file_and_tampered_recorded_hash_are_blocked(self): + changed = copy.deepcopy(self.data) + changed['file_sha256']['tests/sample.py'] = '0' * 64 + self.assertIn('recorded_file_hash_mismatch', self.verify(changed)['reason_codes']) + missing = copy.deepcopy(self.data) + missing['ids'] = ['tests/absent.py::test_absent'] + missing['file_sha256'] = {'tests/absent.py': '0' * 64} + missing['collection']['scope_paths'] = ['tests/absent.py'] + self.assertIn('scope_file_missing', self.verify(missing)['reason_codes']) + + def test_changed_collector_configuration_breaks_scope_digest(self): + (self.work / 'config.ini').write_text('[test]\nvalue=2\n') + self.git('add', 'config.ini') + self.git('commit', '-qm', 'change collection config') + revision = self.git('rev-parse', 'HEAD').strip() + changed = copy.deepcopy(self.data) + changed['source_revision'] = changed['collection']['source_revision'] = revision + self.assertIn('scope_digest_mismatch', self.verify(changed, revision=revision)['reason_codes']) + + def test_nested_source_or_stage_and_omitted_scope_are_rejected(self): + for key, value in [('source_revision', '0' * 40), ('stage', 'current'), ('scope_paths', ['config.ini'])]: + changed = copy.deepcopy(self.data) + changed['collection'][key] = value + with self.subTest(key=key), self.assertRaises(binding.BindingError): + self.verify(changed) + + def test_duplicates_unsafe_paths_extra_hash_and_wrong_types(self): + variants = [] + for value in [self.data['ids'] * 2, [{}], True, ['../outside::case'], ['/outside::case'], ['tests//sample.py::case']]: + changed = copy.deepcopy(self.data); changed['ids'] = value; variants.append(changed) + changed = copy.deepcopy(self.data); changed['extra'] = True; variants.append(changed) + changed = copy.deepcopy(self.data); changed['file_sha256']['extra.py'] = '0' * 64; variants.append(changed) + changed = copy.deepcopy(self.data); changed['collection']['scope_paths'] = [{}]; variants.append(changed) + for changed in variants: + with self.subTest(data_type=type(changed['ids']).__name__), self.assertRaises(binding.BindingError): + self.verify(changed) + + def test_empty_collection_is_valid_but_not_a_collection_execution_claim(self): + changed = copy.deepcopy(self.data) + changed['ids'] = []; changed['file_sha256'] = {} + self.assertEqual(self.verify(changed)['status'], 'pass') + changed['collection']['scope_paths'] = [] + changed['collection']['scope_sha256'] = self.scope_digest({}) + self.assertEqual(self.verify(changed)['status'], 'pass') + + def test_symlink_tree_and_gitlink_are_not_regular_blobs(self): + (self.work / 'tests/link.py').symlink_to('sample.py') + self.git('add', 'tests/link.py'); self.git('commit', '-qm', 'add symlink') + self.git('update-index', '--add', '--cacheinfo', f'160000,{self.rev},tests/submodule') + self.git('commit', '-qm', 'add gitlink') + revision = self.git('rev-parse', 'HEAD').strip() + for path in ['tests/link.py', 'tests/submodule', 'tests']: + changed = copy.deepcopy(self.data) + changed['source_revision'] = changed['collection']['source_revision'] = revision + changed['ids'] = [path + '::case']; changed['file_sha256'] = {path: '0' * 64} + changed['collection']['scope_paths'] = [path] + with self.subTest(path=path): + self.assertEqual(self.verify(changed, revision=revision)['status'], 'block') + + def test_symlink_git_directory_ancestor_and_lexical_alias_rejected(self): + alias = self.root / 'alias'; alias.symlink_to(self.work, target_is_directory=True) + for path in [str(alias / '.git'), str(self.gitdir) + '/', str(self.work / 'x') + '/../.git']: + with self.subTest(path=path), self.assertRaises(binding.BindingError): + binding.verify_inventory(path, self.data, self.rev, 'baseline') + + def test_ambient_git_redirects_and_replacements_do_not_change_binding(self): + self.git('replace', self.rev, self.rev) + with patch.dict(os.environ, {'GIT_DIR': '/nonexistent', 'GIT_WORK_TREE': '/nonexistent', + 'GIT_CONFIG_COUNT': '1', 'GIT_CONFIG_KEY_0': 'core.bare', + 'GIT_CONFIG_VALUE_0': 'true'}): + self.assertEqual(self.verify()['status'], 'pass') + + def test_json_symlink_ancestor_and_lexical_alias_rejected(self): + real = self.root / 'records' + real.mkdir() + record = real / 'record.json' + record.write_text(json.dumps(self.data)) + alias = self.root / 'record-alias' + alias.symlink_to(real, target_is_directory=True) + for path in [str(alias / 'record.json'), str(real) + '/./record.json']: + with self.subTest(path=path), self.assertRaises(binding.BindingError): + binding._json_file(path) + + def test_lazy_fetch_disabled_in_read_only_git_environment(self): + self.assertEqual(binding._env().get('GIT_NO_LAZY_FETCH'), '1') + + def test_json_duplicate_keys_symlink_fifo_and_oversize_rejected(self): + duplicate = json.dumps(self.data)[:-1] + ',"schema":"secret-marker"}' + for raw in [duplicate, '{"secret":"secret-marker"}', '[' * 2000, ' ' * (1024 * 1024 + 1)]: + result = self.cli(raw) + self.assertEqual(result.returncode, 2) + self.assertNotIn('secret-marker', result.stdout + result.stderr) + real = self.root / 'real.json'; real.write_text(json.dumps(self.data)) + alias = self.root / 'alias.json'; alias.symlink_to(real) + self.assertEqual(self.cli(input_path=alias).returncode, 2) + fifo = self.root / 'fifo'; os.mkfifo(fifo) + self.assertEqual(self.cli(input_path=fifo).returncode, 2) + + def test_cli_pass_block_and_rejected_input_are_json(self): + self.assertEqual(self.cli().returncode, 0) + changed = copy.deepcopy(self.data); changed['stage'] = changed['collection']['stage'] = 'current' + result = self.cli(json.dumps(changed)) + self.assertEqual(result.returncode, 1) + self.assertEqual(json.loads(result.stdout)['status'], 'block') + result = self.cli('{"secret":"secret-marker"}') + self.assertEqual(result.returncode, 2) + self.assertNotIn('secret-marker', result.stdout + result.stderr) + + def test_recorded_argv_is_not_a_shell_command_or_character_policy(self): + changed = copy.deepcopy(self.data) + changed['collection']['argv'] += ['-k', 'case[param$with|literal;chars]'] + try: + result = self.verify(changed) + except binding.BindingError: + self.fail('literal argv must not be rejected as a shell command') + self.assertEqual(result['status'], 'pass') + + def test_cli_unknown_option_never_echoes_input(self): + result = self.cli(extra=('--unknown=secret-marker',)) + self.assertEqual(result.returncode, 2) + self.assertNotIn('secret-marker', result.stdout + result.stderr) + self.assertTrue(result.stdout.startswith('{'), 'CLI rejection must be bounded JSON') + self.assertEqual(json.loads(result.stdout)['reason_codes'], ['rejected_input']) + + def test_git_output_limit_terminates_before_wall_clock_timeout(self): + import time + fake = self.root / 'bin' + fake.mkdir() + program = fake / 'git' + program.write_text('#!' + sys.executable + '\nimport os,time\nos.write(1,b"x"*1024)\ntime.sleep(10)\n') + program.chmod(0o700) + started = time.monotonic() + with patch.object(binding, '_GIT_EXECUTABLE', str(program)), patch.object(binding, '_MAX_OUTPUT', 32): + self.assertIsNone(binding._git(str(self.gitdir), ['fixture-read'])) + self.assertLess(time.monotonic() - started, 2.0) + + def test_hostile_path_git_is_never_executed(self): + marker = self.root / 'hostile-marker' + fake = self.root / 'bin' + fake.mkdir() + program = fake / 'git' + program.write_text('#!' + sys.executable + '\nimport pathlib\npathlib.Path(' + repr(str(marker)) + ').write_text("executed")\n') + program.chmod(0o700) + with patch.dict(os.environ, {'PATH': str(fake)}): + self.assertEqual(self.verify()['status'], 'pass') + self.assertFalse(marker.exists()) + + def test_admitted_git_directory_remains_pinned_after_path_replacement(self): + original = binding._git + replacement_work = self.root / 'replacement-work' + replacement_work.mkdir() + subprocess.run(['git', '-C', str(replacement_work), 'init', '-q'], check=True) + subprocess.run(['git', '-C', str(replacement_work), 'config', 'user.email', 'replacement@example.invalid'], check=True) + subprocess.run(['git', '-C', str(replacement_work), 'config', 'user.name', 'Replacement'], check=True) + (replacement_work / 'replacement.txt').write_text('replacement') + subprocess.run(['git', '-C', str(replacement_work), 'add', '.'], check=True) + subprocess.run(['git', '-C', str(replacement_work), 'commit', '-qm', 'replacement'], check=True) + + swapped = False + + def replace_after_admission(git_dir, args, **kwargs): + nonlocal swapped + result = original(git_dir, args, **kwargs) + if not swapped and args[:2] == ['rev-parse', '--is-bare-repository']: + swapped = True + moved = self.root / 'admitted-original.git' + os.rename(self.gitdir, moved) + os.rename(replacement_work / '.git', self.gitdir) + return result + + with patch.object(binding, '_git', side_effect=replace_after_admission): + self.assertEqual(self.verify()['status'], 'pass') + self.assertTrue(swapped) + + def test_each_scope_blob_is_read_once(self): + original = binding._blob + with patch.object(binding, '_blob', wraps=original) as read: + self.assertEqual(self.verify()['status'], 'pass') + self.assertEqual(read.call_count, 2) + + +if __name__ == '__main__': + unittest.main()