fix(rework): require verified completion comment receipt
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail-closed canonical rework comment renderer and verifier."""
|
||||
from __future__ import annotations
|
||||
import argparse, hashlib, json, os, re, sys, tempfile
|
||||
from pathlib import Path
|
||||
|
||||
MAX = 1_000_000
|
||||
SHA = re.compile(r"^[0-9a-f]{40}$")
|
||||
DIGEST = re.compile(r"^[0-9a-f]{64}$")
|
||||
ID = re.compile(r"^[1-9][0-9]*$")
|
||||
MARK = "<!-- gitea-agent:implementation-rework/v1 "
|
||||
|
||||
class Bad(ValueError): pass
|
||||
|
||||
def read_json(p):
|
||||
b=Path(p).read_bytes()
|
||||
if len(b)>MAX: raise Bad("input too large")
|
||||
try: x=json.loads(b)
|
||||
except Exception as e: raise Bad("invalid json") from e
|
||||
if not isinstance(x,dict): raise Bad("object required")
|
||||
return x
|
||||
|
||||
def read_json_any(p):
|
||||
b=Path(p).read_bytes()
|
||||
if len(b)>MAX: raise Bad("input too large")
|
||||
try: return json.loads(b)
|
||||
except Exception as e: raise Bad("invalid json") from e
|
||||
|
||||
def text(x, name, pattern=None, maxlen=4096):
|
||||
if not isinstance(x,str) or not x or len(x)>maxlen or "\r" in x or "\n" in x or x.rstrip()!=x: raise Bad(f"invalid {name}")
|
||||
if pattern and not pattern.fullmatch(x): raise Bad(f"invalid {name}")
|
||||
return x
|
||||
|
||||
def number(x,name):
|
||||
if isinstance(x,bool) or not isinstance(x,int) or x<1: raise Bad(f"invalid {name}")
|
||||
return str(x)
|
||||
|
||||
def safe_human(s,name):
|
||||
if not isinstance(s,str) or not s or len(s)>10000 or "\r" in s or "\n" in s or s.rstrip()!=s: raise Bad(f"invalid {name}")
|
||||
# Keep the renderer deliberately conservative: no markup, mentions, URLs,
|
||||
# credentials, shell-like payloads, or HTML in untrusted fields.
|
||||
if re.search(r"<[^>]*>|(?:https?://|www\.)|(?:^|\s)@[A-Za-z0-9_.-]+|(?:password|passwd|secret|token|api[_ -]?key|authorization|bearer|credential)\s*[:=]",s,re.I): raise Bad(f"unsafe {name}")
|
||||
return s
|
||||
|
||||
def canon(d):
|
||||
for k in ("owner","repository"): text(d.get(k),k,re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$"))
|
||||
for k in ("reviewed_head","final_head"): text(d.get(k),k,SHA)
|
||||
text(d.get("contract_sha256"),"contract_sha256",DIGEST)
|
||||
vals={k:number(d.get(k),k) for k in ("issue","pr","review")}
|
||||
result=d.get("result")
|
||||
if result not in ("rework_delivered","partial"): raise Bad("invalid result")
|
||||
fs=d.get("findings")
|
||||
if not isinstance(fs,list) or not fs or len(fs)>100: raise Bad("invalid findings")
|
||||
out=[]; seen=set()
|
||||
for f in fs:
|
||||
if not isinstance(f,dict): raise Bad("invalid finding")
|
||||
fid=text(f.get("id"),"finding id",re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"))
|
||||
if fid in seen: raise Bad("duplicate finding")
|
||||
seen.add(fid)
|
||||
status=f.get("status"); cls=f.get("classification")
|
||||
if status not in ("resolved","unresolved") or cls not in ("valid_fix","evidence_gap","reviewer_misunderstanding"): raise Bad("invalid finding enum")
|
||||
out.append((fid,status,cls,safe_human(f.get("changes"),"changes"),safe_human(f.get("verification"),"verification"),safe_human(f.get("reason"),"reason")))
|
||||
if result=="rework_delivered" and any(status!="resolved" for _,status,cls,_,_,_ in out if cls in ("valid_fix","evidence_gap")): raise Bad("rework_delivered requires resolved actionable findings")
|
||||
extras={k:safe_human(d.get(k),k) for k in ("local_checks","runtime_browser")}
|
||||
cs=d.get("commit_status"); ar=d.get("actions_runs")
|
||||
if cs not in ("success","failure","pending","없음","조회 불가") or ar not in ("success","failure","pending","없음","조회 불가"): raise Bad("invalid check status")
|
||||
return d, vals, out, extras, cs, ar
|
||||
|
||||
def render_body(d,vals,findings,extras,cs,ar):
|
||||
lines=["## 구현 재작업 결과","",f"- Source Issue: #{vals['issue']}",f"- Pull Request: #{vals['pr']}",f"- Source Review: #{vals['review']}",f"- Reviewed head: `{d['reviewed_head']}`",f"- Rework head: `{d['final_head']}`",f"- Contract SHA-256: `{d['contract_sha256']}`",f"- Result: `{d['result']}`","","### 지적 처리",""]
|
||||
for fid,status,cls,ch,ver,reason in findings:
|
||||
lines += [f"- {fid} — `{status}`",f" - 분류: {cls}",f" - 변경: {ch}",f" - 검증: {ver}",f" - 근거: {reason}"]
|
||||
lines += ["","### 최종 검증","",f"- Local checks: {extras['local_checks']}",f"- Runtime/browser: {extras['runtime_browser']}",f"- Commit status: {cs}",f"- Actions runs: {ar}","","### 다음 단계","","Reviewer가 rework head를 새 snapshot으로 다시 검토해야 하며 이 comment는 기존 `REQUEST_CHANGES`를 해제하거나 승인을 의미하지 않는다.",""]
|
||||
return "\n".join(lines)
|
||||
|
||||
def atomic(path,data):
|
||||
p=Path(path); p.parent.mkdir(parents=True,exist_ok=True)
|
||||
fd,tmp=tempfile.mkstemp(prefix=".rework-",dir=str(p.parent));
|
||||
try:
|
||||
with os.fdopen(fd,"wb") as f: f.write(data); f.flush(); os.fsync(f.fileno())
|
||||
os.replace(tmp,p)
|
||||
finally:
|
||||
if os.path.exists(tmp): os.unlink(tmp)
|
||||
|
||||
def do_render(a):
|
||||
d,vals,fs,ex,cs,ar=canon(read_json(a.input))
|
||||
body=render_body(d,vals,fs,ex,cs,ar); bb=body.encode()
|
||||
rh=hashlib.sha256(bb).hexdigest()
|
||||
corr=hashlib.sha256("\0".join([d['owner'],d['repository'],vals['issue'],vals['pr'],vals['review'],d['reviewed_head'],d['final_head'],d['contract_sha256']]).encode()).hexdigest()
|
||||
marker=f"{MARK}owner={d['owner']} repository={d['repository']} issue={vals['issue']} pr={vals['pr']} review={vals['review']} reviewed-head={d['reviewed_head']} head={d['final_head']} contract-sha256={d['contract_sha256']} result-sha256={rh} correlation={corr} -->\n"
|
||||
atomic(a.comment,(body+marker).encode())
|
||||
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)
|
||||
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)
|
||||
target=exp.get("target")
|
||||
if not isinstance(target,dict): raise Bad("expected target")
|
||||
owner=text(target.get("owner"),"owner",re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$"))
|
||||
repository=text(target.get("repository"),"repository",re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$"))
|
||||
issue=number(target.get("issue"),"issue"); pr_number=number(target.get("pr"),"pr"); review=number(exp.get("review"),"review")
|
||||
reviewed_head=text(exp.get("reviewed_head"),"reviewed_head",SHA); head=text(head,"head",SHA); contract=text(exp.get("contract_sha256"),"contract_sha256",DIGEST)
|
||||
computed_corr=hashlib.sha256("\0".join([owner,repository,issue,pr_number,review,reviewed_head,head,contract]).encode()).hexdigest()
|
||||
expected_marker=f"{MARK}owner={owner} repository={repository} issue={issue} pr={pr_number} review={review} reviewed-head={reviewed_head} head={head} contract-sha256={contract} result-sha256={rh} correlation={computed_corr} -->\n"
|
||||
if not isinstance(body,str) or body.encode().decode()!=body or not isinstance(marker_free,str) or body != marker_free+marker or hashlib.sha256(marker_free.encode()).hexdigest()!=rh or not DIGEST.fullmatch(rh or "") or corr!=computed_corr or marker!=expected_marker: raise Bad("expected digest")
|
||||
phead=pr.get("head",{}).get("sha") if isinstance(pr.get("head"),dict) else None
|
||||
if pr.get("object_kind")!="pull_request" or pr.get("owner")!=owner or pr.get("repository")!=repository or pr.get("number")!=int(pr_number) or phead!=head or pr.get("state")!="open" or pr.get("metadata_binding_verified") is not True or not pr.get("html_url"): raise Bad("PR mismatch")
|
||||
aid=ident.get("id"); login=ident.get("login")
|
||||
if isinstance(aid,bool) or not isinstance(aid,int) or aid<1 or not isinstance(login,str) or not login or ident.get("role")!="gitea-impl-agent" or ident.get("authenticated") is not True or ident.get("stable_account_verified") is not True: raise Bad("identity mismatch")
|
||||
if comment_readback.get("object_kind")!="pull_request_top_level_comments" or comment_readback.get("owner")!=owner or comment_readback.get("repository")!=repository or comment_readback.get("pr")!=int(pr_number) or comment_readback.get("complete") is not True or comment_readback.get("endpoint_binding_verified") is not True or comment_readback.get("source_issue_unchanged") is not True or comment_readback.get("review_collection_unchanged") is not True or comment_readback.get("pr_metadata_unchanged") is not True: raise Bad("comments scope")
|
||||
comments=comment_readback.get("comments")
|
||||
if not isinstance(comments,list) or len(comments)>10000 or comment_readback.get("total_count")!=len(comments) or isinstance(comment_readback.get("page_count"),bool) or not isinstance(comment_readback.get("page_count"),int) or comment_readback["page_count"]<1: raise Bad("comments incomplete")
|
||||
provider_counts=comment_readback.get("provider_counts")
|
||||
if not isinstance(provider_counts,dict): raise Bad("provider counts")
|
||||
count_names=("top_level","reviews","inline")
|
||||
if any(isinstance(provider_counts.get(k),bool) or not isinstance(provider_counts.get(k),int) or provider_counts[k]<0 for k in (*count_names,"aggregate_comments")): raise Bad("provider counts")
|
||||
components=provider_counts.get("aggregate_components")
|
||||
if not isinstance(components,list) or not components or len(set(components))!=len(components) or any(k not in count_names for k in components) or provider_counts["top_level"]!=len(comments) or provider_counts["aggregate_comments"]!=sum(provider_counts[k] for k in components): raise Bad("provider counts")
|
||||
matches=[]
|
||||
for c in comments:
|
||||
if not isinstance(c,dict): raise Bad("invalid comment")
|
||||
if corr in str(c.get("body","")): matches.append(c)
|
||||
if len(matches)!=1: raise Bad("correlation cardinality")
|
||||
c=matches[0]; cb=c.get("body")
|
||||
if cb != body: raise Bad("body mismatch")
|
||||
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"]}
|
||||
atomic(a.output,(json.dumps(receipt,sort_keys=True,separators=(",",":"))+"\n").encode())
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(); sp=p.add_subparsers(dest="cmd",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)
|
||||
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)
|
||||
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
|
||||
return 0
|
||||
if __name__=="__main__": raise SystemExit(main())
|
||||
Reference in New Issue
Block a user