[verified] Add fail-closed rework provider readback adapter

This commit is contained in:
2026-10-12 01:46:39 +00:00
parent d47df21578
commit 2ad836f332
8 changed files with 1744 additions and 8 deletions
@@ -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 입력에 넣지 않는다. - `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한다. - 게시 전 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 하나가 확인될 때만 성공으로 판정한다. - 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 `<issue-number>`는 body digest·title·state·labels·assignees·milestone·comment collection이 불변이어야 한다. `Closes #<issue-number>` 같은 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 `<issue-number>`는 `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을 통과한다. 완료 조건: 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 ### Evidence manifest automation
@@ -0,0 +1,159 @@
# Canonical PR rework completion readback
## Scope and source
Canonical entry point: `<HARNESS_ROOT>/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": "<independently-observed-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
`<HARNESS_ROOT>/repositories/<owner>/<repository>/worktrees/issue-<issue>/runtime/<run_id>`.
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_ROOT>/harness/agents/impl/skills/gitea-issue-rework/scripts/rework_comment.py snapshot \
--expected <RUNTIME>/expected.json \
--provider-contract <TRUSTED_PROVIDER_CONTRACT> \
--provider-contract-sha256 <INDEPENDENT_PROVIDER_SHA256> \
--actor-id <INDEPENDENT_IMPL_ACCOUNT_ID> \
--output <RUNTIME>/rework-baseline.json
```
After the authorized write or existing-comment reuse, through `terminal`:
```sh
python3 -B <HARNESS_ROOT>/harness/agents/impl/skills/gitea-issue-rework/scripts/rework_comment.py verify-live \
--expected <RUNTIME>/expected.json \
--provider-contract <TRUSTED_PROVIDER_CONTRACT> \
--provider-contract-sha256 <INDEPENDENT_PROVIDER_SHA256> \
--actor-id <INDEPENDENT_IMPL_ACCOUNT_ID> \
--baseline <RUNTIME>/rework-baseline.json \
--baseline-sha256 <INDEPENDENT_BASELINE_SHA256> \
--action create \
--output <RUNTIME>/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.
@@ -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']} 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()) atomic(a.expected,(json.dumps(expected,ensure_ascii=False,sort_keys=True,separators=(",",":"))+"\n").encode())
def do_verify(a): def verify_readbacks(exp, pr, comment_readback, ident, *, action, baseline_count):
exp=read_json(a.expected); pr=read_json(a.pr_readback); comment_readback=read_json(a.comments_readback); ident=read_json(a.identity_readback) 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") body=exp.get("body"); rh=exp.get("result_sha256"); corr=exp.get("correlation"); head=exp.get("head")
marker=exp.get("marker","") marker=exp.get("marker","")
marker_free=exp.get("marker_free_body", body[:-len(marker)] if marker and body.endswith(marker) else body) 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 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 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") 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 baseline=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") 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":a.action,"cardinality":len(comments),"page_count":comment_readback["page_count"]} 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()) atomic(a.output,(json.dumps(receipt,sort_keys=True,separators=(",",":"))+"\n").encode())
def main(): 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) 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") 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) 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() args=p.parse_args()
try: do_render(args) if args.cmd=="render" else do_verify(args) try:
except (Bad, OSError, KeyError, TypeError, UnicodeError) as e: print(f"error: {e}",file=sys.stderr); return 2 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 return 0
if __name__=="__main__": raise SystemExit(main()) if __name__=="__main__": raise SystemExit(main())
@@ -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 = ('<!-- gitea-agent:implementation-rework/v1 owner='+target['owner']+' repository='+target['repository']+
' issue='+str(target['issue'])+' pr='+str(target['pr'])+' review='+str(expected['review'])+
' reviewed-head='+expected['reviewed_head']+' head='+expected['head']+
' contract-sha256='+expected['contract_sha256']+' result-sha256='+expected['result_sha256']+
' correlation='+correlation+' -->\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'])}
@@ -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
+12
View File
@@ -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 foreign/replaced paths, close every client and directory descriptor, and retain
unauthenticated-token rejection behavior. 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 It validates all `test*.py` files recursively under exactly these required
roles: `common`, `spec`, `impl`, and `review`. `harness/tests`, every role, and roles: `common`, `spec`, `impl`, and `review`. `harness/tests`, every role, and
every nested test package must have an `__init__.py`; every role and every every nested test package must have an `__init__.py`; every role and every
+477
View File
@@ -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':'<https://foreign.invalid/comments?page=2&limit=50>; 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':
'</api/v1'+endpoint+'?page='+str(page)+'&limit=50>; rel="'+relation+'"'},200)}
with self.subTest(relation=relation):
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
header = '</api/v1'+endpoint+'?page=1&limit=50>; 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':
'</api/v1'+endpoint+'?page=1&limit=50>; 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')
@@ -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)