From 2ad836f332e1f477ebb2454ba0ab670276700ad8 Mon Sep 17 00:00:00 2001 From: gitea-spec-agent Date: Mon, 12 Oct 2026 01:46:39 +0000 Subject: [PATCH] [verified] Add fail-closed rework provider readback adapter --- .../impl/skills/gitea-issue-rework/SKILL.md | 10 +- .../gitea-issue-rework/references/readback.md | 159 +++++ .../scripts/rework_comment.py | 38 +- .../scripts/rework_readback.py | 544 ++++++++++++++++++ .../scripts/rework_readback_cli.py | 318 ++++++++++ harness/tests/README.md | 12 + harness/tests/impl/test_rework_readback.py | 477 +++++++++++++++ .../impl/test_rework_readback_transport.py | 194 +++++++ 8 files changed, 1744 insertions(+), 8 deletions(-) create mode 100644 harness/agents/impl/skills/gitea-issue-rework/references/readback.md create mode 100644 harness/agents/impl/skills/gitea-issue-rework/scripts/rework_readback.py create mode 100644 harness/agents/impl/skills/gitea-issue-rework/scripts/rework_readback_cli.py create mode 100644 harness/tests/impl/test_rework_readback.py create mode 100644 harness/tests/impl/test_rework_readback_transport.py diff --git a/harness/agents/impl/skills/gitea-issue-rework/SKILL.md b/harness/agents/impl/skills/gitea-issue-rework/SKILL.md index c4d9735..96188ae 100644 --- a/harness/agents/impl/skills/gitea-issue-rework/SKILL.md +++ b/harness/agents/impl/skills/gitea-issue-rework/SKILL.md @@ -176,10 +176,18 @@ Reviewer가 rework head를 새 snapshot으로 다시 검토해야 하며 이 com - `correlation`은 `owner`, `repository`, base-10 Issue 번호, base-10 PR 번호, base-10 review ID, lowercase reviewed head, lowercase final head와 lowercase contract digest를 이 순서로 NUL 하나씩 구분한 UTF-8 bytes의 SHA-256이다. `result-sha256`은 correlation 입력에 넣지 않는다. - 게시 전 exact PR top-level comment endpoint target과 fresh PR metadata binding을 검증하고 그 scoped collection만 complete pagination한 뒤 같은 correlation을 찾는다. Source Issue comment endpoint, inline-comment·review-reply endpoint와 native review collection은 검색 집합에서 제외하며 endpoint scope와 PR relation을 구분할 수 없으면 fail-closed한다. 0개면 exact body를 한 번 생성하고, 1개이며 author stable account ID·body·marker·digest가 모두 같으면 write 없는 no-op으로 재사용한다. 같은 correlation의 body가 다르거나 2개 이상이면 중복을 만들지 않고 fail-closed한다. - Comment create timeout·오류 뒤에는 blind retry하지 않는다. Complete comment collection을 한 번 authoritative하게 reconcile해 exact result 하나가 확인될 때만 성공으로 판정한다. -- 게시 또는 재사용 후 exact comment ID, author stable account ID, body, created·updated timestamp, marker, digest, verified endpoint target, fresh PR metadata binding과 PR top-level collection cardinality를 readback한다. 신규 게시에서는 scoped PR top-level comment collection에 exact comment 하나만 추가되어야 한다. Provider의 aggregate comment-count field는 fresh top-level·review·inline comment cardinality로 설명 가능해야 하며 설명할 수 없으면 fail-closed한다. 해당 comment timestamp에 인과가 결합된 PR `updated_at` 변경만 허용한다. Source Issue ``는 body digest·title·state·labels·assignees·milestone·comment collection이 불변이어야 한다. `Closes #` 같은 provider relation 때문에 Source Issue `updated_at`만 변경되면 그 시각이 이번 PR comment 생성과 인과적으로 결합된 경우에만 허용한다. Existing exact comment 재사용에서는 PR top-level collection cardinality, PR·underlying issue와 Source Issue field가 모두 불변이어야 한다. 기존 review collection 또는 그 밖의 PR field 변경은 허용하지 않는다. +- 게시 또는 재사용 후 exact comment ID, author stable account ID, body, created·updated timestamp, marker, digest, verified endpoint target, fresh PR metadata binding과 PR top-level collection cardinality를 readback한다. 신규 게시에서는 scoped PR top-level comment collection에 exact comment 하나만 추가되어야 한다. Provider의 aggregate comment-count field는 fresh top-level·review·inline comment cardinality로 설명 가능해야 하며 설명할 수 없으면 fail-closed한다. 신규 게시에서 PR·underlying PR-Issue의 `updated_at`은 불변이거나 exact 신규 comment 생성 timestamp와 같을 때만 허용한다. 이는 다른 native metadata가 불변인 상태의 state-delta/exact-timestamp admission이며, GET readback만으로 독립적인 causal event attestation을 제공하지 않는다. Source Issue ``는 `updated_at`을 포함한 전체 native metadata와 comment collection이 완전히 불변이어야 하며, provider relation을 이유로 timestamp 예외를 허용하지 않는다. Existing exact comment 재사용에서는 PR top-level collection cardinality, PR·underlying issue와 Source Issue field가 모두 불변이어야 한다. 기존 review collection 또는 그 밖의 PR field 변경은 허용하지 않는다. 완료 조건: exact final head와 finding matrix에 결합된 canonical comment가 impl role author로 정확히 하나 존재하고 authoritative readback을 통과한다. +### 정본 provider readback 실행 + +- `references/readback.md`의 canonical `scripts/rework_comment.py snapshot`으로 게시 전 baseline을 수집하고 producer의 SHA-256을 독립 보존한다. Impl role의 private credential, stable account ID와 reviewed provider-contract fingerprint를 고정한다. +- 승인된 단일 게시 또는 existing-comment 재사용 뒤에는 `scripts/rework_comment.py verify-live`를 `terminal`로 실행한다. Source Issue·PR·underlying PR-Issue·scoped top-level comments·reviews·inline evidence를 fresh GET으로 다시 수집하고 계산된 불변성·cardinality 결과를 기존 verifier에 직접 연결한다. +- 정본 출력은 caller가 admission한 Issue runtime의 `rework-baseline.json`과 `rework-receipt.json`이다. Product checkout 밖에서 baseline은 보존하고 receipt는 성공 검증 시에만 update-in-place한다. 실패 때문에 baseline 재수집이나 POST 재시도를 하지 않는다. +- Provider version과 aggregate semantics는 명시된 trusted contract에서만 채택한다. Count-header 없는 empty-array와 capability-bound count fallback은 구분하며, 권한 오류·redirect·불완전 pagination은 fallback 사유가 아니다. Counts-only inline fallback을 complete inline-body inventory로 보고하지 않는다. +- 기존 offline `verify`는 호환 경로다. 수동 JSON의 success flag나 archived replay만으로 fresh provider readback 또는 외부 게시 완료를 주장하지 않는다. + ## 절차 ### Evidence manifest automation diff --git a/harness/agents/impl/skills/gitea-issue-rework/references/readback.md b/harness/agents/impl/skills/gitea-issue-rework/references/readback.md new file mode 100644 index 0000000..e4f3389 --- /dev/null +++ b/harness/agents/impl/skills/gitea-issue-rework/references/readback.md @@ -0,0 +1,159 @@ +# Canonical PR rework completion readback + +## Scope and source + +Canonical entry point: `/harness/agents/impl/skills/gitea-issue-rework/scripts/rework_comment.py`. +The adjacent `rework_readback.py` derives readback inputs; `rework_readback_cli.py` +provides the role-local GET-only transport and private artifact writer. Update +these canonical files and this reference in place; do not copy them into another +profile or use a review-role credential for impl delivery. + +`snapshot` and `verify-live` never create, edit, retry, or submit a Gitea comment. +The `create` action verifies an already observed single comment addition; `reuse` +verifies an unchanged collection with exactly one matching existing comment. +An independently authorized publication workflow remains responsible for its +single external write, uncertain-write reconciliation, Issue lease, and fresh +pre-write delegation admission. + +## Prerequisites and independent expectations + +- The canonical access contract is `harness/operations/gitea-access.md`. +- Run in the impl role's own `HERMES_HOME`. Only its owned, non-symlink, mode-0600 + `.env` file supplies `GITEA_TOKEN`. No ambient token or other-profile fallback. +- Independently supply the expected stable impl account ID. The first API read + must authenticate that ID and the `gitea-impl-agent` login. +- Generate `expected.json` with the existing canonical renderer. Its exact target, + final head, reviewed head, review ID, body, marker and digest bind the workflow. +- Supply a reviewed provider contract and its independently expected SHA-256. + The exact provider version is read from the server and must match the contract. + A digest calculated from an untrusted delegate file is not independent evidence. +- Own a private mode-0700 runtime directory. Inputs must be owned regular files, + not group/world writable or symlinked. Output files use mode 0600; a failed + verification does not replace an existing receipt. + +## Provider contract + +Schema: `gitea-agent.rework-provider/v1`. The contract explicitly declares the +aggregate semantics for PR and underlying PR-Issue `comments` fields. Each +`components` array contains unique values from `top_level`, `reviews`, `inline` +and must include `top_level`. Choose the reviewed deployment semantics, not the +combination that happens to fit a current response. There is no guessed version +or automatic capability downgrade. + +Shape example only, **not a claim about this deployment**: + +```json +{ + "schema": "gitea-agent.rework-provider/v1", + "version": "", + "aggregate": { + "pr": {"field": "comments", "components": ["top_level"]}, + "pr_issue": {"field": "comments", "components": ["top_level"]} + }, + "inline": { + "mode": "per_review", + "field": "review_comments", + "components": ["inline"] + } +} +``` + +`per_review` requires every exact-review comment endpoint. Only the explicit +`per_review_or_pr_count` capability permits endpoint-unavailable 404/405 fallback. +Permission errors, redirects, malformed JSON and conflicting metadata never +trigger fallback. Fallback uses a fresh PR count, the declared review/inline +components and complete review `comments_count` records; disagreement is BLOCK. +Counts-only fallback is recorded as such and is not proof of inline comment +bodies or a complete inline-record inventory. + +Native wire-shape reference: the pinned Gitea `v1.24.6` serializers in +`services/convert/pull.go` and `modules/structs/{issue,pull}.go` expose PR `url` +and `html_url` as HTML URLs, and PR-Issue `pull_request.html_url` without a native +`pull_request.url`. Require the actual native relation URL; do not invent a +missing field. This source pin is fixture/protocol evidence, not a claim about +the deployment version. The caller's independent provider contract still binds +that version. In `per_review` mode only, an omitted `review_comments` is accepted +as zero only when complete per-review records and their counters prove a zero +projection. Explicit null, nonzero projection or counts-only fallback with a +missing scalar is rejected. Raw pagination Link evidence is preserved and +`first`, `prev`, `next`, `last` are checked against the declared total, including +the empty sentinel page after an exact multiple of the page size. + +## Commands and canonical artifact paths + +The caller's admitted Issue runtime is +`/repositories///worktrees/issue-/runtime/`. +Place the canonical `rework-baseline.json` and `rework-receipt.json` there, outside +the product checkout. Preserve the baseline SHA-256 returned by its producer +independently; never replace the baseline after the external write. + +Through `terminal`, before publication: + +```sh +python3 -B /harness/agents/impl/skills/gitea-issue-rework/scripts/rework_comment.py snapshot \ + --expected /expected.json \ + --provider-contract \ + --provider-contract-sha256 \ + --actor-id \ + --output /rework-baseline.json +``` + +After the authorized write or existing-comment reuse, through `terminal`: + +```sh +python3 -B /harness/agents/impl/skills/gitea-issue-rework/scripts/rework_comment.py verify-live \ + --expected /expected.json \ + --provider-contract \ + --provider-contract-sha256 \ + --actor-id \ + --baseline /rework-baseline.json \ + --baseline-sha256 \ + --action create \ + --output /rework-receipt.json +``` + +Use `--action reuse` only for an unchanged scoped collection. Do not recapture a +baseline or retry a write merely because readback fails. Exit 0 and a receipt are +required; stdout reports only the artifact path, fingerprint and read-only flag. +The existing offline `verify` command remains compatible, but manually supplied +JSON/true flags or archived artifacts alone are not fresh provider readback. + +## Verified invariants + +- Source Issue, PR, underlying PR-Issue, repository, exact source review and heads + are independently bound to authenticated endpoint responses. +- Pagination is bounded; duplicate IDs, contradictory totals, truncated pages, + malformed metadata and off-target continuation links are rejected. +- Genuine empty arrays without a count header are supported; null is not silently + accepted as an empty collection. +- Existing top-level comments, Source Issue fields/comments, native reviews and + admitted inline evidence remain unchanged. Create admits exactly one matching + impl-authored comment. Reuse admits no addition. +- Only provider-declared count projections and the bounded PR/underlying-PR-Issue + state-delta/exact-timestamp admission for that verified new comment are admitted; + Source Issue metadata, including `updated_at`, is fully immutable. This admission + is not proof of causal event attestation; the receipt records that limitation. +- The adapter computes normalized inputs and passes them to the same verifier used + by `rework_comment.py verify`, rather than accepting caller success flags. + +## Limits and verification + +The CLI enforces verified HTTPS, no redirects, GET-only requests, a one-megabyte +response cap, ten-second socket timeouts and a 120-second operation budget. Body +budget checks occur between socket reads, so an already-blocking read may add its +remaining socket-timeout allowance. There are no automatic retries. + +The authenticated transport and independently supplied actor/provider/baseline +expectations are trust inputs, not a sandbox or proof that arbitrary caller code +is honest. The adapter cannot prove missing inline bodies from scalar counts, and +state deltas plus exact timestamps are not independent causal-event attestation. + +Run focused tests with `terminal`: + +```sh +python3 -B -m unittest harness.tests.impl.test_rework_readback harness.tests.impl.test_rework_readback_transport harness.tests.impl.test_rework_comment -v +python3 -B harness/tests/run.py +``` + +Local synthetic transport and HTTP fixtures validate the executable paths; they +do not constitute a live Gitea readback or an external-write completion record. diff --git a/harness/agents/impl/skills/gitea-issue-rework/scripts/rework_comment.py b/harness/agents/impl/skills/gitea-issue-rework/scripts/rework_comment.py index b58707b..f3f5ffc 100755 --- a/harness/agents/impl/skills/gitea-issue-rework/scripts/rework_comment.py +++ b/harness/agents/impl/skills/gitea-issue-rework/scripts/rework_comment.py @@ -108,8 +108,9 @@ def do_render(a): expected={"body":body+marker,"marker":marker,"marker_free_body":body,"result_sha256":rh,"correlation":corr,"target":{"owner":d['owner'],"repository":d['repository'],"issue":int(vals['issue']),"pr":int(vals['pr'])},"review":int(vals['review']),"reviewed_head":d['reviewed_head'],"head":d['final_head'],"contract_sha256":d['contract_sha256']} atomic(a.expected,(json.dumps(expected,ensure_ascii=False,sort_keys=True,separators=(",",":"))+"\n").encode()) -def do_verify(a): - exp=read_json(a.expected); pr=read_json(a.pr_readback); comment_readback=read_json(a.comments_readback); ident=read_json(a.identity_readback) +def verify_readbacks(exp, pr, comment_readback, ident, *, action, baseline_count): + if action not in ('create', 'reuse') or type(baseline_count) is not int or baseline_count < 0: + raise Bad('invalid verification action or baseline count') body=exp.get("body"); rh=exp.get("result_sha256"); corr=exp.get("correlation"); head=exp.get("head") marker=exp.get("marker","") marker_free=exp.get("marker_free_body", body[:-len(marker)] if marker and body.endswith(marker) else body) @@ -145,9 +146,16 @@ def do_verify(a): author=c.get("user",c.get("author",{})); cid=author.get("id") if isinstance(author,dict) else None; clog=author.get("login") if isinstance(author,dict) else None if cid!=aid or clog!=login: raise Bad("author mismatch") if isinstance(c.get("id"),bool) or not isinstance(c.get("id"),int) or c["id"]<1 or not c.get("html_url",c.get("url")) or not c.get("created_at") or not c.get("updated_at"): raise Bad("comment metadata") - baseline=a.baseline_count - if baseline<0 or (a.action=="create" and len(comments)!=baseline+1) or (a.action=="reuse" and len(comments)!=baseline): raise Bad("cardinality") - receipt={"schema":"gitea-rework-handoff/v1","status":"verified","target":{"owner":owner,"repository":repository,"issue":int(issue),"pr":int(pr_number),"review":int(review),"object_kind":"pull_request","endpoint_binding_verified":True},"comment_id":c["id"],"comment_url":c.get("html_url",c.get("url")),"comment_body":cb,"marker":marker,"created_at":c["created_at"],"updated_at":c["updated_at"],"final_head":head,"result_sha256":rh,"correlation":corr,"author":{"id":aid,"login":login,"role":"gitea-impl-agent","authenticated":True,"stable_account_verified":True},"pr_metadata_binding_verified":True,"pr_metadata_unchanged":True,"source_issue_unchanged":True,"review_collection_unchanged":True,"provider_counts":provider_counts,"readback_verified":True,"action":a.action,"cardinality":len(comments),"page_count":comment_readback["page_count"]} + baseline=baseline_count + if baseline<0 or (action=="create" and len(comments)!=baseline+1) or (action=="reuse" and len(comments)!=baseline): raise Bad("cardinality") + receipt={"schema":"gitea-rework-handoff/v1","status":"verified","target":{"owner":owner,"repository":repository,"issue":int(issue),"pr":int(pr_number),"review":int(review),"object_kind":"pull_request","endpoint_binding_verified":True},"comment_id":c["id"],"comment_url":c.get("html_url",c.get("url")),"comment_body":cb,"marker":marker,"created_at":c["created_at"],"updated_at":c["updated_at"],"final_head":head,"result_sha256":rh,"correlation":corr,"author":{"id":aid,"login":login,"role":"gitea-impl-agent","authenticated":True,"stable_account_verified":True},"pr_metadata_binding_verified":True,"pr_metadata_unchanged":True,"source_issue_unchanged":True,"review_collection_unchanged":True,"provider_counts":provider_counts,"readback_verified":True,"action":action,"cardinality":len(comments),"page_count":comment_readback["page_count"]} + return receipt + + +def do_verify(a): + receipt = verify_readbacks(read_json(a.expected), read_json(a.pr_readback), + read_json(a.comments_readback), read_json(a.identity_readback), + action=a.action, baseline_count=a.baseline_count) atomic(a.output,(json.dumps(receipt,sort_keys=True,separators=(",",":"))+"\n").encode()) def main(): @@ -155,8 +163,24 @@ def main(): r=sp.add_parser("render"); r.add_argument("--input",required=True); r.add_argument("--comment",required=True); r.add_argument("--expected",required=True) adoption=r.add_mutually_exclusive_group(required=True); adoption.add_argument("--no-delegated-evidence",action="store_true"); adoption.add_argument("--delegation-adoption") v=sp.add_parser("verify"); v.add_argument("--expected",required=True); v.add_argument("--pr-readback",required=True); v.add_argument("--comments-readback",required=True); v.add_argument("--identity-readback",required=True); v.add_argument("--action",choices=("create","reuse"),required=True); v.add_argument("--baseline-count",type=int,required=True); v.add_argument("--output",required=True) + for command in ('snapshot', 'verify-live'): + live = sp.add_parser(command) + live.add_argument('--expected', required=True) + live.add_argument('--provider-contract', required=True) + live.add_argument('--provider-contract-sha256', required=True) + live.add_argument('--actor-id', required=True, type=int) + live.add_argument('--output', required=True) + if command == 'verify-live': + live.add_argument('--baseline', required=True) + live.add_argument('--baseline-sha256', required=True) + live.add_argument('--action', choices=('create', 'reuse'), required=True) args=p.parse_args() - try: do_render(args) if args.cmd=="render" else do_verify(args) - except (Bad, OSError, KeyError, TypeError, UnicodeError) as e: print(f"error: {e}",file=sys.stderr); return 2 + try: + if args.cmd == 'render': do_render(args) + elif args.cmd == 'verify': do_verify(args) + else: + from rework_readback_cli import run_live + run_live(args, verify_readbacks) + except (Bad, OSError, KeyError, TypeError, UnicodeError, ValueError) as e: print(f"error: {e}",file=sys.stderr); return 2 return 0 if __name__=="__main__": raise SystemExit(main()) diff --git a/harness/agents/impl/skills/gitea-issue-rework/scripts/rework_readback.py b/harness/agents/impl/skills/gitea-issue-rework/scripts/rework_readback.py new file mode 100644 index 0000000..5bef532 --- /dev/null +++ b/harness/agents/impl/skills/gitea-issue-rework/scripts/rework_readback.py @@ -0,0 +1,544 @@ +"""Read-only provider snapshots normalized for the canonical rework verifier.""" +import copy +import hashlib +import json +import re +from datetime import datetime +from urllib.parse import parse_qs, urlsplit + + +def integer(value, minimum=0): + if type(value) is not int or value < minimum: + raise ReadbackError('invalid provider integer') + return value + + +def object_value(value): + if type(value) is not dict: + raise ReadbackError('provider object required') + return value + +ORIGIN = 'https://gitea.jsfamily.quest/api/v1' +CREDENTIAL_SOURCE = '$HERMES_HOME/.env:GITEA_TOKEN' + + +class ReadbackError(ValueError): + pass + + +def timestamp(value): + if type(value) is not str or not re.fullmatch(r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})', value): + raise ReadbackError('invalid provider timestamp') + try: return datetime.fromisoformat(value.replace('Z','+00:00')) + except ValueError: raise ReadbackError('invalid provider timestamp') from None + + +def digest(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False, + allow_nan=False).encode()).hexdigest() + + +def text(value): + if type(value) is not str: + raise ReadbackError('provider text required') + return value + + +def actor(value): + value = object_value(value) + integer(value.get('id'), 1) + if type(value.get('login')) is not str or not value['login']: + raise ReadbackError('invalid provider actor') + return value + + +def issue_metadata(record): + record = object_value(record) + for field in ('labels', 'assignees'): + if field not in record: raise ReadbackError('missing Issue metadata collection') + values = record[field] + if field == 'assignees' and values is None: continue + if type(values) is not list: + raise ReadbackError('invalid Issue metadata collection') + for value in values: + value = object_value(value) + integer(value.get('id'), 1) + if field == 'labels': + text(value.get('name')) + else: + actor(value) + if 'milestone' not in record: + raise ReadbackError('missing Issue milestone field') + milestone = record.get('milestone') + if milestone is not None: + milestone = object_value(milestone) + integer(milestone.get('id'), 1) + text(milestone.get('title')) + if milestone.get('state') not in ('open','closed'): raise ReadbackError('invalid milestone state') + actor(record.get('user')) + timestamp(record.get('created_at')) + timestamp(record.get('updated_at')) + text(record.get('title')) + text(record.get('body')) + if record.get('state') not in ('open', 'closed'): + raise ReadbackError('invalid Issue state') + return record + + +def branch(value): + value = object_value(value) + for field in ('ref', 'label', 'sha'): + text(value.get(field)) + if not re.fullmatch(r'[0-9a-f]{40}', value['sha']): + raise ReadbackError('invalid PR branch SHA') + integer(value.get('repo_id'), 1) + repo = object_value(value.get('repo')) + if integer(repo.get('id'), 1) != value['repo_id'] or not re.fullmatch( + r'[A-Za-z0-9][A-Za-z0-9._-]{0,99}/[A-Za-z0-9][A-Za-z0-9._-]{0,99}', + repo.get('full_name', '')): + raise ReadbackError('invalid PR branch repository') + return value + + +def review_record(value): + value = object_value(value) + integer(value.get('id'), 1) + integer(value.get('comments_count')) + if value.get('state') not in ('APPROVED', 'REQUEST_CHANGES', 'COMMENT', 'PENDING'): + raise ReadbackError('invalid native review state') + text(value.get('body')) + if not re.fullmatch(r'[0-9a-f]{40}', text(value.get('commit_id'))): + raise ReadbackError('invalid native review commit') + timestamp(value.get('submitted_at')) + timestamp(value.get('updated_at')) + if type(value.get('stale')) is not bool or type(value.get('dismissed')) is not bool: + raise ReadbackError('invalid native review flags') + actor(value.get('user')) + return value + + +class ReworkReadbackAdapter: + def __init__(self, request, *, origin, credential_source, actor_id, provider_contract): + if (not callable(request) or origin != ORIGIN or credential_source != CREDENTIAL_SOURCE + or type(actor_id) is not int or actor_id < 1): + raise ReadbackError('invalid authenticated transport binding') + self._transport = request + self.actor_id = actor_id + self.provider = self.provider_contract(provider_contract) + + def provider_contract(self, contract): + contract = object_value(contract) + if (set(contract) != {'schema','version','aggregate','inline'} + or contract.get('schema') != 'gitea-agent.rework-provider/v1' + or type(contract.get('version')) is not str or not contract['version'] + or any(ord(char) < 33 for char in contract['version'])): + raise ReadbackError('invalid explicit provider contract') + aggregate = object_value(contract.get('aggregate')) + if set(aggregate) != {'pr','pr_issue'}: raise ReadbackError('invalid aggregate scope') + for scope in ('pr','pr_issue'): + spec = object_value(aggregate[scope]) + parts = spec.get('components') + if (set(spec) != {'field','components'} or spec.get('field') != 'comments' + or type(parts) is not list or not parts + or any(type(part) is not str or part not in ('top_level','reviews','inline') for part in parts) + or len(parts) != len(set(parts)) or 'top_level' not in parts): + raise ReadbackError('invalid aggregate count declaration') + inline = object_value(contract.get('inline')) + if (set(inline) != {'mode','field','components'} + or inline.get('mode') not in ('per_review','per_review_or_pr_count') + or inline.get('field') != 'review_comments' + or inline.get('components') not in (['inline'], ['reviews','inline'])): + raise ReadbackError('invalid inline count declaration') + return copy.deepcopy(contract) + + def request(self, path): + name = r'[A-Za-z0-9][A-Za-z0-9._-]{0,99}' + route = r'/(?:user|version|repos/'+name+'/'+name+r'(?:/(?:issues/[1-9][0-9]*(?:/comments)?|pulls/[1-9][0-9]*(?:/reviews(?:/[1-9][0-9]*/comments)?)?))?)' + if type(path) is not str or not re.fullmatch(route + r'(?:\?page=[1-9][0-9]*&limit=50)?', path): + raise ReadbackError('non-canonical provider endpoint') + try: result = self._transport(path) + except Exception: raise ReadbackError('provider transport failed') from None + if type(result) is not tuple or len(result) != 3: + raise ReadbackError('invalid provider response tuple') + payload, headers, status = result + integer(status, 100) + headers = object_value(headers) + normalized = {} + for key, value in headers.items(): + if type(key) is not str or type(value) is not str or key.lower() in normalized: + raise ReadbackError('invalid or conflicting provider header') + normalized[key.lower()] = value + return copy.deepcopy(payload), normalized, status + + def get(self, path): + value, headers, status = self.request(path) + if status != 200: + raise ReadbackError('provider GET failed') + return object_value(value), headers + + def links(self, endpoint, header, page, total): + if header is None: return False + last_page = max(1, (total + 49) // 50) + if page > last_page + 1: + raise ReadbackError('pagination page beyond declared total') + seen = set() + for part in header.split(','): + match = re.fullmatch(r'\s*<([^<>\s]+)>;\s*rel=(?:"(next|prev|first|last)"|(next|prev|first|last))\s*', part) + relation = match[2] or match[3] if match is not None else None + if match is None or relation in seen: + raise ReadbackError('malformed pagination Link') + seen.add(relation) + url = match[1] + if url.startswith(ORIGIN + '/'): + url = url[len(ORIGIN):] + elif url.startswith('/api/v1/'): + url = url[len('/api/v1'):] + parsed = urlsplit(url) + query = parse_qs(parsed.query, keep_blank_values=True) + if (parsed.scheme or parsed.netloc or parsed.fragment or parsed.path != endpoint + or set(query) != {'page','limit'} or query['limit'] != ['50'] + or len(query['page']) != 1 or not re.fullmatch(r'[1-9][0-9]*', query['page'][0])): + raise ReadbackError('foreign or invalid pagination Link') + linked_page = int(query['page'][0]) + expected = {'first':1, 'last':last_page}.get(relation) + if relation == 'prev': + if page <= 1 or linked_page != page - 1: + raise ReadbackError('pagination Link bound or sequence mismatch') + elif relation == 'next': + if linked_page != page + 1 or linked_page > last_page: + raise ReadbackError('pagination Link bound or sequence mismatch') + elif linked_page != expected: + raise ReadbackError('pagination Link bound or sequence mismatch') + return 'next' in seen + + def collection(self, endpoint, *, allow_missing=False): + rows, pages, ids, expected_total = [], [], set(), None + for page in range(1, 22): + path = endpoint + '?page=' + str(page) + '&limit=50' + values, headers, status = self.request(path) + if status in (404,405) and allow_missing and page == 1: + return {'endpoint':endpoint,'rows':[], 'pages':[{'path':path,'page':1,'count':0,'total':None,'status':status, + 'link':headers.get('link')}]} + if status != 200 or type(values) is not list or len(values) > 50: + raise ReadbackError('invalid collection response') + declared = headers.get('x-total-count') + if declared is None and not values and page == 1: declared = '0' + if declared is None or not re.fullmatch(r'0|[1-9][0-9]*', declared): + raise ReadbackError('missing or invalid collection total') + total = int(declared) + if total > 1000 or (expected_total is not None and expected_total != total): + raise ReadbackError('collection bound or total drift') + expected_total = total + for row in values: + ident = integer(object_value(row).get('id'), 1) + if ident in ids: raise ReadbackError('duplicate collection ID') + ids.add(ident) + rows.extend(values) + pages.append({'path':path,'page':page,'count':len(values),'total':total,'status':200}) + link = headers.get('link') + next_link = self.links(endpoint, link, page, total) + pages[-1]['link'] = link + if len(rows) > total or (len(rows) == total and next_link): + raise ReadbackError('contradictory collection total or Link') + if len(rows) == total and len(values) < 50: + return {'endpoint':endpoint,'rows':rows,'pages':pages} + if len(values) < 50: + raise ReadbackError('truncated collection') + raise ReadbackError('incomplete provider collection') + + def target(self, expected): + expected = object_value(expected) + target = object_value(expected.get('target')) + if set(target) != {'owner','repository','issue','pr'}: + raise ReadbackError('invalid expected target fields') + for name in ('owner','repository'): + if type(target.get(name)) is not str or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]{0,99}', target[name]): + raise ReadbackError('invalid literal target identifier') + for name in ('issue','pr'): integer(target.get(name), 1) + integer(expected.get('review'), 1) + if target['issue'] == target['pr']: raise ReadbackError('source Issue must differ from PR') + for name, length in (('head',40),('reviewed_head',40),('contract_sha256',64),('result_sha256',64),('correlation',64)): + if type(expected.get(name)) is not str or not re.fullmatch('[0-9a-f]{'+str(length)+'}', expected[name]): + raise ReadbackError('invalid literal expected digest') + free, body, marker = [expected.get(name) for name in ('marker_free_body','body','marker')] + if any(type(value) is not str for value in (free,body,marker)) or len(body.encode()) > 1_000_000: + raise ReadbackError('invalid expected body') + correlation = hashlib.sha256('\0'.join([target['owner'],target['repository'],str(target['issue']), + str(target['pr']),str(expected['review']),expected['reviewed_head'],expected['head'], + expected['contract_sha256']]).encode()).hexdigest() + wanted = ('\n') + if (body != free+marker or hashlib.sha256(free.encode()).hexdigest() != expected['result_sha256'] + or expected['correlation'] != correlation or marker != wanted): + raise ReadbackError('expected renderer binding mismatch') + return dict(target, review=expected['review'], reviewed_head=expected['reviewed_head'], head=expected['head']) + + def bind(self, target, raw): + prefix = ORIGIN + '/repos/' + target['owner'] + '/' + target['repository'] + html = 'https://gitea.jsfamily.quest/' + target['owner'] + '/' + target['repository'] + repo = object_value(raw['repository']) + repo_id = integer(repo.get('id'), 1) + if repo.get('full_name') != target['owner']+'/'+target['repository'] or repo.get('html_url') != html: + raise ReadbackError('repository binding mismatch') + source, pr, underlying = [object_value(raw[name]) for name in ('source_issue','pr','pr_issue')] + for record in (source, pr, underlying): + issue_metadata(record) + for record, number in ((source,target['issue']), (pr,target['pr']), (underlying,target['pr'])): + integer(record.get('id'), 1) + if integer(record.get('number'), 1) != number: + raise ReadbackError('Issue or PR number mismatch') + if source.get('html_url') != html+'/issues/'+str(target['issue']) or source.get('pull_request') is not None: + raise ReadbackError('source Issue kind or URL mismatch') + if (pr.get('html_url') != html+'/pulls/'+str(target['pr']) + or pr.get('url') != html+'/pulls/'+str(target['pr']) or pr.get('state') != 'open'): + raise ReadbackError('PR kind or URL mismatch') + if (pr['user']['id'] != self.actor_id or pr['user']['login'] != 'gitea-impl-agent' + or underlying['user']['id'] != self.actor_id + or underlying['user']['login'] != 'gitea-impl-agent'): + raise ReadbackError('PR author binding mismatch') + closing = re.findall(r'(?i)\b(?:closes?|fixes?|resolves?)\s+#([1-9][0-9]*)\b', pr['body']) + canonical_closing = re.findall(r'\bCloses[ \t]+#([1-9][0-9]*)\b', pr['body']) + if closing != [str(target['issue'])] or canonical_closing != closing: + raise ReadbackError('PR closing relationship mismatch') + base_branch, head_branch = branch(pr.get('base')), branch(pr.get('head')) + base = object_value(object_value(pr.get('base')).get('repo')) + if (integer(base.get('id'), 1) != repo_id or base.get('full_name') != repo['full_name'] + or base_branch['repo_id'] != repo_id): + raise ReadbackError('PR base repository mismatch') + if head_branch['sha'] != target['head']: + raise ReadbackError('PR head mismatch') + # Fork heads are valid; the target repository is bound through base and endpoint. + relation = object_value(underlying.get('pull_request')) + if (underlying.get('html_url') not in (html+'/pulls/'+str(target['pr']), html+'/issues/'+str(target['pr'])) + or relation.get('html_url') != html+'/pulls/'+str(target['pr']) + or ('url' in relation and relation.get('url') != html+'/pulls/'+str(target['pr']))): + raise ReadbackError('underlying Issue PR binding mismatch') + review = next((row for row in raw['reviews']['rows'] if row.get('id') == target['review']), None) + if review is None or review.get('commit_id') != target['reviewed_head']: + raise ReadbackError('source review binding mismatch') + if (review.get('dismissed') or review.get('state') == 'PENDING' + or review['user']['id'] == self.actor_id + or review['user']['login'] not in ('gitea-review-agent', 'gitea-spec-agent')): + raise ReadbackError('source review author or state mismatch') + + def counts(self, raw): + inline = sum(integer(review.get('comments_count')) for review in raw['reviews']['rows']) + fallback = False + inline_ids = set() + for review in raw['reviews']['rows']: + collected = raw['inline'][str(review['id'])] + if collected['pages'][0]['status'] in (404,405): + if self.provider['inline']['mode'] != 'per_review_or_pr_count': + raise ReadbackError('inline fallback not explicitly authorized') + fallback = True + elif len(collected['rows']) != review['comments_count']: + raise ReadbackError('per-review inline count mismatch') + for row in collected['rows']: + if row['id'] in inline_ids: raise ReadbackError('duplicate cross-review inline ID') + inline_ids.add(row['id']) + computed = inline + (len(raw['reviews']['rows']) if 'reviews' in self.provider['inline']['components'] else 0) + field = self.provider['inline']['field'] + if field not in raw['pr']: + if (self.provider['inline']['mode'] != 'per_review' or fallback or computed != 0 + or len(inline_ids) != 0): + raise ReadbackError('missing inline provider counter') + elif integer(raw['pr'].get(field)) != computed: + raise ReadbackError('inline provider counter crosscheck failed') + if not fallback and len(inline_ids) != inline: + raise ReadbackError('inline record cardinality mismatch') + counts = {'top_level':len(raw['comments']['rows']), + 'reviews':len(raw['reviews']['rows']), 'inline':inline} + for scope in ('pr','pr_issue'): + spec = self.provider['aggregate'][scope] + if integer(raw[scope].get(spec['field'])) != sum(counts[name] for name in spec['components']): + raise ReadbackError('provider aggregate count mismatch') + if integer(raw['source_issue'].get('comments')) != len(raw['source_comments']['rows']): + raise ReadbackError('source collection count mismatch') + return counts + + def capture(self, target): + identity, _ = self.get('/user') + if integer(identity.get('id'), 1) != self.actor_id or identity.get('login') != 'gitea-impl-agent': + raise ReadbackError('authenticated identity mismatch') + version, _ = self.get('/version') + if version.get('version') != self.provider['version']: + raise ReadbackError('provider version mismatch') + prefix = '/repos/' + target['owner'] + '/' + target['repository'] + raw = {'identity':identity,'version':version} + for name, path in {'repository':prefix,'source_issue':prefix+'/issues/'+str(target['issue']), + 'pr':prefix+'/pulls/'+str(target['pr']), + 'pr_issue':prefix+'/issues/'+str(target['pr'])}.items(): + raw[name], _ = self.get(path) + for name, endpoint in {'source_comments':prefix+'/issues/'+str(target['issue'])+'/comments', + 'comments':prefix+'/issues/'+str(target['pr'])+'/comments', + 'reviews':prefix+'/pulls/'+str(target['pr'])+'/reviews'}.items(): + raw[name] = self.collection(endpoint) + raw['inline'] = {str(review['id']):self.collection(prefix+'/pulls/'+str(target['pr'])+ + '/reviews/'+str(review['id'])+'/comments', allow_missing=self.provider['inline']['mode'] == 'per_review_or_pr_count') for review in raw['reviews']['rows']} + self.bind(target, raw) + self.counts(raw) + return raw + + def snapshot(self, expected): + target = self.target(expected) + raw = self.capture(target) + if digest(self.capture(target)) != digest(raw): + raise ReadbackError('concurrent provider drift') + counts = self.counts(raw) + snapshot = {'schema':'gitea-agent.rework-readback/v1','target':target, + 'expected_sha256':digest(expected),'provider':copy.deepcopy(self.provider), + 'actor_id':self.actor_id,'origin':ORIGIN,'credential_source':CREDENTIAL_SOURCE, + 'raw':raw,'review_body_sha256':hashlib.sha256(text(next(row for row in raw['reviews']['rows'] + if row['id'] == target['review'])['body']).encode()).hexdigest(), + 'normalized_counts':counts,'fallback':'counts_only' if any(value['pages'][0]['status'] != 200 for value in raw['inline'].values()) else 'none'} + self.validate_snapshot(expected, snapshot) + return snapshot + + def validate_collection(self, collected, endpoint, *, reviews=False, allow_missing=False): + collected = object_value(collected) + rows, pages = collected.get('rows'), collected.get('pages') + if (collected.get('endpoint') != endpoint or type(rows) is not list or len(rows) > 1000 + or type(pages) is not list or not 1 <= len(pages) <= 21): + raise ReadbackError('collection provenance mismatch') + ids = set() + for row in rows: + row = object_value(row) + ident = integer(row.get('id'), 1) + if ident in ids: raise ReadbackError('duplicate collection ID') + ids.add(ident) + user = object_value(row.get('user')) + integer(user.get('id'), 1) + if type(user.get('login')) is not str or not user['login']: + raise ReadbackError('invalid collection author') + if reviews: + review_record(row) + else: + if type(row.get('body')) is not str: raise ReadbackError('invalid comment body') + if timestamp(row.get('updated_at')) < timestamp(row.get('created_at')): + raise ReadbackError('invalid comment timestamp order') + first = object_value(pages[0]) + missing = first.get('status') in (404,405) + if missing: + if not allow_missing or rows or len(pages) != 1 or first.get('total') is not None: + raise ReadbackError('invalid inline capability fallback') + elif len(pages) != len(rows)//50+1: + raise ReadbackError('incomplete collection page records') + for index, page in enumerate(pages, 1): + page = object_value(page) + count = 0 if missing else min(50, max(0, len(rows)-(index-1)*50)) + link = page.get('link') + if link is not None and type(link) is not str: + raise ReadbackError('collection page provenance mismatch') + if (integer(page.get('page'), 1) != index or integer(page.get('count')) != count + or page.get('path') != endpoint+'?page='+str(index)+'&limit=50' + or integer(page.get('status'), 100) not in ((404,405) if missing else (200,)) + or (not missing and integer(page.get('total')) != len(rows))): + raise ReadbackError('collection page provenance mismatch') + if not missing: + self.links(endpoint, link, index, len(rows)) + return missing + + def validate_snapshot(self, expected, snapshot): + snapshot = object_value(snapshot) + target = self.target(expected) + if (snapshot.get('schema') != 'gitea-agent.rework-readback/v1' + or snapshot.get('origin') != ORIGIN or snapshot.get('credential_source') != CREDENTIAL_SOURCE + or integer(snapshot.get('actor_id'), 1) != self.actor_id + or digest(snapshot.get('target')) != digest(target) + or digest(snapshot.get('provider')) != digest(self.provider) + or snapshot.get('expected_sha256') != digest(expected)): + raise ReadbackError('snapshot binding mismatch') + raw = object_value(snapshot.get('raw')) + identity = object_value(raw.get('identity')) + if integer(identity.get('id'), 1) != self.actor_id or identity.get('login') != 'gitea-impl-agent': + raise ReadbackError('snapshot actor mismatch') + if object_value(raw.get('version')).get('version') != self.provider['version']: + raise ReadbackError('snapshot provider version mismatch') + prefix = '/repos/'+target['owner']+'/'+target['repository'] + for name, endpoint in {'source_comments':prefix+'/issues/'+str(target['issue'])+'/comments', + 'comments':prefix+'/issues/'+str(target['pr'])+'/comments', + 'reviews':prefix+'/pulls/'+str(target['pr'])+'/reviews'}.items(): + self.validate_collection(raw.get(name), endpoint, reviews=name == 'reviews') + inline = object_value(raw.get('inline')) + if set(inline) != {str(row['id']) for row in raw['reviews']['rows']}: + raise ReadbackError('incomplete review comment endpoints') + fallback = False + for rid, collected in inline.items(): + fallback |= self.validate_collection(collected, prefix+'/pulls/'+str(target['pr'])+ + '/reviews/'+rid+'/comments', allow_missing=self.provider['inline']['mode'] == 'per_review_or_pr_count') + self.bind(target, raw) + target_review = next(row for row in raw['reviews']['rows'] if row['id'] == target['review']) + if snapshot.get('review_body_sha256') != hashlib.sha256(target_review['body'].encode()).hexdigest(): + raise ReadbackError('review body digest mismatch') + for name in ('source_issue','pr','pr_issue'): + timestamp(raw[name].get('updated_at')) + if (type(raw[name].get('body')) is not str or type(raw[name].get('title')) is not str + or raw[name].get('state') not in ('open','closed')): + raise ReadbackError('incomplete Issue or PR metadata') + counts = self.counts(raw) + if (digest(snapshot.get('normalized_counts')) != digest(counts) + or snapshot.get('fallback') != ('counts_only' if fallback else 'none')): + raise ReadbackError('snapshot count or capability mismatch') + return raw + + def normalized_inputs(self, expected, baseline, after, action): + if action not in ('create','reuse'): raise ReadbackError('invalid rework action') + b, a = [self.validate_snapshot(expected, value) for value in (baseline, after)] + target = self.target(expected) + if digest(b['repository']) != digest(a['repository']): raise ReadbackError('repository drift') + for name in ('source_comments','reviews','inline'): + if digest(b[name]) != digest(a[name]): raise ReadbackError('source or review collection drift') + old = {row['id']:row for row in b['comments']['rows']} + current = {row['id']:row for row in a['comments']['rows']} + for ident, row in old.items(): + if ident not in current or digest(row) != digest(current[ident]): + raise ReadbackError('existing comment changed or missing') + added = [row for ident, row in current.items() if ident not in old] + if len(added) != (1 if action == 'create' else 0): raise ReadbackError('comment delta cardinality') + matches = [row for row in current.values() if expected['correlation'] in row['body']] + if len(matches) != 1: raise ReadbackError('comment correlation cardinality') + matched = matches[0] + if (matched['body'] != expected['body'] or matched['user']['id'] != self.actor_id + or matched['user']['login'] != 'gitea-impl-agent' + or matched.get('html_url') != a['pr']['html_url']+'#issuecomment-'+str(matched['id'])): + raise ReadbackError('canonical comment binding mismatch') + if action == 'create': + if matched['id'] != added[0]['id'] or matched['updated_at'] != matched['created_at']: + raise ReadbackError('created comment causality mismatch') + for name in ('pr','pr_issue','source_issue'): + if timestamp(matched['created_at']) < timestamp(b[name]['updated_at']): + raise ReadbackError('created comment predates baseline') + for name in ('source_issue','pr','pr_issue'): + before, now = copy.deepcopy(b[name]), copy.deepcopy(a[name]) + if name == 'source_issue': + if digest(before) != digest(now): + raise ReadbackError('source Issue metadata drift') + else: + field = self.provider['aggregate'][name]['field'] + if integer(now.pop(field)) != integer(before.pop(field)) + (1 if action == 'create' else 0): + raise ReadbackError('PR counter delta causality mismatch') + if name != 'source_issue' and action == 'create' and now['updated_at'] != before['updated_at']: + if now['updated_at'] != matched['created_at']: + raise ReadbackError('uncausal Issue or PR timestamp') + now['updated_at'] = before['updated_at'] + if digest(before) != digest(now): raise ReadbackError('unrelated Issue or PR metadata drift') + counts = self.counts(a) + pr = dict(a['pr'], object_kind='pull_request', owner=target['owner'], + repository=target['repository'], metadata_binding_verified=True) + components = self.provider['aggregate']['pr']['components'] + provider_counts = dict(counts, aggregate_components=components, + aggregate_comments=sum(counts[name] for name in components)) + comments = {'object_kind':'pull_request_top_level_comments','owner':target['owner'], + 'repository':target['repository'],'pr':target['pr'], + 'complete':True,'endpoint_binding_verified':True, + 'source_issue_unchanged':True,'review_collection_unchanged':True, + 'pr_metadata_unchanged':True,'comments':a['comments']['rows'], + 'total_count':len(a['comments']['rows']), 'page_count':len(a['comments']['pages']), + 'provider_counts':provider_counts} + identity = dict(a['identity'], role='gitea-impl-agent', authenticated=True, + stable_account_verified=True) + return {'pr':pr,'comments':comments,'identity':identity, + 'baseline_count':len(b['comments']['rows'])} diff --git a/harness/agents/impl/skills/gitea-issue-rework/scripts/rework_readback_cli.py b/harness/agents/impl/skills/gitea-issue-rework/scripts/rework_readback_cli.py new file mode 100644 index 0000000..b336061 --- /dev/null +++ b/harness/agents/impl/skills/gitea-issue-rework/scripts/rework_readback_cli.py @@ -0,0 +1,318 @@ +"""Role-local, read-only transport and CLI bridge for rework readback.""" +import hashlib +import json +import os +from pathlib import Path +import re +import secrets +import ssl +import stat +import time +import urllib.error +import urllib.parse +import urllib.request + +ORIGIN = 'https://gitea.jsfamily.quest' + + +def _open_parent(path): + path = Path(path) + raw = os.fspath(path) + if not raw.startswith('/') or any(part in ('', '.', '..') for part in raw.split('/')[1:]): + raise ValueError('absolute canonical path required') + flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC + fd = os.open('/', flags) + try: + for part in path.parts[1:-1]: + child = os.open(part, flags, dir_fd=fd) + os.close(fd) + fd = child + return fd, path.name + except BaseException: + os.close(fd) + raise + + +def load_role_token(home): + """Read only the caller's private profile file; never ambient token fallback.""" + try: + parent, name = _open_parent(Path(home) / '.env') + try: + fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=parent) + try: + metadata = os.fstat(fd) + if (not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != os.getuid() + or stat.S_IMODE(metadata.st_mode) != 0o600 or metadata.st_nlink != 1): + raise ValueError('unsafe role credential file') + with os.fdopen(fd, 'rb', closefd=False) as stream: + raw = stream.read(65537) + if len(raw) > 65536: + raise ValueError('role credential file too large') + finally: + os.close(fd) + finally: + os.close(parent) + values = [] + for line in raw.decode('utf-8').splitlines(): + key, separator, value = line.partition('=') + if separator and key.strip() == 'GITEA_TOKEN': + value = value.strip() + if len(value) >= 2 and value[0] in ('\"', "'") and value[-1] == value[0]: + value = value[1:-1] + values.append(value) + if (len(values) != 1 or not 1 <= len(values[0]) <= 4096 + or any(ord(char) < 33 or ord(char) > 126 for char in values[0])): + raise ValueError('invalid role credential') + return values[0] + except (OSError, UnicodeError, ValueError): + raise ValueError('private role credential unavailable') from None + + +def decode_json(raw): + def pairs(items): + result = {} + for key, value in items: + if key in result: raise ValueError('duplicate JSON key') + result[key] = value + return result + def invalid_constant(value): + raise ValueError('invalid JSON number') + try: + return json.loads(raw, object_pairs_hook=pairs, parse_constant=invalid_constant) + except (ValueError, UnicodeError): + raise ValueError('invalid readback JSON') from None + + +def response_headers(headers): + values, seen = {}, set() + for key, value in headers.items(): + if type(key) is not str or type(value) is not str or key.lower() in seen: + raise ValueError('invalid or duplicate response header') + seen.add(key.lower()) + values[key] = value + return values + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +class RestTransport: + """GET-only fixed-origin transport; opener injection is a test seam.""" + def __init__(self, token, *, opener=None): + if (type(token) is not str or not 1 <= len(token) <= 4096 + or any(ord(c) < 33 or ord(c) > 126 for c in token)): + raise ValueError('invalid role credential') + self._credential = token + self._deadline = time.monotonic() + 120 + self._opener = opener or urllib.request.build_opener( + NoRedirect(), urllib.request.HTTPSHandler(context=ssl.create_default_context())) + + def __call__(self, path): + try: + parts = urllib.parse.urlsplit(path) + if (type(path) is not str or not path.startswith('/') or path.startswith('//') + or parts.scheme or parts.netloc or parts.fragment + or any(c in path for c in ('\r', '\n', '\\')) + or any(p in ('.', '..') for p in urllib.parse.unquote(parts.path).split('/')) + or self._credential in path): + raise ValueError('invalid readback endpoint') + url = ORIGIN + '/api/v1' + path + request = urllib.request.Request(url, method='GET', headers={ + 'Authorization': 'token ' + self._credential, + 'Accept': 'application/json', 'Cache-Control': 'no-cache'}) + started = time.monotonic() + remaining = self._deadline - started + if remaining <= 0: + raise ValueError('readback operation budget exceeded') + try: + response = self._opener.open(request, timeout=min(10, remaining)) + except urllib.error.HTTPError as error: + try: return None, response_headers(error.headers), error.code + finally: error.close() + with response: + if response.geturl() != url: + raise ValueError('redirected readback response') + status = response.status + headers = response_headers(response.headers) + if status != 200: + return None, headers, status + content_type = response.headers.get('Content-Type', '').split(';')[0].strip().lower() + if content_type != 'application/json': + raise ValueError('non-JSON readback response') + chunks, size = [], 0 + while True: + if time.monotonic() - started > 30 or time.monotonic() >= self._deadline: + raise ValueError('readback response budget exceeded') + chunk = response.read1(min(65536, 1_000_001 - size)) + if not chunk: break + chunks.append(chunk) + size += len(chunk) + if size > 1_000_000: + raise ValueError('readback response too large') + raw = b''.join(chunks) + if self._credential.encode() in raw: + raise ValueError('credential echoed in readback') + data = decode_json(raw) + if self._credential in json.dumps(data, ensure_ascii=False): + raise ValueError('credential echoed in readback') + return data, headers, status + except Exception: + raise ValueError('authenticated readback transport failed') from None + + +def read_bound_json(path, expected_sha256=None): + if expected_sha256 is not None and not re.fullmatch(r'[0-9a-f]{64}', expected_sha256): + raise ValueError('invalid independently expected SHA-256') + parent, name = _open_parent(path) + try: + fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=parent) + try: + metadata = os.fstat(fd) + if (not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1 + or metadata.st_mode & 0o022 or metadata.st_uid != os.getuid()): + raise ValueError('unsafe readback input') + with os.fdopen(fd, 'rb', closefd=False) as stream: + raw = stream.read(16_000_001) + if len(raw) > 16_000_000: + raise ValueError('readback input too large') + finally: + os.close(fd) + finally: + os.close(parent) + if expected_sha256 is not None and hashlib.sha256(raw).hexdigest() != expected_sha256: + raise ValueError('readback input fingerprint mismatch') + value = decode_json(raw) + if type(value) is not dict: + raise ValueError('readback object required') + return value + + +class OutputFile: + """Pin a private output directory, and replace only after successful verification.""" + def __init__(self, path): + self.path = Path(path) + self.fd, self.name = _open_parent(self.path) + try: + parent = os.fstat(self.fd) + if parent.st_uid != os.getuid() or stat.S_IMODE(parent.st_mode) != 0o700: + raise ValueError('private output directory required') + self.parent_identity = (parent.st_dev, parent.st_ino) + self.original = self.identity() + self.done = False + except BaseException: + os.close(self.fd) + raise + + def identity(self): + try: metadata = os.stat(self.name, dir_fd=self.fd, follow_symlinks=False) + except FileNotFoundError: return None + if (not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != os.getuid() + or stat.S_IMODE(metadata.st_mode) != 0o600 or metadata.st_nlink != 1): + raise ValueError('unsafe output target') + return metadata.st_dev, metadata.st_ino + + def check_parent(self): + fd, _ = _open_parent(self.path) + try: + metadata = os.fstat(fd) + if ((metadata.st_dev, metadata.st_ino) != self.parent_identity + or metadata.st_uid != os.getuid() or stat.S_IMODE(metadata.st_mode) != 0o700): + raise ValueError('output directory drift') + finally: + os.close(fd) + + def write(self, value): + raw = (json.dumps(value, ensure_ascii=False, sort_keys=True, allow_nan=False) + '\n').encode() + if len(raw) > 16_000_000 or self.done: + raise ValueError('invalid output size or repeated write') + self.check_parent() + if self.identity() != self.original: + raise ValueError('output target drift') + temporary = '.readback-' + secrets.token_hex(16) + fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o600, dir_fd=self.fd) + try: + os.fchmod(fd, 0o600) + with os.fdopen(fd, 'wb', closefd=False) as stream: + stream.write(raw) + stream.flush() + os.fsync(fd) + self.check_parent() + if self.identity() != self.original: + raise ValueError('output target drift') + os.replace(temporary, self.name, src_dir_fd=self.fd, dst_dir_fd=self.fd) + self.done = True + os.fsync(self.fd) + self.check_parent() + finally: + os.close(fd) + try: os.unlink(temporary, dir_fd=self.fd) + except FileNotFoundError: pass + return hashlib.sha256(raw).hexdigest() + + def __enter__(self): return self + def __exit__(self, *args): os.close(self.fd) + + +def run_live(args, verifier): + """Capture a baseline or feed computed fresh readbacks into the existing verifier.""" + try: + input_paths = [Path(args.expected), Path(args.provider_contract)] + if args.cmd == 'verify-live': input_paths.append(Path(args.baseline)) + if Path(args.output) in input_paths: + raise ValueError('output aliases protected input') + with OutputFile(args.output) as output: + expected = read_bound_json(args.expected) + contract = read_bound_json(args.provider_contract, args.provider_contract_sha256) + expected_digest = hashlib.sha256(json.dumps( + expected, sort_keys=True, ensure_ascii=False, allow_nan=False).encode()).hexdigest() + baseline = None + if args.cmd == 'verify-live': + bundle = read_bound_json(args.baseline, args.baseline_sha256) + if (bundle.get('schema') != 'gitea-agent.rework-baseline/v1' + or bundle.get('expected_sha256') != expected_digest + or bundle.get('provider_contract_sha256') != args.provider_contract_sha256 + or type(bundle.get('snapshot')) is not dict): + raise ValueError('baseline binding mismatch') + baseline = bundle['snapshot'] + home = os.environ.get('HERMES_HOME') + if not home: + raise ValueError('role profile not bound') + credential = load_role_token(home) + transport = RestTransport(credential) + import importlib.util + path = Path(__file__).with_name('rework_readback.py') + spec = importlib.util.spec_from_file_location('gitea_impl_readback_core', path) + if spec is None or spec.loader is None: + raise ValueError('canonical readback core unavailable') + module = importlib.util.module_from_spec(spec) + import sys + sys.modules[spec.name] = module + spec.loader.exec_module(module) + adapter = module.ReworkReadbackAdapter(transport, origin=ORIGIN + '/api/v1', + credential_source='$HERMES_HOME/.env:GITEA_TOKEN', + actor_id=args.actor_id, provider_contract=contract) + after = adapter.snapshot(expected) + if args.cmd == 'snapshot': + result = {'schema': 'gitea-agent.rework-baseline/v1', + 'expected_sha256': expected_digest, + 'provider_contract_sha256': args.provider_contract_sha256, + 'snapshot': after} + else: + inputs = adapter.normalized_inputs(expected, baseline, after, args.action) + result = verifier(expected, inputs['pr'], inputs['comments'], inputs['identity'], + action=args.action, baseline_count=inputs['baseline_count']) + result['readback_provenance'] = {'kind': 'fresh_authenticated_GET', + 'origin': ORIGIN, 'baseline_sha256': args.baseline_sha256, + 'provider_contract_sha256': args.provider_contract_sha256, + 'snapshot_sha256': hashlib.sha256(json.dumps(after, sort_keys=True, + ensure_ascii=False, allow_nan=False).encode()).hexdigest(), + 'limitation': 'state-delta/exact-timestamp admission; not proof of causal event attestation'} + if credential in json.dumps(result, ensure_ascii=False): + raise ValueError('credential material in readback artifact') + digest = output.write(result) + print(json.dumps({'output': str(args.output), 'sha256': digest, 'read_only': True})) + except Exception: + raise ValueError('live readback failed; output not verified') from None diff --git a/harness/tests/README.md b/harness/tests/README.md index 57daa2e..540ae46 100644 --- a/harness/tests/README.md +++ b/harness/tests/README.md @@ -29,6 +29,18 @@ reject them merely because the conventional Unix-domain path encoding exceeds foreign/replaced paths, close every client and directory descriptor, and retain unauthenticated-token rejection behavior. +Rework readback contract: the impl role's canonical `rework_comment.py snapshot` +and `verify-live` commands collect authenticated GET-only provider state through +`rework_readback.py` and `rework_readback_cli.py`. Focused coverage lives in +`harness/tests/impl/test_rework_readback.py` and +`harness/tests/impl/test_rework_readback_transport.py`; run them with the existing +`harness/tests/impl/test_rework_comment.py` regressions. They exercise real +renderer/verifier integration and a local HTTP fixture, not live Gitea access. +Provider/baseline fingerprints, exact identity/endpoints, complete pagination, +create/reuse deltas, metadata causality and failed-receipt preservation are +fail-closed. The workflow contract and canonical runtime artifact paths are in +`harness/agents/impl/skills/gitea-issue-rework/references/readback.md`. + It validates all `test*.py` files recursively under exactly these required roles: `common`, `spec`, `impl`, and `review`. `harness/tests`, every role, and every nested test package must have an `__init__.py`; every role and every diff --git a/harness/tests/impl/test_rework_readback.py b/harness/tests/impl/test_rework_readback.py new file mode 100644 index 0000000..0e0a6f0 --- /dev/null +++ b/harness/tests/impl/test_rework_readback.py @@ -0,0 +1,477 @@ +import copy +import hashlib +import importlib.util +import json +import tempfile +import unittest +from argparse import Namespace +from pathlib import Path +from urllib.parse import parse_qs, urlsplit + +DIRECTORY = Path(__file__).parents[2] / 'agents/impl/skills/gitea-issue-rework/scripts' +ORIGIN = 'https://gitea.jsfamily.quest' +T0 = '2026-01-01T00:00:00Z' +T1 = '2026-01-02T00:00:00Z' + + +class ReworkReadbackTests(unittest.TestCase): + def load(self, name): + path = DIRECTORY / (name + '.py') + self.assertTrue(path.is_file(), 'canonical readback core missing') + spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + def setUp(self): + self.legacy = self.load('rework_comment') + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + root = Path(self.temp.name) + data = {'owner':'acme','repository':'demo','issue':7,'pr':9,'review':301, + 'reviewed_head':'a'*40,'final_head':'b'*40,'contract_sha256':'c'*64, + 'result':'partial','findings':[{'id':'F-1','status':'resolved','classification':'valid_fix', + 'changes':'bounded fix','verification':'local checks pass','reason':'regression'}], + 'local_checks':'pass','runtime_browser':'not applicable', + 'commit_status':'success','actions_runs':'없음'} + (root/'input.json').write_text(json.dumps(data)) + self.legacy.do_render(Namespace(input=str(root/'input.json'), no_delegated_evidence=True, + delegation_adoption=None, comment=str(root/'comment.md'), expected=str(root/'expected.json'))) + self.expected = json.loads((root/'expected.json').read_text()) + self.contract = {'schema':'gitea-agent.rework-provider/v1','version':'1.24.6', + 'aggregate':{'pr':{'field':'comments','components':['top_level','reviews','inline']}, + 'pr_issue':{'field':'comments','components':['top_level','reviews','inline']}}, + 'inline':{'mode':'per_review','field':'review_comments','components':['reviews','inline']}} + self.repo = {'id':3,'full_name':'acme/demo','html_url':ORIGIN+'/acme/demo'} + self.source = {'id':707,'number':7,'html_url':ORIGIN+'/acme/demo/issues/7', + 'body':'source contract','title':'Source','state':'open','comments':0,'updated_at':T0} + self.pr = {'id':99,'number':9,'html_url':ORIGIN+'/acme/demo/pulls/9', + 'url':ORIGIN+'/acme/demo/pulls/9','state':'open', + 'head':{'sha':'b'*40,'repo':{'id':12,'full_name':'contributor/demo'}}, + 'base':{'repo':copy.deepcopy(self.repo)},'title':'PR','body':'Closes #7', + 'comments':1,'review_comments':1,'updated_at':T0} + self.underlying = {'id':99,'number':9,'html_url':self.pr['html_url'], + 'pull_request':{'html_url':self.pr['html_url'],'merged':False}, + 'title':'PR','body':'Closes #7','state':'open','comments':1,'updated_at':T0} + self.reviews = [{'id':301,'commit_id':'a'*40,'comments_count':0,'state':'REQUEST_CHANGES', + 'user':{'id':33,'login':'gitea-review-agent'}}] + self.comments = [] + self.source_comments = [] + self.inline = [] + self.overrides = {} + self.calls = [] + for record in (self.source, self.pr, self.underlying): + record.update(created_at=T0, labels=[], assignees=[], milestone=None, + user={'id':22,'login':'genichin'} if record is self.source + else {'id':42,'login':'gitea-impl-agent'}) + self.pr['base'].update(ref='main', label='main', sha='d'*40, repo_id=3) + self.pr['head'].update(ref='fix/issue-7', label='contributor:fix/issue-7', repo_id=12) + self.reviews[0].update(body='Fix F-1', submitted_at=T0, updated_at=T0, + stale=True, dismissed=False, html_url=self.pr['html_url']+'#pullreview-301', + pull_request_url=self.pr['html_url']) + + def request(self, path): + self.calls.append(path) + if path in self.overrides: return copy.deepcopy(self.overrides[path]) + parsed = urlsplit(path) + objects = {'/user':{'id':42,'login':'gitea-impl-agent'}, '/version':{'version':'1.24.6'}, + '/repos/acme/demo':self.repo, '/repos/acme/demo/issues/7':self.source, + '/repos/acme/demo/pulls/9':self.pr, '/repos/acme/demo/issues/9':self.underlying} + if parsed.path in objects: return copy.deepcopy(objects[parsed.path]), {}, 200 + collections = {'/repos/acme/demo/issues/7/comments':self.source_comments, + '/repos/acme/demo/issues/9/comments':self.comments, + '/repos/acme/demo/pulls/9/reviews':self.reviews, + '/repos/acme/demo/pulls/9/reviews/301/comments':self.inline} + if parsed.path not in collections: raise AssertionError('unexpected GET endpoint') + rows = collections[parsed.path] + page = int(parse_qs(parsed.query)['page'][0]) + return copy.deepcopy(rows[(page-1)*50:page*50]), {'X-Total-Count':str(len(rows))}, 200 + + def adapter(self): + module = self.load('rework_readback') + try: + return module.ReworkReadbackAdapter(self.request, origin=ORIGIN+'/api/v1', + credential_source='$HERMES_HOME/.env:GITEA_TOKEN', actor_id=42, + provider_contract=self.contract) + except Exception as error: + self.fail('canonical CLI transport contract rejected: '+str(error)) + + def add_result(self): + self.comments.append({'id':501,'html_url':self.pr['html_url']+'#issuecomment-501', + 'body':self.expected['body'],'created_at':T1,'updated_at':T1, + 'user':{'id':42,'login':'gitea-impl-agent'}}) + self.pr['comments'] += 1 + self.underlying['comments'] += 1 + self.pr['updated_at'] = self.underlying['updated_at'] = T1 + + def test_snapshot_rejects_wrong_provider_bindings(self): + cases = [('repo',['id'],True), ('repo',['full_name'],'elsewhere/demo'), + ('repo',['html_url'],'https://foreign.invalid/acme/demo'), + ('source',['number'],8), ('source',['id'],True), + ('source',['html_url'],ORIGIN+'/elsewhere/demo/issues/7'), + ('source',['pull_request'],{}), ('pr',['number'],10), ('pr',['id'],True), + ('pr',['url'],'https://foreign.invalid/api/v1/repos/acme/demo/pulls/9'), + ('pr',['html_url'],ORIGIN+'/elsewhere/demo/pulls/9'), + ('pr',['head','sha'],'c'*40), ('pr',['base','repo','id'],4), + ('pr',['state'],'closed'), ('underlying',['number'],10), + ('underlying',['pull_request'],True), ('reviews',[0,'commit_id'],'c'*40)] + error = self.load('rework_readback').ReadbackError + # Each load has a distinct exception class; assert ValueError at the public boundary. + self.assertTrue(issubclass(error, ValueError)) + for attribute, keys, value in cases: + with self.subTest(attribute=attribute, keys=keys): + original = copy.deepcopy(getattr(self, attribute)) + item = getattr(self, attribute) + for key in keys[:-1]: item = item[key] + item[keys[-1]] = value + try: + with self.assertRaises(ValueError): self.adapter().snapshot(self.expected) + finally: setattr(self, attribute, original) + + def test_pagination_complete_51_and_rejects_bad_pages(self): + self.comments = [{'id':600+i,'body':'existing '+str(i),'created_at':T0,'updated_at':T0, + 'user':{'id':8,'login':'other'}} for i in range(51)] + self.pr['comments'] = self.underlying['comments'] = 52 + snapshot = self.adapter().snapshot(self.expected) + self.assertEqual(snapshot['normalized_counts']['top_level'], 51) + self.assertEqual(len(snapshot['raw']['comments']['pages']), 2) + first = '/repos/acme/demo/issues/9/comments?page=1&limit=50' + second = '/repos/acme/demo/issues/9/comments?page=2&limit=50' + invalid = [(second,[self.comments[0]],{'X-Total-Count':'51'},200), + (second,[self.comments[-1]],{'X-Total-Count':'52'},200), + (first,None,{},200), (first,[],{'X-Total-Count':'0','x-total-count':'0'},200), + (first,[],{'X-Total-Count':'0','Link':'; rel="next"'},200), + (first,self.comments[:50],{},200), + (first,[{'id':True}],{'X-Total-Count':'1'},200), + (first,[],{'X-Total-Count':'0','Link':'not a Link'},200), + (first,[],{'X-Total-Count':'0'},True), (first,[],{},403), (first,[],{},302)] + for path, rows, headers, status in invalid: + with self.subTest(path=path, headers=headers, status=status): + self.overrides = {path:(rows,headers,status)} + with self.assertRaises(ValueError): self.adapter().snapshot(self.expected) + self.overrides = {} + + def test_counts_only_inline_fallback_is_explicit_and_crosschecked(self): + self.contract['inline']['mode'] = 'per_review_or_pr_count' + self.reviews[0]['comments_count'] = 3 + self.pr['comments'] = self.underlying['comments'] = self.pr['review_comments'] = 4 + endpoint = '/repos/acme/demo/pulls/9/reviews/301/comments?page=1&limit=50' + self.overrides = {endpoint:(None,{},404)} + try: snapshot = self.adapter().snapshot(self.expected) + except ValueError as error: self.fail('configured counts-only fallback missing: '+str(error)) + self.assertEqual(snapshot['normalized_counts']['inline'], 3) + self.assertEqual(snapshot['fallback'], 'counts_only') + self.assertEqual(snapshot['raw']['inline']['301']['rows'], []) + self.pr['review_comments'] = 5 + with self.assertRaises(ValueError): self.adapter().snapshot(self.expected) + self.pr['review_comments'] = 4 + for status, payload in ((403,None),(302,None),(200,None)): + self.overrides = {endpoint:(payload,{},status)} + with self.subTest(status=status): + with self.assertRaises(ValueError): self.adapter().snapshot(self.expected) + self.contract['inline']['mode'] = 'per_review' + self.overrides = {endpoint:(None,{},404)} + with self.assertRaises(ValueError): self.adapter().snapshot(self.expected) + + def test_preflight_rejects_unbound_contract_and_target_before_GET(self): + module = self.load('rework_readback') + changes = [(['schema'],'unsupported'), (['version'],True), + (['aggregate','pr','components'],['inline']), + (['aggregate','pr','components'],['top_level','top_level']), + (['aggregate','pr_issue','field'],'other'), + (['inline','mode'],'automatic'), (['inline','components'],['other'])] + for keys, value in changes: + contract = copy.deepcopy(self.contract) + item = contract + for key in keys[:-1]: item = item[key] + item[keys[-1]] = value + with self.subTest(contract=keys): + with self.assertRaises(ValueError): + module.ReworkReadbackAdapter(self.request, origin=ORIGIN+'/api/v1', + credential_source='$HERMES_HOME/.env:GITEA_TOKEN', actor_id=42, + provider_contract=contract) + self.assertEqual(self.calls, []) + for keys, value in ((['target','owner'],'acme/../elsewhere'), + (['target','issue'],9), (['review'],True), (['head'],'B'*40), + (['body'],'forged body')): + expected = copy.deepcopy(self.expected) + item = expected + for key in keys[:-1]: item = item[key] + item[keys[-1]] = value + self.calls = [] + with self.subTest(expected=keys): + with self.assertRaises(ValueError): self.adapter().snapshot(expected) + self.assertEqual(self.calls, [], 'invalid expectation reached provider') + + def test_snapshot_detects_drift_during_collection(self): + original = self.request + endpoints = ['/user','/version','/repos/acme/demo','/repos/acme/demo/issues/7', + '/repos/acme/demo/pulls/9','/repos/acme/demo/issues/9', + '/repos/acme/demo/pulls/9/reviews'] + for endpoint in endpoints: + counts = {} + def changing(path): + value, headers, status = original(path) + key = urlsplit(path).path + counts[key] = counts.get(key, 0) + 1 + if key == endpoint and counts[key] > 1: + if type(value) is list: value[0]['state'] = 'APPROVED' + else: value['unexpected_drift'] = True + return value, headers, status + self.request = changing + try: + with self.subTest(endpoint=endpoint): + with self.assertRaisesRegex(ValueError, 'drift'): + self.adapter().snapshot(self.expected) + finally: self.request = original + + def test_normalization_rejects_unrelated_delta_and_forged_pages(self): + adapter = self.adapter() + baseline = adapter.snapshot(self.expected) + self.add_result() + after = adapter.snapshot(self.expected) + # Prove this exact clean fixture is admitted before adversarial mutation. + adapter.normalized_inputs(self.expected, baseline, after, 'create') + cases = [(['schema'],'forged'), (['actor_id'],True), (['origin'],'https://foreign.invalid/api/v1'), + (['target','owner'],'other'), (['provider','version'],'different'), + (['expected_sha256'],'0'*64), (['raw','identity','login'],'other'), + (['raw','source_issue','body'],'unrelated edit'), + (['raw','reviews','rows',0,'state'],'APPROVED'), + (['raw','pr','head','sha'],'c'*40), (['raw','pr','title'],'unrelated edit'), + (['raw','pr','updated_at'],'2026-01-03T00:00:00Z'), + (['raw','comments','pages',0,'path'],'/repos/elsewhere/demo/issues/9/comments?page=1&limit=50'), + (['raw','comments','pages',0,'count'],0), + (['raw','comments','rows',0,'body'],'forged body'), + (['raw','comments','rows',0,'user','id'],8), + (['raw','comments','rows',0,'created_at'],'2025-01-01T00:00:00Z'), + (['raw','comments','rows',0,'updated_at'],'2026-01-03T00:00:00Z')] + for keys, value in cases: + changed = copy.deepcopy(after) + item = changed + for key in keys[:-1]: item = item[key] + item[keys[-1]] = value + with self.subTest(keys=keys): + with self.assertRaises(ValueError): + adapter.normalized_inputs(self.expected, baseline, changed, 'create') + with self.assertRaises(ValueError): adapter.normalized_inputs(self.expected, baseline, after, 'reuse') + + def test_existing_comment_rows_are_immutable(self): + self.comments = [{'id':400,'body':'prior comment','created_at':T0,'updated_at':T0, + 'user':{'id':8,'login':'other'}}] + self.pr['comments'] = self.underlying['comments'] = 2 + adapter = self.adapter() + baseline = adapter.snapshot(self.expected) + self.add_result() + after = adapter.snapshot(self.expected) + adapter.normalized_inputs(self.expected, baseline, after, 'create') + after['raw']['comments']['rows'][0]['body'] = 'changed prior comment' + with self.assertRaisesRegex(ValueError, 'existing comment'): + adapter.normalized_inputs(self.expected, baseline, after, 'create') + + def test_snapshot_validates_captured_rows_before_persisting(self): + self.add_result() + self.adapter().snapshot(self.expected) + for keys, value in ((['body'],True), (['user','id'],True), + (['created_at'],'2026-01-02T00:00:00'), + (['updated_at'],T0)): + saved = copy.deepcopy(self.comments) + item = self.comments[0] + for key in keys[:-1]: item = item[key] + item[keys[-1]] = value + with self.subTest(keys=keys): + with self.assertRaises(ValueError): self.adapter().snapshot(self.expected) + self.comments = saved + self.source['updated_at'] = 'not a timestamp' + with self.assertRaises(ValueError): self.adapter().snapshot(self.expected) + + def test_live_bridge_round_trip_over_local_HTTP(self): + import contextlib + import http.server + import io + import os + import threading + import urllib.request + from unittest.mock import patch + cli = self.load('rework_readback_cli') + root = Path(self.temp.name) + profile = root/'profile' + profile.mkdir(mode=0o700) + secret = 'synthetic-fixture-' + 'q'*40 + (profile/'.env').write_text('GITEA_TOKEN='+secret+'\n') + (profile/'.env').chmod(0o600) + (root/'provider.json').write_text(json.dumps(self.contract)) + contract_hash = hashlib.sha256((root/'provider.json').read_bytes()).hexdigest() + requests = [] + fixture = self + class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, format, *args): pass + def do_GET(self): + requests.append((self.command, self.headers.get('Authorization') == 'token '+secret)) + data, headers, status = fixture.request(self.path[len('/api/v1'):]) + self.send_response(status) + self.send_header('Content-Type','application/json') + for key, value in headers.items(): self.send_header(key,value) + self.end_headers() + self.wfile.write(json.dumps(data).encode()) + server = http.server.ThreadingHTTPServer(('127.0.0.1',0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + inner = urllib.request.build_opener(cli.NoRedirect()) + class Proxy: + def open(self, request, timeout): + forwarded = urllib.request.Request('http://127.0.0.1:'+str(server.server_port)+request.selector, + headers=dict(request.header_items()), method=request.get_method()) + response = inner.open(forwarded, timeout=timeout) + class Response: + status = response.status + headers = response.headers + def geturl(self): return request.full_url + def read1(self, n): return response.read1(n) + def __enter__(self): return self + def __exit__(self, *args): response.close() + return Response() + transport = cli.RestTransport + common = dict(expected=str(root/'expected.json'), provider_contract=str(root/'provider.json'), + provider_contract_sha256=contract_hash, actor_id=42) + baseline_path, receipt_path = root/'baseline.json', root/'receipt.json' + try: + with patch.dict(os.environ, {'HERMES_HOME':str(profile)}), patch.object(cli,'RestTransport', + side_effect=lambda credential: transport(credential, opener=Proxy())), contextlib.redirect_stdout(io.StringIO()) as stdout: + cli.run_live(Namespace(cmd='snapshot', output=str(baseline_path), **common), self.legacy.verify_readbacks) + baseline_hash = hashlib.sha256(baseline_path.read_bytes()).hexdigest() + self.add_result() + args = Namespace(cmd='verify-live', baseline=str(baseline_path), baseline_sha256=baseline_hash, + action='create', output=str(receipt_path), **common) + cli.run_live(args, self.legacy.verify_readbacks) + receipt = json.loads(receipt_path.read_text()) + self.assertEqual(receipt['status'],'verified') + self.assertEqual(receipt['comment_id'],501) + self.assertEqual(receipt['readback_provenance']['kind'],'fresh_authenticated_GET') + original = receipt_path.read_bytes() + self.source['title'] = 'unrelated provider edit' + with self.assertRaises(ValueError): cli.run_live(args, self.legacy.verify_readbacks) + self.assertEqual(receipt_path.read_bytes(), original) + self.assertNotIn(secret, stdout.getvalue()) + self.assertNotIn(secret, receipt_path.read_text()) + self.assertTrue(requests) + self.assertEqual(set(method for method, _ in requests), {'GET'}) + self.assertTrue(all(authenticated for _, authenticated in requests)) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=3) + + def test_underlying_relation_requires_native_html_url(self): + self.adapter().snapshot(self.expected) + self.underlying['pull_request'].pop('html_url') + with self.assertRaises(ValueError): self.adapter().snapshot(self.expected) + + def test_non_next_Link_relations_are_coherent_and_preserved(self): + endpoint = '/repos/acme/demo/issues/7/comments' + path = endpoint+'?page=1&limit=50' + for relation, page in (('last',21), ('prev',1), ('first',2)): + self.overrides = {path:([],{'X-Total-Count':'0','Link': + '; rel="'+relation+'"'},200)} + with self.subTest(relation=relation): + with self.assertRaises(ValueError): self.adapter().snapshot(self.expected) + header = '; rel="last"' + self.overrides = {path:([],{'X-Total-Count':'0','Link':header},200)} + snapshot = self.adapter().snapshot(self.expected) + self.assertEqual(snapshot['raw']['source_comments']['pages'][0].get('link'), header) + + def test_pagination_link_rejects_unbalanced_relation_quotes(self): + endpoint = '/repos/acme/demo/issues/7/comments' + path = endpoint+'?page=1&limit=50' + for relation in ('"last', 'last"'): + self.overrides = {path:([],{'X-Total-Count':'0','Link': + '; rel='+relation},200)} + with self.subTest(relation=relation): + with self.assertRaises(ValueError): self.adapter().snapshot(self.expected) + + def test_native_Gitea_PR_URL_and_omitted_proven_zero_inline_count(self): + # Pinned primary serializers: services/convert/pull.go and modules/structs/pull.go (v1.24.6). + self.pr['url'] = self.pr['html_url'] + self.contract['inline']['components'] = ['inline'] + self.pr.pop('review_comments') + try: snapshot = self.adapter().snapshot(self.expected) + except ValueError as error: self.fail('native Gitea wire shape rejected: '+str(error)) + self.assertEqual(snapshot['normalized_counts']['inline'], 0) + self.assertEqual(snapshot['fallback'], 'none') + self.contract['inline']['mode'] = 'per_review_or_pr_count' + self.overrides = {'/repos/acme/demo/pulls/9/reviews/301/comments?page=1&limit=50':(None,{},404)} + with self.assertRaises(ValueError): self.adapter().snapshot(self.expected) + + def test_boolean_actor_is_rejected_before_other_GETs(self): + module = self.load('rework_readback') + self.overrides = {'/user':({'id':True,'login':'gitea-impl-agent'}, {}, 200)} + adapter = module.ReworkReadbackAdapter(self.request, origin=ORIGIN+'/api/v1', + credential_source='$HERMES_HOME/.env:GITEA_TOKEN', actor_id=1, provider_contract=self.contract) + with self.assertRaises(ValueError): adapter.snapshot(self.expected) + self.assertEqual(self.calls, ['/user']) + + def test_required_native_metadata_and_PR_author_are_verified(self): + self.adapter().snapshot(self.expected) + cases = [('pr',['user'],None), ('pr',['user','id'],43), + ('pr',['user','login'],'different'), ('source',['labels'],True), + ('source',['assignees'],True), ('source',['milestone'],[]), + ('pr',['base','ref'],None), ('pr',['head','repo_id'],99), + ('reviews',[0,'state'],None), ('reviews',[0,'body'],None), + ('reviews',[0,'user','login'],'unapproved'), + ('reviews',[0,'submitted_at'],None), ('reviews',[0,'dismissed'],True), + ('reviews',[0,'stale'],'true')] + for name, keys, value in cases: + original = copy.deepcopy(getattr(self,name)) + item = getattr(self,name) + for key in keys[:-1]: item = item[key] + item[keys[-1]] = value + with self.subTest(name=name, keys=keys): + with self.assertRaises(ValueError): self.adapter().snapshot(self.expected) + setattr(self,name,original) + for name, field in (('source','labels'), ('source','assignees'), ('source','milestone'), + ('underlying','user'), ('reviews','submitted_at')): + original = copy.deepcopy(getattr(self,name)) + record = getattr(self,name)[0] if name == 'reviews' else getattr(self,name) + record.pop(field) + with self.subTest(missing=name+'.'+field): + with self.assertRaises(ValueError): self.adapter().snapshot(self.expected) + setattr(self,name,original) + + def test_source_timestamp_change_is_not_admitted_as_causal_proof(self): + adapter = self.adapter() + baseline = adapter.snapshot(self.expected) + self.add_result() + after = adapter.snapshot(self.expected) + adapter.normalized_inputs(self.expected, baseline, after, 'create') + after['raw']['source_issue']['updated_at'] = T1 + with self.assertRaises(ValueError): adapter.normalized_inputs(self.expected, baseline, after, 'create') + + def test_native_nullable_assignees_and_milestone_without_number(self): + for record in (self.source,self.pr,self.underlying): + record['assignees'] = None + record['milestone'] = {'id':55,'title':'Native milestone','state':'open'} + try: self.adapter().snapshot(self.expected) + except ValueError as error: self.fail('native nullable-assignee/milestone shape rejected: '+str(error)) + self.source.pop('assignees') + with self.assertRaises(ValueError): self.adapter().snapshot(self.expected) + + def test_closing_keyword_must_match_canonical_Closes(self): + self.adapter().snapshot(self.expected) + self.pr['body'] = 'Fixes #7' + with self.assertRaises(ValueError): self.adapter().snapshot(self.expected) + + def test_renderer_to_existing_verifier_create_and_reuse(self): + adapter = self.adapter() + try: baseline = adapter.snapshot(self.expected) + except Exception as error: self.fail('actual renderer expected rejected: '+str(error)) + self.assertEqual(self.calls[0], '/user') + self.add_result() + after = adapter.snapshot(self.expected) + inputs = adapter.normalized_inputs(self.expected, baseline, after, 'create') + receipt = self.legacy.verify_readbacks(self.expected, inputs['pr'], inputs['comments'], + inputs['identity'], action='create', baseline_count=inputs['baseline_count']) + self.assertEqual(receipt['status'], 'verified') + self.assertEqual(receipt['comment_id'], 501) + reused = adapter.normalized_inputs(self.expected, after, adapter.snapshot(self.expected), 'reuse') + self.assertEqual(self.legacy.verify_readbacks(self.expected, reused['pr'], reused['comments'], + reused['identity'], action='reuse', baseline_count=reused['baseline_count'])['status'], 'verified') diff --git a/harness/tests/impl/test_rework_readback_transport.py b/harness/tests/impl/test_rework_readback_transport.py new file mode 100644 index 0000000..f412c24 --- /dev/null +++ b/harness/tests/impl/test_rework_readback_transport.py @@ -0,0 +1,194 @@ +import importlib.util +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).parents[2] / 'agents/impl/skills/gitea-issue-rework/scripts/rework_comment.py' + + +class ReworkReadbackTransportTests(unittest.TestCase): + def module(self): + path = SCRIPT.with_name('rework_readback_cli.py') + self.assertTrue(path.is_file(), 'canonical live readback bridge is missing') + spec = importlib.util.spec_from_file_location('transport_under_test', path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + def test_role_token_requires_private_profile_file(self): + module = self.module() + with tempfile.TemporaryDirectory() as directory: + home = Path(directory) + with self.assertRaises(ValueError): module.load_role_token(home) + source = home / '.env' + source.write_text('GITEA_TOKEN=' + 's' * 48 + '\n') + source.chmod(0o644) + with self.assertRaises(ValueError): module.load_role_token(home) + source.chmod(0o600) + self.assertEqual(module.load_role_token(home), 's' * 48) + source.unlink() + external = home / 'external' + external.write_text('GITEA_TOKEN=' + 's' * 48) + external.chmod(0o600) + source.symlink_to(external) + with self.assertRaises(ValueError): module.load_role_token(home) + + def test_http_transport_get_only_redirects_and_secret_echo_are_rejected(self): + module = self.module() + self.assertTrue(callable(getattr(module, 'RestTransport', None)), 'read-only HTTP transport missing') + import http.server + import threading + import urllib.request + requests = [] + secret = 'z' * 48 + + class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, format, *args): pass + def do_GET(self): + requests.append((self.path, self.headers.get('Authorization') == 'token ' + secret)) + if self.path == '/api/v1/version': + self.send_response(302) + self.send_header('Location', 'https://outside.invalid/user') + self.end_headers() + return + body = (b'{"credential":"' + secret.encode() + b'"}' + if self.path.endswith('/secret') else b'{"id":42,"login":"gitea-impl-agent"}') + self.send_response(200) + self.send_header('Content-Type', 'application/json') + if self.path.endswith('/duplicate'): + self.send_header('X-Total-Count', '1') + self.send_header('X-Total-Count', '2') + self.end_headers() + self.wfile.write(body) + + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): return None + + server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + inner = urllib.request.build_opener(NoRedirect()) + + class Proxy: + def open(self, request, timeout): + self_request = urllib.request.Request( + 'http://127.0.0.1:' + str(server.server_port) + request.selector, + headers=dict(request.header_items()), method=request.get_method()) + response = inner.open(self_request, timeout=timeout) + original = request.full_url + class Response: + status = response.status + headers = response.headers + def geturl(self): return original + def read1(self, n): return response.read1(n) + def __enter__(self): return self + def __exit__(self, *args): response.close() + return Response() + + try: + client = module.RestTransport(secret, opener=Proxy()) + data, _, status = client('/user') + self.assertEqual(status, 200) + self.assertEqual(data['id'], 42) + self.assertEqual(client('/version')[2], 302) + with self.assertRaises(ValueError) as raised: + client('/repos/acme/widget/secret') + self.assertNotIn(secret, str(raised.exception)) + with self.assertRaises(ValueError): client('/repos/acme/widget/duplicate') + self.assertTrue(all(authenticated for _, authenticated in requests)) + self.assertEqual(len(requests), 4) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=3) + + def test_output_admission_preserves_receipt_on_failure_and_rejects_links(self): + module = self.module() + self.assertTrue(callable(getattr(module, 'OutputFile', None)), 'pinned output writer missing') + with tempfile.TemporaryDirectory() as directory: + receipt = Path(directory) / 'receipt.json' + receipt.write_text('original') + receipt.chmod(0o600) + with module.OutputFile(receipt): pass + self.assertEqual(receipt.read_text(), 'original') + with module.OutputFile(receipt) as output: + output.write({'status': 'verified'}) + self.assertIn('verified', receipt.read_text()) + self.assertEqual(receipt.stat().st_mode & 0o777, 0o600) + foreign = Path(directory) / 'foreign' + foreign.write_text('preserve') + receipt.unlink() + receipt.symlink_to(foreign) + with self.assertRaises(ValueError): module.OutputFile(receipt) + self.assertEqual(foreign.read_text(), 'preserve') + + def test_http_operation_budget_stops_before_next_get(self): + module = self.module() + from unittest.mock import patch + clock = [0] + calls = [] + class Opener: + def open(self, request, timeout): + calls.append(request.full_url) + class Response: + status = 200 + headers = {'Content-Type': 'application/json'} + emitted = False + def geturl(self): return request.full_url + def read1(self, n): + if self.emitted: return b'' + self.emitted = True + return b'{"id":42}' + def __enter__(self): return self + def __exit__(self, *args): pass + return Response() + with patch.object(module.time, 'monotonic', side_effect=lambda: clock[0]): + client = module.RestTransport('b' * 48, opener=Opener()) + self.assertEqual(client('/user')[2], 200) + clock[0] = 121 + with self.assertRaises(ValueError): client('/version') + self.assertEqual(len(calls), 1) + + def test_live_bridge_rejects_unbound_provider_before_transport(self): + module = self.module() + self.assertTrue(callable(getattr(module, 'run_live', None)), 'live verifier bridge missing') + import hashlib + from types import SimpleNamespace + from unittest.mock import patch + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + expected = root / 'expected.json' + expected.write_text('{}') + contract = root / 'provider.json' + contract.write_text('{"schema":"test-contract"}') + trusted = hashlib.sha256(contract.read_bytes()).hexdigest() + self.assertEqual(module.read_bound_json(contract, trusted)['schema'], 'test-contract') + receipt = root / 'receipt.json' + receipt.write_text('preserve') + receipt.chmod(0o600) + args = SimpleNamespace(cmd='snapshot', expected=str(expected), + provider_contract=str(contract), provider_contract_sha256='0' * 64, + actor_id=42, output=str(receipt)) + with patch.object(module, 'RestTransport') as transport: + with patch.object(module, 'read_bound_json', wraps=module.read_bound_json) as reader: + with self.assertRaises(ValueError): module.run_live(args, lambda *a, **k: None) + self.assertGreaterEqual(reader.call_count, 2) + transport.assert_not_called() + self.assertEqual(receipt.read_text(), 'preserve') + + def test_existing_command_exposes_readonly_snapshot(self): + result = subprocess.run([sys.executable, '-B', str(SCRIPT), 'snapshot', '--help'], + capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('--provider-contract', result.stdout) + self.assertIn('--actor-id', result.stdout) + + def test_existing_command_exposes_live_verification(self): + result = subprocess.run([sys.executable, '-B', str(SCRIPT), 'verify-live', '--help'], + capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('--baseline-sha256', result.stdout)