[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
@@ -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())
@@ -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