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 e9fae8a..527c054 100644 --- a/harness/agents/spec/skills/gitea-implementation-spec-review/SKILL.md +++ b/harness/agents/spec/skills/gitea-implementation-spec-review/SKILL.md @@ -187,6 +187,25 @@ Canonical body를 렌더링하기 전에 trusted workflow directory에 JSON obje - Verdict와 native state 조합은 `approve/APPROVED`, `request_changes/REQUEST_CHANGES`, `blocked_spec/COMMENT`만 허용한다. - Initial version에서는 inline review comment를 만들지 않고 `comments: []`를 명시한다. +## 반복 기계 검증 helper + +반복 실행의 기계 검증은 [scripts/review_support.py](scripts/review_support.py)를 사용한다. Python 표준 라이브러리만 필요하며 다음 호출은 ``에서 `terminal`로 실행한다. + +```python +terminal(command="python3 harness/agents/spec/skills/gitea-implementation-spec-review/scripts/review_support.py --help") +terminal(command="python3 harness/agents/spec/skills/gitea-implementation-spec-review/scripts/review_support.py validate-result --result /result.json --allowed-paths /allowed-paths.json") +terminal(command="python3 harness/agents/spec/skills/gitea-implementation-spec-review/scripts/review_support.py render --result /result.json --allowed-paths /allowed-paths.json") +``` + +- ``은 `/harness/.runtime/implementation-spec-review/`의 trusted workflow directory다. `allowed-paths.json`은 main이 authoritative changed-file set과 pinned base/head의 실제 경로에서 만든 JSON string array이며 결과 artifact가 스스로 선언한 allowlist를 신뢰하지 않는다. +- `validate-result`, `render`, `parse`는 기존 strict schema, canonical body, digest·correlation과 round-trip을 검증한다. `parse`에는 `--body`와 `--allowed-paths`를 전달한다. 구분자·markup 때문에 모호한 list 항목만 JSON string quoting/escaping으로 원래 값을 보존하며 구분자·markup이 없는 기존 항목의 본문 형식은 유지한다. 출력은 stdout으로 반환하므로 기존 artifact 보존 규칙에 따라 main이 저장한다. +- `check-cache --contract-path <읽은 repository-cache.md> --owner --repository --issue `는 기존 canonical cache의 경로별 permission·symlink만 검사한다. Cache 생성·repair·lock·Git fetch와 bare/remote/ref 확인은 기존 절차에 남는다. +- `collect_pages`는 1부터 시작하는 page 번호로 main의 read-only callback을 호출하고 반환된 `items`, `total_count`, `has_more`를 검증한다. API adapter는 provider의 실제 metadata와 확인된 effective page size를 이 envelope와 `page_size`에 대응하고 null collection은 count와 종료 근거로 empty임이 증명될 때만 전달한다. 저장된 page envelope 배열은 `check-pages --pages --key [--page-size ] [--max-pages ]`로 검증한다. +- `verify-publication --before --after --exact-review --result --allowed-paths --reviewer-id --inline-comments --expected-pr-url [--provider-contract ]`는 single native create의 exact review·collection·timeline과 제한된 field projection을 대조한다. `expected_pr_url`은 snapshot에서 자동 추출하지 않고 main이 canonical authenticated access로 따로 확인한 exact PR URL을 제공하며 host·설치 prefix를 포함해 exact-match한다. Timeline은 ID·type·review ID·author·body·timestamp binding과 기존 event 보존을 검증하며 provider 전체 wire schema 확인은 기존 main 절차에 남는다. Snapshot은 `issue`, `issue_comments`, `pr`, `pr_comments`, `reviews`, `timeline`, `files`를 포함한다. Provider 계약을 생략하면 기존 review 변경을 허용하지 않는다. 현재 projection 계약은 `version.version=1.27.3`, boolean `supersession_same_author_only`·`already_dismissed_review_timestamp_projection`이며 main이 서버 버전별 authoritative 근거를 검증한 뒤 제공한다. `official` 정책은 main이 따로 판정하고 확인된 boolean `expected_official`을 계약에 제공한 경우 helper도 대조한다. 동일 `PENDING` ID submit 복구는 기존 절차로 검증하며 이 helper의 single-create 검증으로 대체하지 않는다. +- `check-unchanged --before --after `는 read-only replay의 exact snapshot 동일성을 확인한다. Helper 성공은 호출자가 실제 write를 하지 않았다는 증명이나 최종 리뷰 완료를 단독으로 의미하지 않는다. +- Helper는 credential을 발견하거나 network·외부 write를 수행하지 않고 acceptance 의미·evidence 유효성·verdict를 결정하지 않는다. 실제 secret 부재는 main이 별도로 확인하며 알려진 민감값은 Python API의 `sensitive_values`로 메모리 안에서만 전달한다. 입력 오류는 nonzero로 중단하고 입력 본문·credential을 error에 출력하지 않는다. +- 회귀 검증은 `terminal(command="PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s harness/tests/spec -p 'test_implementation_spec_review_support.py' -v")`로 실행한다. Schema/body/provider 처리 변경 시 같은 schema의 저장된 실제 wire artifact로 render·parse byte 일치와 publication·read-only replay도 검증한다. Helper 검증을 위해 새 외부 write를 하지 않으며 과거 artifact replay를 fresh authenticated readback으로 보고하지 않는다. + ## 절차 1. Exact target identifiers를 검증하고 `operations/repository-cache.md`를 새로 읽는다. @@ -199,7 +218,7 @@ Canonical body를 렌더링하기 전에 trusted workflow directory에 JSON obje 7. Cache path·ownership·permission·bare·remote를 검증하고 canonical base repository origin에서 prune/fetch한다. Gitea default branch SHA와 local ref를 일치시키고 PR base commit object가 cache에 존재하는지 검증한다. 8. Same-repository PR이면 head object도 cache에서 검증하고 exact base-to-head tree를 Gitea changed-file set·diff와 대조한다. Fork PR이면 target cache에 head object가 있다고 가정하지 않고 authoritative head repository·branch SHA와 Gitea changed-file set·diff를 대조한다. Diff 직렬화의 byte 차이만으로 실패시키지 않고 변경 경로와 exact base/head blob 및 hunk가 동일 변경을 나타내는지 검증한다. 임의 ref, checkout, clone 또는 worktree를 만들지 않는다. 9. Repository lock을 해제하되 Issue lock은 유지한다. -10. Acceptance matrix 작성 전에 기존 evidence의 source revision과 reviewed head 사이 전체 diff·실행 입력 변경을 확인하고, 필수 검증별 재사용 가능 범위와 현재 head에 새로 결합해야 할 근거를 먼저 요약한다. 재사용 여부는 위 Acceptance matrix의 기존 evidence binding 기준으로 판단하며 새 gate나 일괄 재실행 요구를 추가하지 않는다. 이 초기 요약만으로 검토를 끝내지 않고 Approved Discovery에서 acceptance matrix를 만들고 모든 changed file, 문서, source, test와 exact-head-bound status evidence를 대응시킨다. +10. Acceptance matrix 작성 전에 기존 evidence의 source revision과 reviewed head 사이 전체 diff·실행 입력 변경을 확인하고, 필수 검증별 재사용 가능 범위와 현재 head에 새로 결합해야 할 근거를 먼저 요약한다. 재사용 여부는 위 Acceptance matrix의 기존 evidence binding 기준으로 판단하며 새 gate나 일괄 재실행 요구를 추가하지 않는다. 재검토에서는 이전 finding별 승인 계약 근거와 최소 보완 요청을 새 구현·evidence의 확인 위치에 대응하고, 해소 또는 잔여 사유를 기존 criterion의 `evidence`와 `reason`에 기록한다. 이미 충족된 검증을 source revision 차이만으로 다시 요구하지 않되, 변경 영향으로 새로 판정할 수 없어진 필수 조건은 그 근거와 최소 보완 범위를 특정한다. 이 초기 요약만으로 검토를 끝내지 않고 Approved Discovery에서 acceptance matrix를 만들고 모든 changed file, 문서, source, test와 exact-head-bound status evidence를 대응시킨다. 11. 독립 code-review worker 결과가 있으면 exact head에 바인딩됐고 authoritative하게 읽을 수 있을 때만 보조 evidence로 사용한다. 그 결과를 spec verdict로 그대로 복사하지 않는다. 12. Verdict 규칙으로 하나의 strict result artifact와 canonical review body를 trusted workflow directory에 생성한다. 13. Exact key/type, duplicate key, marker field, digest, deterministic correlation, criterion cardinality, path binding, verdict/state 조합, credential 부재와 render → parse → render byte equality를 검증한다. diff --git a/harness/agents/spec/skills/gitea-implementation-spec-review/scripts/review_support.py b/harness/agents/spec/skills/gitea-implementation-spec-review/scripts/review_support.py new file mode 100644 index 0000000..3cc329e --- /dev/null +++ b/harness/agents/spec/skills/gitea-implementation-spec-review/scripts/review_support.py @@ -0,0 +1,666 @@ +#!/usr/bin/env python3 +"""Pure, standard-library support for implementation-spec review artifacts.""" +from __future__ import annotations +import argparse, grp, hashlib, json, math, os, pwd, re, stat, sys +from typing import NoReturn +from pathlib import Path + +SCHEMA = "gitea-agent.implementation-spec-review-result/v1" +TOP = ("schema", "target", "contract_sha256", "verdict", "criteria", "scope", "conclusion") +TARGET = ("owner", "repository", "issue", "pull", "head") +CRITERION = ("criterion", "contract", "implementation_paths", "test_paths", "evidence", "result", "reason") +SCOPE = ("missing", "excess", "spec_conflicts") +VERDICTS = {"approve", "request_changes", "blocked_spec"} +RESULTS = {"satisfied", "unsatisfied", "indeterminate"} +HEX40 = re.compile(r"^[0-9a-f]{40}$") +HEX64 = re.compile(r"^[0-9a-f]{64}$") +IDENT = re.compile(r"^[^\s/\\\x00<>`=@]+$") +CONTROL = re.compile(r"[\x00-\x1f\x7f-\x9f\u2028\u2029]") +MARKUP = re.compile(r"[`<>\[\]{}]|(?:^|\s)[#*!|]|[a-z][a-z0-9+.-]*://|www\.", re.I) + +class ValidationError(ValueError): + pass + +def _fail(msg="invalid review artifact") -> NoReturn: + raise ValidationError(msg) + +def _obj(v, keys, label): + if type(v) is not dict or set(v) != set(keys) or len(v) != len(keys): _fail("invalid " + label) + return v + +def _str(v, label, lo=1, hi=4000, human=False): + if type(v) is not str or not lo <= len(v) <= hi: _fail("invalid " + label) + if CONTROL.search(v) or v != v.rstrip(): _fail("invalid " + label) + try: + v.encode("utf-8") + except UnicodeError: + _fail("invalid " + label) + if human and (MARKUP.search(v) or "@" in v): _fail("invalid " + label) + return v + +def _array(v, label, hi=512, human=False): + if type(v) is not list or len(v) > hi: _fail("invalid " + label) + out=[]; seen=set() + for x in v: + _str(x, label + " item", 1, 4000, human) + if x in seen: _fail("duplicate " + label) + seen.add(x); out.append(x) + return out + +def loads_json(text): + if type(text) is not str: _fail("invalid JSON") + def hook(pairs): + d={} + for k,v in pairs: + if k in d: _fail("duplicate JSON key") + d[k]=v + return d + try: + return json.loads(text, object_pairs_hook=hook, parse_constant=lambda x: _fail("nonfinite JSON"), + parse_float=lambda x: float(x) if math.isfinite(float(x)) else _fail("nonfinite JSON")) + except ValidationError: raise + except Exception: _fail("invalid JSON") + +def _path(p): + _str(p, "path", 1, 1024) + if p.startswith("/") or "\\" in p or "\x00" in p: _fail("invalid path") + bits=p.split("/") + if any(x in ("", ".", "..") for x in bits): _fail("invalid path") + return p + +def validate_result(result, allowed_paths, sensitive_values=()): + _obj(result, TOP, "result schema") + if result["schema"] != SCHEMA: _fail("invalid schema") + t=_obj(result["target"], TARGET, "target") + for k in ("owner", "repository"): + _str(t[k], k, 1, 255); + if not IDENT.fullmatch(t[k]) or t[k] in (".", ".."): _fail("invalid target") + for k in ("issue", "pull"): + if type(t[k]) is not int or t[k] < 1: _fail("invalid target") + if type(t["head"]) is not str or not HEX40.fullmatch(t["head"]): _fail("invalid target") + if type(result["contract_sha256"]) is not str or not HEX64.fullmatch(result["contract_sha256"]): _fail("invalid contract hash") + if type(result["verdict"]) is not str or result["verdict"] not in VERDICTS: _fail("invalid verdict") + if type(allowed_paths) not in (list, tuple, set, frozenset): _fail("invalid allowed paths") + for path in allowed_paths: + _path(path) + allowed = set(allowed_paths) + if len(allowed) != len(allowed_paths): _fail("duplicate allowed path") + criteria=result["criteria"] + if type(criteria) is not list or not 1 <= len(criteria) <= 512: _fail("invalid criteria") + ids=set() + for c in criteria: + _obj(c, CRITERION, "criterion") + _str(c["criterion"], "criterion", 1, 200, True) + if c["criterion"] in ids: _fail("duplicate criterion") + ids.add(c["criterion"]) + for k in ("contract", "evidence", "reason"): _str(c[k], k, 1, 4000, True) + for k in ("implementation_paths", "test_paths"): + for p in _array(c[k], k): + _path(p) + if p not in allowed: _fail("path outside allowed set") + if type(c["result"]) is not str or c["result"] not in RESULTS: _fail("invalid criterion result") + s=_obj(result["scope"], SCOPE, "scope") + for k in SCOPE: _array(s[k], k, 512, True) + _str(result["conclusion"], "conclusion", 1, 4000, True) + human = [result["conclusion"]] + [c[k] for c in criteria for k in ("criterion", "contract", "evidence", "reason")] + human += [x for k in SCOPE for x in s[k]] + for secret in sensitive_values: + if type(secret) is not str: _fail("invalid sensitive values") + if secret and any(secret in value for value in human): + _fail("sensitive value in human data") + states={c["result"] for c in criteria} + if result["verdict"] == "approve" and (states != {"satisfied"} or any(s[k] for k in SCOPE)): _fail("verdict contradiction") + if result["verdict"] == "request_changes" and not ({"unsatisfied", "indeterminate"} & states): _fail("verdict contradiction") + if result["verdict"] == "blocked_spec" and not (s["spec_conflicts"] or "indeterminate" in states): _fail("verdict contradiction") + return result + +def _sha(b): return hashlib.sha256(b).hexdigest() +def _human_paths(xs): + if not xs: + return "없음" + # Quote complete list elements only when plain comma-separated prose is ambiguous. + unsafe = "<>@`[]{}*|~" + if any(", " in x or "; " in x or x == "없음" or '"' in x or any(c in x for c in unsafe) for x in xs): + def quoted(value): + text = json.dumps(value, ensure_ascii=False) + for char in unsafe: + text = text.replace(char, "\\u%04x" % ord(char)) + return text + return ", ".join(quoted(x) for x in xs) + return ", ".join(xs) + + +def _parse_list(text): + if text == "없음": + return [] + if not text.startswith('"'): + return text.split(", ") + decoder = json.JSONDecoder() + out = [] + while text: + try: + value, end = decoder.raw_decode(text) + except ValueError: + _fail("invalid list encoding") + if type(value) is not str: + _fail("invalid list encoding") + out.append(value) + text = text[end:] + if text: + if not text.startswith(", "): + _fail("invalid list encoding") + text = text[2:] + if not text: + _fail("invalid list encoding") + return out + + +def _parse_evidence(text): + # Find the first path/evidence separator outside quoted path elements. + quoted = escaped = False + for i, char in enumerate(text): + if quoted: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == '"': + quoted = False + elif char == '"': + quoted = True + elif text[i:i + 2] == "; ": + return _parse_list(text[:i]), text[i + 2:] + _fail("invalid criterion block") + +def _marker(r, digest): + t=r["target"] + raw="\x00".join([t["owner"],t["repository"],str(t["issue"]),str(t["pull"]),t["head"],r["contract_sha256"],digest]).encode() + corr=_sha(raw) + return ("" % + (t["owner"],t["repository"],t["issue"],t["pull"],t["head"],r["contract_sha256"],digest,corr)) + +def render_review(result, allowed_paths, sensitive_values=()): + r=validate_result(result, allowed_paths, sensitive_values); t=r["target"] + lines=["## 구현 명세 적합성 검토", "", f"- Source Issue: #{t['issue']}", f"- Pull Request: #{t['pull']}", + f"- Reviewed head: `{t['head']}`", f"- Contract SHA-256: `{r['contract_sha256']}`", f"- Verdict: `{r['verdict']}`", "", + "### Acceptance criteria 판정", ""] + for c in r["criteria"]: + lines += [f"- `{c['criterion']}` — `{c['result']}`", f" - 계약: {c['contract']}", + f" - 구현 근거: {_human_paths(c['implementation_paths'])}; {c['evidence']}", f" - 테스트 근거: {_human_paths(c['test_paths'])}", + f" - 판정 근거: {c['reason']}", ""] + s=r["scope"] + lines += ["### 범위 판정", "", f"- 누락: {_human_paths(s['missing'])}", f"- 범위 초과: {_human_paths(s['excess'])}", + f"- 명세 모순: {_human_paths(s['spec_conflicts'])}", "", "### 결론", "", r["conclusion"], ""] + body="\n".join(lines) + return body + "\n" + _marker(r, _sha(body.encode("utf-8"))) + "\n" + +def parse_review(body, allowed_paths, sensitive_values=()): + if type(body) is not str or not body.endswith("\n"): _fail("invalid review body") + lines=body.splitlines() + if not lines or not lines[-1].startswith(""): _fail("invalid review marker") + marker=lines[-1]; m=re.fullmatch(r"", marker) + if not m: _fail("invalid review marker") + # Parse the stable prose emitted by render_review, then validate by re-rendering. + try: + if lines[0] != "## 구현 명세 적합성 검토" or lines[8] != "### Acceptance criteria 판정": _fail("invalid review body") + issue=int(lines[2][len("- Source Issue: #") :]); pull=int(lines[3][len("- Pull Request: #"):]) + head=lines[4][len("- Reviewed head: `"):-1]; contract=lines[5][len("- Contract SHA-256: `"):-1]; verdict=lines[6][len("- Verdict: `"):-1] + i=9; criteria=[] + while i < len(lines) and lines[i] != "### 범위 판정": + if not lines[i]: i+=1; continue + cm=re.fullmatch(r"- `([^`]+)` — `([^`]+)`", lines[i]); + if not cm: _fail("invalid criterion block") + c={"criterion":cm.group(1),"result":cm.group(2)} + for key,prefix in (("contract"," - 계약: "),("implementation_paths"," - 구현 근거: "),("test_paths"," - 테스트 근거: "),("reason"," - 판정 근거: ")): + i+=1 + if i>=len(lines) or not lines[i].startswith(prefix): _fail("invalid criterion block") + val=lines[i][len(prefix):] + if key == "implementation_paths": + c[key], c["evidence"] = _parse_evidence(val) + else: + c[key] = _parse_list(val) if key.endswith("paths") else val + c={"criterion":c["criterion"],"contract":c["contract"],"implementation_paths":c["implementation_paths"],"test_paths":c["test_paths"],"evidence":c["evidence"],"result":c["result"],"reason":c["reason"]} + criteria.append(c); i+=1 + if i+6 >= len(lines) or lines[i+1] != "": _fail("invalid scope block") + scope={}; i += 1 + for key,prefix in (("missing","- 누락: "),("excess","- 범위 초과: "),("spec_conflicts","- 명세 모순: ")): + i += 1 + if not lines[i].startswith(prefix): _fail("invalid scope block") + scope[key] = _parse_list(lines[i][len(prefix):]) + i+=2 + if lines[i] != "### 결론" or lines[i+1] != "": _fail("invalid conclusion") + conclusion=lines[i+2] + except (IndexError, ValueError): _fail("invalid review body") + r={"schema":SCHEMA,"target":{"owner":m.group(1),"repository":m.group(2),"issue":issue,"pull":pull,"head":head},"contract_sha256":contract,"verdict":verdict,"criteria":criteria,"scope":scope,"conclusion":conclusion} + validate_result(r, allowed_paths, sensitive_values) + canonical=render_review(r, allowed_paths, sensitive_values) + if canonical != body or _sha(canonical[:canonical.rfind("