fix: make rework comments reference-safe and bound correction plans

This commit is contained in:
2026-10-13 15:01:32 +00:00
parent a74fb747a5
commit 4e9c46bba2
4 changed files with 280 additions and 6 deletions
@@ -45,7 +45,7 @@ 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}")
if re.search(r"<[^>]*>|(?:https?://|www\.)|(?:^|\s)@[A-Za-z0-9_.-]+|#[0-9]+|(?:password|passwd|secret|token|api[_ -]?key|authorization|bearer|credential)\s*[:=]",s,re.I): raise Bad(f"unsafe {name}")
return s
def canon(d):
@@ -73,7 +73,7 @@ def canon(d):
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']}`","","### 지적 처리",""]
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`를 해제하거나 승인을 의미하지 않는다.",""]
@@ -112,6 +112,7 @@ 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")
if isinstance(body,str) and re.search(r"#[0-9]+",body): raise Bad("unsafe expected identifier reference")
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")
@@ -158,11 +159,87 @@ def do_verify(a):
action=a.action, baseline_count=a.baseline_count)
atomic(a.output,(json.dumps(receipt,sort_keys=True,separators=(",",":"))+"\n").encode())
def _correction_expected(exp, *, legacy=False):
if type(exp) is not dict: raise Bad("expected object")
body, marker, free = exp.get("body"), exp.get("marker"), exp.get("marker_free_body")
if (type(body) is not str or type(marker) is not str or type(free) is not str
or len(body.encode()) > MAX or body != free + marker or "\r" in free
or not free.endswith("\n")):
raise Bad("expected body binding")
target = exp.get("target")
if type(target) is not 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(target.get("pr"),"pr")
review=number(exp.get("review"),"review")
reviewed=text(exp.get("reviewed_head"),"reviewed_head",SHA); head=text(exp.get("head"),"head",SHA)
contract=text(exp.get("contract_sha256"),"contract_sha256",DIGEST)
corr=hashlib.sha256("\0".join([owner,repository,issue,pr,review,reviewed,head,contract]).encode()).hexdigest()
rh=hashlib.sha256(free.encode()).hexdigest()
expected_marker=f"{MARK}owner={owner} repository={repository} issue={issue} pr={pr} review={review} reviewed-head={reviewed} head={head} contract-sha256={contract} result-sha256={rh} correlation={corr} -->\n"
if marker != expected_marker or exp.get("result_sha256") != rh or exp.get("correlation") != corr:
raise Bad("expected marker or digest")
lines=free.splitlines(keepends=True)
labels=[f"- Source Issue: {issue}\n",f"- Pull Request: {pr}\n",f"- Source Review: {review}\n"]
wanted=[label.replace(": ",": #",1) for label in labels] if legacy else labels
if len(lines)<12 or lines[2:5]!=wanted: raise Bad("canonical identifier lines")
normalized=list(lines); normalized[2:5]=labels
normal="".join(normalized)
if re.search(r"#[0-9]+",normal): raise Bad("unexpected identifier reference")
# Parse the existing canonical layout, then round-trip through its renderer;
# this is a bounded format migration, not arbitrary text with a valid hash.
pieces=normal.split("\n\n### 최종 검증\n\n")
if len(pieces)!=2: raise Bad("malformed canonical body")
prefix_rows=pieces[0].splitlines()
rows=prefix_rows[12:]
if not rows or len(rows)%5: raise Bad("malformed finding rows")
findings=[]
for i in range(0,len(rows),5):
match=re.fullmatch(r"- ([A-Za-z0-9][A-Za-z0-9._:-]{0,127}) — `(resolved|unresolved)`",rows[i])
field_prefixes=(" - 분류: "," - 변경: "," - 검증: "," - 근거: ")
if match is None or any(not rows[i+j+1].startswith(p) for j,p in enumerate(field_prefixes)):
raise Bad("malformed finding rows")
vals=[rows[i+j+1][len(p):] for j,p in enumerate(field_prefixes)]
findings.append(dict(id=match[1],status=match[2],classification=vals[0],changes=vals[1],verification=vals[2],reason=vals[3]))
checks=pieces[1].splitlines()
prefixes=("- Local checks: ","- Runtime/browser: ","- Commit status: ","- Actions runs: ")
if len(checks)<4 or any(not checks[i].startswith(p) for i,p in enumerate(prefixes)):
raise Bad("malformed check rows")
result=re.fullmatch(r"- Result: `(rework_delivered|partial)`\n",lines[8])
if result is None: raise Bad("malformed result line")
data=dict(owner=owner,repository=repository,issue=int(issue),pr=int(pr),review=int(review),reviewed_head=reviewed,final_head=head,contract_sha256=contract,result=result[1],findings=findings,
local_checks=checks[0][len(prefixes[0]):],runtime_browser=checks[1][len(prefixes[1]):],commit_status=checks[2][len(prefixes[2]):],actions_runs=checks[3][len(prefixes[3]):])
d,vals,fs,ex,cs,ar=canon(data)
if normal!=render_body(d,vals,fs,ex,cs,ar): raise Bad("malformed canonical body")
return dict(owner=owner,repository=repository,issue=issue,pr=pr,review=review,reviewed_head=reviewed,head=head,contract_sha256=contract,correlation=corr,result_sha256=rh,body=body,marker=marker,free=free,normal=normal,lines=lines,found=[2,3,4])
def prepare_correction(previous_expected, new_expected, *, comment_id, actor_id):
old=_correction_expected(previous_expected, legacy=True); new=_correction_expected(new_expected)
cid=number(comment_id,"comment_id"); aid=number(actor_id,"actor_id")
immutable=("owner","repository","issue","pr","review","reviewed_head","head","contract_sha256","correlation")
if any(old[k]!=new[k] for k in immutable): raise Bad("correction binding changed")
if old["normal"] != new["free"]: raise Bad("correction semantic transform")
return {"schema":"gitea-rework-correction-plan/v1","target":{"owner":old["owner"],"repository":old["repository"],"issue":int(old["issue"]),"pr":int(old["pr"])},"comment_id":int(cid),"actor_id":int(aid),"old_body":old["body"],"new_body":new["body"],"old_body_sha256":hashlib.sha256(old["body"].encode()).hexdigest(),"new_body_sha256":hashlib.sha256(new["body"].encode()).hexdigest(),"prior_handoff_verified":False,"immutable_source_required":True,"head":old["head"],"correlation":old["correlation"],"old_expected":{"review":int(old["review"]),"reviewed_head":old["reviewed_head"],"contract_sha256":old["contract_sha256"],"result_sha256":old["result_sha256"]},"new_expected":{"review":int(new["review"]),"reviewed_head":new["reviewed_head"],"contract_sha256":new["contract_sha256"],"result_sha256":new["result_sha256"]},"approval_required":True,"external_write_performed":False,"readback_proof":False}
def do_prepare_correction(a):
from rework_readback_cli import read_bound_json, OutputFile
inputs=[Path(a.previous_expected),Path(a.expected)]
out=Path(a.output)
if any(out == p or out.resolve() == p.resolve() for p in inputs): raise Bad("output aliases input")
for p in inputs:
if not p.is_absolute() or any(part in (".","..") for part in p.parts): raise Bad("canonical input path required")
plan=prepare_correction(read_bound_json(a.previous_expected),read_bound_json(a.expected),comment_id=a.comment_id,actor_id=a.actor_id)
with OutputFile(a.output, initial_only=True) as output: output.write(plan)
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)
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)
c=sp.add_parser("prepare-correction"); c.add_argument("--previous-expected",required=True); c.add_argument("--expected",required=True); c.add_argument("--comment-id",required=True,type=int); c.add_argument("--actor-id",required=True,type=int); c.add_argument("--output",required=True)
for command in ('snapshot', 'verify-live'):
live = sp.add_parser(command)
live.add_argument('--expected', required=True)
@@ -185,6 +262,7 @@ def main():
try:
if args.cmd == 'render': do_render(args)
elif args.cmd == 'verify': do_verify(args)
elif args.cmd == 'prepare-correction': do_prepare_correction(args)
elif args.cmd == 'input-gate':
from rework_readback_cli import run_input_gate
run_input_gate(args)