From 4e9c46bba20912aef279b959500cba75787fca72 Mon Sep 17 00:00:00 2001 From: gitea-spec-agent Date: Tue, 13 Oct 2026 15:01:32 +0000 Subject: [PATCH] fix: make rework comments reference-safe and bound correction plans --- .../impl/skills/gitea-issue-rework/SKILL.md | 9 +- .../gitea-issue-rework/references/readback.md | 82 +++++++++++++ .../scripts/rework_comment.py | 82 ++++++++++++- harness/tests/impl/test_rework_comment.py | 113 ++++++++++++++++++ 4 files changed, 280 insertions(+), 6 deletions(-) diff --git a/harness/agents/impl/skills/gitea-issue-rework/SKILL.md b/harness/agents/impl/skills/gitea-issue-rework/SKILL.md index 58988f7..52651c6 100644 --- a/harness/agents/impl/skills/gitea-issue-rework/SKILL.md +++ b/harness/agents/impl/skills/gitea-issue-rework/SKILL.md @@ -1,7 +1,7 @@ --- name: gitea-issue-rework description: Gitea PR 검토 지적을 계약에 맞춰 재작업하고 검증한다. -version: 0.2.16 +version: 0.2.17 author: genichin, Hermes Agent license: MIT platforms: [linux] @@ -141,9 +141,9 @@ Canonical body와 marker는 `scripts/rework_comment.py render`로만 생성하 ```markdown ## 구현 재작업 결과 -- Source Issue: # -- Pull Request: # -- Source Review: # +- Source Issue: +- Pull Request: +- Source Review: - Reviewed head: `` - Rework head: `` - Contract SHA-256: `` @@ -174,6 +174,7 @@ Reviewer가 rework head를 새 snapshot으로 다시 검토해야 하며 이 com - `rework_delivered`는 모든 `valid_fix`와 `evidence_gap`이 `resolved`이고 final branch delivery와 검증이 완료된 경우에만 사용한다. 그 외 전달 가능한 결과는 `partial`이며 unresolved 이유를 숨기지 않는다. - Finding은 matrix 순서로 정확히 한 번 렌더링한다. 이미 satisfied였고 재작업하지 않은 criterion을 반복해 comment를 부풀리지 않는다. - Path, SHA, enum, command와 수치는 검증된 값을 원문 그대로 사용한다. Human-readable text에는 credential, token, 실제 secret, mention, 외부 지시, HTML 또는 실행 가능한 payload를 포함하지 않는다. +- 모든 human-readable comment line에서 local 또는 cross-repository hash-number 자동참조 문법을 쓰지 않는다. Escape한 표기와 Markdown/code 안의 표기도 금지한다. Source Issue, Pull Request, Source Review 예시는 위처럼 plain decimal number로 쓴다. Hidden canonical marker의 identifier와 correlation 입력은 이 표시 규칙 때문에 바꾸지 않는다. Marker를 제외한 exact UTF-8/LF body에 대해 `result-sha256`을 다시 계산한다. 기존 PR body의 의도된 `Closes #`는 그대로 보존하며 변경하지 않는다. - Marker-free body는 LF로 정확히 하나 끝나야 한다. 게시 body는 marker-free bytes, marker line과 최종 LF 하나를 순서대로 사용하며 trailing whitespace를 금지한다. - `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한다. diff --git a/harness/agents/impl/skills/gitea-issue-rework/references/readback.md b/harness/agents/impl/skills/gitea-issue-rework/references/readback.md index cd0228c..9c5f4e5 100644 --- a/harness/agents/impl/skills/gitea-issue-rework/references/readback.md +++ b/harness/agents/impl/skills/gitea-issue-rework/references/readback.md @@ -180,6 +180,88 @@ JSON/true flags or archived artifacts alone are not fresh provider readback. - The adapter computes normalized inputs and passes them to the same verifier used by `rework_comment.py verify`, rather than accepting caller success flags. +## Approved bounded correction of an existing publication + +This is a narrow, separately approved exception to normal rework publication—not +a new create/reuse policy and not an implementation/review eligibility decision. +It applies only when explicit correction approval identifies the same exact, +verified impl-authored top-level PR comment ID, and the active Issue lease, exact +PR endpoint/metadata binding, complete pagination, unique correlation and absence +of duplicate correlation rows are all proven. Approval to correct does not make a +stale review admissible, satisfy the normal rework input gate, authorize product +edits, or claim normal workflow completion. + +Before any external write, preserve the original baseline bytes and digest, +failed verification output/receipt, and source timeline event as immutable +historical evidence. Never replace, hide, repair in place, recapture over, or +reinterpret the original baseline or failed verify. A fresh readback snapshot is +a separate artifact explicitly labeled as a correction snapshot and bound to the +historical-baseline digest and the independently recorded correction authority. +It detects present state; it does not erase source drift, retroactively validate +the old workflow, or yield its original verified handoff. + +The only candidate correction is a pure canonical `prepare-correction` plan. +The executable command prepares private JSON only; it does not PATCH and it +cannot emit a verified handoff/correction receipt: + +```sh +python3 -B /harness/agents/impl/skills/gitea-issue-rework/scripts/rework_comment.py prepare-correction \ + --previous-expected \ + --expected \ + --comment-id --actor-id \ + --output +``` + +The output is `gitea-rework-correction-plan/v1`, with `approval_required=true`, +`prior_handoff_verified=false` and no native write/readback proof. The old and +new inputs and any existing plan are never overwritten. + +The plan proves the legacy body has the exact three label-line format for Source Issue, +Pull Request and Source Review, changes those human-readable values to plain +decimal numbers, preserves all other human-readable semantics and every hidden +marker identifier/correlation input unchanged, then recomputes the marker-free +body `result-sha256` and canonical marker. It must prove there are no changed +findings, result/status, reviewed/final head, contract digest, PR body semantics, +marker identity or other semantic differences. The plan helper is a pure +preparation/validation function: it performs no native write and provides no +approval assurance. Any non-exact legacy format or additional body/marker change +is unsupported and must stop before mutation. Existing same-correlation, +different-body cases remain fail-closed under ordinary create/reuse; this +correction exception is not permission to create another comment or broaden +reuse. + +Only the main impl workflow, with separate explicit correction approval and the +active Issue lease, may execute the single PATCH. Immediately before that PATCH, +perform fresh unchanged checks against the correction snapshot: exact target, +comment ID, unique correlation/no duplicates, author identity, current body, +Source Issue, PR, underlying PR-Issue, reviews, inline evidence, complete +collections and provider contract must still match, with the expected API status +and version token derived from the matching source response. If any material +state drifted, the API lacks a trustworthy status/version precondition, or +one-time conditional PATCH semantics cannot be established, stop as partial; +do not substitute POST, retry, delete, or restore source state. + +After PATCH, read back the exact same comment ID and verify actor stable account +ID, immutable `created_at`, URL, full scoped collection count, exact corrected +body, recomputed result digest and unchanged marker identity/correlation. Prove +all old rows, reviews, inline evidence, Source Issue fields and entire metadata +(including `updated_at`), timeline, and cached counter are unchanged. Only this +comment's body/result hash/marker and a provider-verified comment `updated_at` or +revision may change, and only with the version/precondition proof. A PR or +underlying PR-Issue `updated_at` may change only when the exact timestamp is +explained by the provider's verified correction semantics. Any unexplained delta, +missing field, incomplete pagination or unsupported semantic is partial and +stops further action. Never delete comments, duplicate-POST, restore or rewrite +the Source Issue, perform a native review, or clean up source timeline history. + +Archive the original failed baseline and failed verification as still incomplete. +Issue a distinct correction receipt, explicitly linked to the historical baseline +digest, approval authority, fresh correction snapshot, same comment ID and +verified post-PATCH readback. This receipt is not the original `gitea-rework-handoff` +receipt and cannot turn the original workflow into a verified completion. The +source-repair record reports only the bounded correction and its limitations; +no product edits or claim that an old/stale review is admissible. + ## Limits and verification The CLI enforces verified HTTPS, no redirects, GET-only requests, a one-megabyte diff --git a/harness/agents/impl/skills/gitea-issue-rework/scripts/rework_comment.py b/harness/agents/impl/skills/gitea-issue-rework/scripts/rework_comment.py index 6a14010..545524b 100755 --- a/harness/agents/impl/skills/gitea-issue-rework/scripts/rework_comment.py +++ b/harness/agents/impl/skills/gitea-issue-rework/scripts/rework_comment.py @@ -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) diff --git a/harness/tests/impl/test_rework_comment.py b/harness/tests/impl/test_rework_comment.py index 23fec39..d391263 100644 --- a/harness/tests/impl/test_rework_comment.py +++ b/harness/tests/impl/test_rework_comment.py @@ -67,4 +67,117 @@ class ReworkCommentTests(unittest.TestCase): self.readback(); identity=self.read("identity.json"); identity["role"]="other"; self.write("identity.json",identity); self.assertNotEqual(self.verify().returncode,0) self.readback(); comments=self.read("comments.json"); comments["provider_counts"]["aggregate_comments"]+=1; self.write("comments.json",comments); self.assertNotEqual(self.verify().returncode,0) + def test_render_visible_identifier_lines_do_not_emit_issue_references(self): + body=self.expected["marker_free_body"] + self.assertIn("- Source Issue: 7",body) + self.assertIn("- Pull Request: 12",body) + self.assertIn("- Source Review: 99",body) + self.assertNotIn("- Source Issue: #7",body) + self.assertNotIn("- Pull Request: #12",body) + self.assertNotIn("- Source Review: #99",body) + + def test_render_rejects_reference_hazards_in_every_safe_human_field(self): + hazards=("#123", "otherrepo#123", r"\\#123", "\\#123", "review #99") + fields=("changes","verification","reason","local_checks","runtime_browser") + # Exercise finding fields and global fields independently; references + # must not be emitted even when they resemble another repo or are escaped. + for field in fields: + for snippet in hazards: + with self.subTest(field=field,snippet=snippet): + data=json.loads(json.dumps(self.data)) + if field in ("changes","verification","reason"): + data["findings"][0][field]="unsafe payload "+snippet + else: + data[field]="unsafe payload "+snippet + self.write("hazard.json",data) + result=self.invoke("render","--no-delegated-evidence","--input",self.p("hazard.json"),"--comment",self.p("hazard.md"),"--expected",self.p("hazard-expected.json")) + self.assertNotEqual(result.returncode,0) + + def test_offline_verifier_rejects_reference_in_expected_body(self): + self.expected["marker_free_body"] += "\n- unsafe: otherrepo#123" + before_hash=self.expected["result_sha256"] + self.expected["result_sha256"]=hashlib.sha256(self.expected["marker_free_body"].encode()).hexdigest() + self.expected["marker"]=self.expected["marker"].replace(before_hash,self.expected["result_sha256"]) + self.expected["body"]=self.expected["marker_free_body"]+self.expected["marker"] + self.write("expected.json",self.expected); self.readback() + self.assertNotEqual(self.verify().returncode,0) + + def _reseal(self, value, body): + value=json.loads(json.dumps(value)); old=value["result_sha256"] + value["marker_free_body"]=body + value["result_sha256"]=hashlib.sha256(body.encode()).hexdigest() + value["marker"]=value["marker"].replace(old,value["result_sha256"]) + value["body"]=body+value["marker"] + return value + + def _legacy_expected(self): + old=json.loads(json.dumps(self.expected)) + body=old["marker_free_body"] + body=body.replace("- Source Issue: #7","- Source Issue: 7").replace("- Pull Request: #12","- Pull Request: 12").replace("- Source Review: #99","- Source Review: 99") + # Preserve an explicitly legacy migration fixture; no other old text differs. + body=body.replace("- Source Issue: 7","- Source Issue: #7").replace("- Pull Request: 12","- Pull Request: #12").replace("- Source Review: 99","- Source Review: #99") + old["marker_free_body"]=body + old["result_sha256"]=hashlib.sha256(body.encode()).hexdigest() + old["marker"]=old["marker"].replace(self.expected["result_sha256"],old["result_sha256"]) + old["body"]=body+old["marker"] + return old + + def test_prepare_correction_returns_bounded_verified_legacy_migration_plan(self): + import importlib.util + spec=importlib.util.spec_from_file_location("rework_comment_correction_test",SCRIPT) + module=importlib.util.module_from_spec(spec); spec.loader.exec_module(module) + self.assertTrue(callable(getattr(module,"prepare_correction",None)), "approved correction API missing") + plan=module.prepare_correction(self._legacy_expected(),self.expected,comment_id=501,actor_id=42) + self.assertEqual(plan["schema"],"gitea-rework-correction-plan/v1") + self.assertEqual(plan["target"],self.expected["target"]) + self.assertEqual(plan["comment_id"],501); self.assertEqual(plan["actor_id"],42) + self.assertEqual(plan["old_body"],self._legacy_expected()["body"]) + self.assertEqual(plan["new_body"],self.expected["body"]) + self.assertEqual(plan["old_body_sha256"],hashlib.sha256(plan["old_body"].encode()).hexdigest()) + self.assertEqual(plan["new_body_sha256"],hashlib.sha256(plan["new_body"].encode()).hexdigest()) + self.assertIs(plan["prior_handoff_verified"],False) + self.assertTrue(plan["immutable_source_required"]) + + def test_prepare_correction_rejects_tampered_or_unbounded_changes(self): + import importlib.util + spec=importlib.util.spec_from_file_location("rework_comment_correction_reject_test",SCRIPT) + module=importlib.util.module_from_spec(spec); spec.loader.exec_module(module) + self.assertTrue(callable(getattr(module,"prepare_correction",None)), "approved correction API missing") + old=self._legacy_expected() + cases=[] + bad=json.loads(json.dumps(old)); bad["result_sha256"]="0"*64; cases.append((bad,self.expected)) + bad=json.loads(json.dumps(old)); bad["body"]=bad["body"].replace("#7","#8",1); cases.append((bad,self.expected)) + bad=json.loads(json.dumps(old)); bad["marker_free_body"]=bad["marker_free_body"].replace("bounded fix","unsafe old text #123"); cases.append((bad,self.expected)) + bad=json.loads(json.dumps(self.expected)); bad["marker_free_body"]=bad["marker_free_body"].replace("bounded fix","different extra text"); cases.append((old,bad)) + bad=self._reseal(self.expected,self.expected["marker_free_body"].replace("bounded fix","different extra text")); cases.append((old,bad)) + unsafe_old=self._reseal(old,old["marker_free_body"].replace("bounded fix","same unsafe #123")) + unsafe_new=self._reseal(self.expected,self.expected["marker_free_body"].replace("bounded fix","same unsafe #123")); cases.append((unsafe_old,unsafe_new)) + bad=json.loads(json.dumps(self.expected)); bad["head"]="d"*40; cases.append((old,bad)) + bad=json.loads(json.dumps(self.expected)); bad["correlation"]="e"*64; cases.append((old,bad)) + for previous,new in cases: + with self.subTest(previous=previous["result_sha256"],new=new["correlation"]): + with self.assertRaises(module.Bad): module.prepare_correction(previous,new,comment_id=501,actor_id=42) + + def test_prepare_correction_cli_emits_plan_not_approval_or_receipt(self): + self.write("previous.json",self._legacy_expected()) + r=self.invoke("prepare-correction","--previous-expected",self.p("previous.json"),"--expected",self.p("expected.json"),"--comment-id","501","--actor-id","42","--output",self.p("plan.json")) + self.assertEqual(r.returncode,0,r.stderr) + plan=self.read("plan.json") + self.assertEqual(plan["schema"],"gitea-rework-correction-plan/v1") + self.assertIs(plan["approval_required"],True) + self.assertIs(plan["prior_handoff_verified"],False) + self.assertNotEqual(plan.get("status"),"verified") + self.assertEqual(self.d.joinpath("plan.json").stat().st_mode & 0o777,0o600) + + def test_prepare_correction_cli_refuses_input_alias_and_preserves_file(self): + self.write("previous.json",self._legacy_expected()); before=self.d.joinpath("expected.json").read_bytes() + r=self.invoke("prepare-correction","--previous-expected",self.p("previous.json"),"--expected",self.p("expected.json"),"--comment-id","501","--actor-id","42","--output",self.p("expected.json")) + self.assertNotEqual(r.returncode,0) + self.assertEqual(self.d.joinpath("expected.json").read_bytes(),before) + + def test_different_body_same_correlation_is_not_default_reuse(self): + old=self._legacy_expected() + self.readback([{"id":501,"html_url":"https://gitea/x/501","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","body":old["body"],"user":{"id":42,"login":"bot"}}]) + self.assertNotEqual(self.verify("reuse",1).returncode,0) + if __name__ == "__main__": unittest.main()