diff --git a/docs/execution-plan.md b/docs/execution-plan.md index 062068e..1db2387 100644 --- a/docs/execution-plan.md +++ b/docs/execution-plan.md @@ -138,7 +138,13 @@ On resume the runtime reconciles the current configuration, source criteria, remote receipt and local revisions. Required checks are retained across HEAD changes; results from another SHA cannot satisfy them. Changed criteria add new identities rather than reusing old coverage. Command hashes can be rebound only -to currently authorized exact commands. Without recoverable local history the +to currently authorized exact commands. When some stored commands have no +current binding, the authorized ones are still bound, the others stay inert +fingerprints, and every check, obligation and result is kept under `partial` +provenance. The projection's normalized `provenance` and `reason` are not part +of a check's identity, so a restored check is not a conflict with its original +description. See [Verification](verification.md) for identity fields and the +diagnostics that name a conflicting check. Without recoverable local history the runtime replans with `partial` provenance and accepts no prior check as passed. This is partial recovery, not a fully verified continuation. Analysis such as #127 can read this metadata without reading prompts. diff --git a/docs/verification.md b/docs/verification.md index 87ad514..c50b10f 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -323,6 +323,39 @@ commands are `sha256:` fingerprints, does not gain a second keep the same check identities, results and obligations, and a frozen identity is never rewritten. +A check's execution identity is its `command`, `executor`, `profile`, `context` +and `level`. `provenance` and `reason` only describe it: the history store +normalizes them to `runtime`, so a report that describes a frozen check +differently, such as the configured test with `provenance: project`, is the same +check and keeps the frozen description. An executor, profile or context the store +kept as a `ref-` reference matches the value it was derived from. Any +other difference in an identity field is another check: it needs a new ID, and +its report cannot overwrite the frozen check or record evidence for it. + +When a resume cannot bind every stored command, for example because a proposed +command is no longer authorized, the commands that today's policy authorizes are +still bound and the others stay inert fingerprints. Every check, obligation and +earlier result is kept; the plan is recorded as `partial` and each check is +marked `unverified` on the current HEAD. An agent cannot bind a fingerprint by +reporting the command it hides: only today's authorization can, so such a report +is rejected like any other identity change. Existing unverified local checks +stay unverified until evidence for the current HEAD exists; CI never supplies it. + +A rejected identity names the check and every differing field. Commands appear +only as a 12-character fingerprint prefix, marked `unbound` while the frozen +command is still a fingerprint; other values that are not plain tokens appear as +`ref-` references. For example: + +```text +cannot rewrite frozen check identity: check configured-test-8478ec11cc826f7e differs in command (frozen unbound sha256:8478ec11cc82, proposed sha256:8478ec11cc82) +``` + +Identical fingerprints with `unbound` on one side mean the command is the same but +has no current authorization. Before dispatch, this text is the `BLOCKED` stop +reason with `stop_reason: readiness_unconfirmed`. In a stage report, it is a +report note while the report stays `not_verified`. An obligation demotion names +the check and what it would lose (`required`, `criteria`). + Hotfix requires `hotfix-reproduction` and `hotfix-regression`, both `required`, with distinct focused commands proposed by the implementer. The configured general suite satisfies neither identity. A missing proposal stops with diff --git a/scripts/cycle_dispatch.py b/scripts/cycle_dispatch.py index 692ed3c..481844f 100644 --- a/scripts/cycle_dispatch.py +++ b/scripts/cycle_dispatch.py @@ -552,6 +552,12 @@ def advance(self: Any, role: str, instruction: str = "", *, outcome.decision.profile if outcome.decision else "unknown"),)) test_payload, self.state.verification_plan = self.api._normalize_test_payload( test_payload, self.state.verification_plan) + rejected = (test_payload.get("tests") or {}).get("rejected") + if isinstance(rejected, str): + # The report stays unverified; say which check and field. + # Some rejections quote an agent-chosen check ID verbatim. + self.report.note(f" verification {role} report rejected: " + f"{self.api.readable(rejected, 300)}") try: self.record_plan_stage(role, reported, outcome, test_payload) except ValueError as error: diff --git a/scripts/cycle_history.py b/scripts/cycle_history.py index 2db8ebe..a5a9d49 100644 --- a/scripts/cycle_history.py +++ b/scripts/cycle_history.py @@ -48,6 +48,7 @@ if TYPE_CHECKING: from .execution_plan import ExecutionPlan +from verification_plan import persisted_token from review_contract import ( CLAIM_STATUSES, RESOLUTION_RUN, @@ -238,8 +239,7 @@ def clean(value, key=""): if key == "command": return command_digest(value) if key in {"id", "executor", "profile", "context"} and isinstance(value, str): - return (value if re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", value) - else "ref-" + hashlib.sha256(value.encode()).hexdigest()) + return persisted_token(value) if key == "items": return [f"AC-{i}" for i in range(1, len(value) + 1)] if key == "cause": @@ -273,8 +273,14 @@ def clean(value, key=""): return {"projection_version": 1, "plan": clean(plan.to_dict())} -def restore_plan(record: dict[str, Any], commands: tuple[str, ...]) -> ExecutionPlan: - """Recover only checks whose command is still explicitly authorized.""" +def restore_plan(record: dict[str, Any], commands: tuple[str, ...], *, + partial: bool = False) -> ExecutionPlan: + """Recover only checks whose command is still explicitly authorized. + + With `partial`, a command without a current binding stays its inert + fingerprint instead of failing the whole restore. Every check, obligation + and historical result is kept either way; a fingerprint is never executable. + """ if not TYPE_CHECKING: from execution_plan import ExecutionPlan payload = record["payload"] @@ -284,6 +290,8 @@ def restore_plan(record: dict[str, Any], commands: tuple[str, ...]) -> Execution def bind(value, key=""): if key == "command": if value not in bindings: + if partial: + return value raise HistoryError("stored check has no currently authorized command binding") return bindings[value] if isinstance(value, dict): diff --git a/scripts/cycle_plan.py b/scripts/cycle_plan.py index 8104682..acc8c1d 100644 --- a/scripts/cycle_plan.py +++ b/scripts/cycle_plan.py @@ -145,6 +145,7 @@ def prepare_plan(self: Any): except (HistoryError, ValueError, TypeError): self.plan_partial = True if record is not None: + commands: tuple[str, ...] = () try: commands = self.authorized_bindings() recovered = restore_plan(record, commands) @@ -187,9 +188,11 @@ def prepare_plan(self: Any): # Keep unavailable command identities as obligations. A # fingerprint is not executable and grants no permission. # Re-authorizing its exact command permits a later resume. + # Commands authorized today are still bound, so the + # configured check keeps one identity across resumes (#393). try: - retained = ExecutionPlan.from_dict(record["payload"]["plan"]) - except (KeyError, TypeError, ContractError): + retained = restore_plan(record, commands, partial=True) + except (KeyError, TypeError, ContractError, HistoryError): retained = None self.state.verification_plan = retained.verification if retained is not None else None if retained is not None and retained.verification is not None: diff --git a/scripts/run_cycle.py b/scripts/run_cycle.py index 2fb8a4f..8701079 100644 --- a/scripts/run_cycle.py +++ b/scripts/run_cycle.py @@ -2694,13 +2694,16 @@ def _normalize_test_payload( try: normalized, updated = verification_contract.normalize_tests( tests, payload.get("head_sha", ""), plan=plan) - except (verification_contract.VerificationError, KeyError, TypeError, ValueError): + except (verification_contract.VerificationError, KeyError, TypeError, ValueError) as error: # Invalid version/identity/evidence is insufficient verification, not # an executed test failure and never an exception escaping the driver. conclusion = "failed" if tests.get("conclusion") == "failed" else "not_verified" if plan is not None: conclusion = plan.cap(conclusion, payload.get("head_sha", "")) normalized, updated = {"conclusion": conclusion, "passed": False}, plan + if isinstance(error, verification_contract.VerificationError): + # Its text names check IDs and field names, never commands (#393). + normalized["rejected"] = str(error) payload["tests"] = normalized return payload, updated diff --git a/scripts/verification_plan.py b/scripts/verification_plan.py index 25c5206..c8ec8ae 100644 --- a/scripts/verification_plan.py +++ b/scripts/verification_plan.py @@ -143,6 +143,50 @@ def validate(self, criterion_ids: set[str]) -> None: raise VerificationError("a criterion-dependent check must be required") +#: What a check executes, where and by whom. Changing any of them is another +#: check: it needs another ID and never inherits this one's evidence. +IDENTITY_FIELDS = ("command", "executor", "profile", "context", "level") +_FINGERPRINT = re.compile(r"sha256:[a-f0-9]{64}") +_SAFE_TOKEN = re.compile(r"[A-Za-z0-9._:-]{1,64}") + + +def persisted_token(value: str) -> str: + """How the history store keeps an ID, executor, profile or context value.""" + return (value if re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", value) + else "ref-" + hashlib.sha256(value.encode()).hexdigest()) + + +def identity_conflict(frozen: Check, proposed: Check) -> tuple[str, ...]: + """The identity fields in which two checks with one ID differ. + + A frozen executor, profile or context restored from history may be the + store's reference for the proposed value; that is the same identity. A + command is never matched by its fingerprint here: only today's + authorization may bind one (`cycle_history.restore_plan`). + """ + def same(field: str, old: Any, new: Any) -> bool: + return old == new or (field in {"executor", "profile", "context"} + and isinstance(new, str) and old == persisted_token(new)) + return tuple(field for field in IDENTITY_FIELDS + if not same(field, getattr(frozen, field), getattr(proposed, field))) + + +def safe_value(field: str, value: Any) -> str: + """A diagnostic form that never reveals a command, path or free text. + + A command is shown as a short fingerprint, marked `unbound` while it is + still the persisted fingerprint, so an operator can tell a missing binding + from a different command without the command itself. + """ + if field == "command": + if isinstance(value, str) and _FINGERPRINT.fullmatch(value): + return "unbound " + value[:19] + return "sha256:" + hashlib.sha256(str(value).encode()).hexdigest()[:12] + if isinstance(value, str) and _SAFE_TOKEN.fullmatch(value): + return value + return "ref-" + hashlib.sha256(repr(value).encode()).hexdigest()[:12] + + @dataclass(frozen=True) class CheckResult: id: str @@ -219,15 +263,32 @@ def validate(self) -> None: raise VerificationError("result evidence command differs from check") def extend(self, additions: tuple[Check, ...]) -> VerificationPlan: - """Freeze identity and obligations. Promotion and additions are allowed.""" + """Freeze identity and obligations. Promotion and additions are allowed. + + The execution identity is `IDENTITY_FIELDS`. Provenance and reason are + descriptive: persistence normalizes them, so the frozen values are kept + and a different description of the same check is not a conflict. + """ checks = {check.id: check for check in self.checks} for check in additions: old = checks.get(check.id) if old is not None: - if replace(check, required=old.required, criteria=old.criteria) != old: - raise VerificationError("cannot rewrite frozen check identity") - if (old.required and not check.required) or not set(old.criteria) <= set(check.criteria): - raise VerificationError("cannot demote a frozen obligation") + differing = identity_conflict(old, check) + if differing: + raise VerificationError( + f"cannot rewrite frozen check identity: check {safe_value('id', old.id)} " + "differs in " + ", ".join( + f"{field} (frozen {safe_value(field, getattr(old, field))}, " + f"proposed {safe_value(field, getattr(check, field))})" + for field in differing)) + demoted = [name for name, lost in ( + ("required", old.required and not check.required), + ("criteria", not set(old.criteria) <= set(check.criteria))) if lost] + if demoted: + raise VerificationError( + f"cannot demote a frozen obligation: check {safe_value('id', old.id)} " + "would lose " + ", ".join(demoted)) + check = replace(old, required=check.required, criteria=check.criteria) checks[check.id] = check plan = replace(self, checks=tuple(checks.values())) plan.validate() diff --git a/tests/test_run_cycle.py b/tests/test_run_cycle.py index e3d9ada..b56a357 100644 --- a/tests/test_run_cycle.py +++ b/tests/test_run_cycle.py @@ -507,6 +507,265 @@ def test_manual_command_grammar_is_fixed(self): for command in ("python3 -m unittest", "manual:", "manual:a b", None, 3): self.assertFalse(vp.is_manual_command(command)) + # -- #393: frozen identities across resumes -------------------------------- + + LEGACY_TEST = "python3 -m unittest discover -s tests" + LEGACY_UNAUTHORIZED = "npm run e2e -- --grep family" + + def legacy_plan_fixture(self): + """A sanitized revision shaped like the blocked one (#393). + + Two configured IDs share one command, both owned by the runtime and + stored with the projection's `runtime` provenance and reason. One agent + check has a command that today's policy no longer authorizes, so a + strict restore fails and the resume takes the partial path. + """ + import cycle_history as ch + import verification_plan as vp + from cycle_journal import Journal + test = self.LEGACY_TEST + digest = hashlib.sha256(test.encode()).hexdigest()[:16] + criteria = vp.Criteria("github", "b" * 64, "evaluable", ("unit", "browser", "integrity")) + checks = ( + vp.Check("configured-test", test, "runtime", "runtime", "project", True, + provenance="project", reason="configured test command"), + vp.Check("configured-test-" + digest, test, "runtime", "runtime", "project", True, + provenance="project", reason="configured test command"), + vp.Check("family-unit", "python3 -m unittest tests.unit", "codex", "deep_coder", + "project", True, criteria=("AC-1",)), + vp.Check("family-browser", self.LEGACY_UNAUTHORIZED, "codex", "deep_coder", + "project", True, criteria=("AC-2",), level="boundary"), + vp.Check("evidence-integrity", "manual:evidence-integrity", "codex", "deep_coder", + "boundary", True, criteria=("AC-3",), level="boundary")) + verification = vp.VerificationPlan(criteria, checks) + verification = verification.record(vp.CheckResult("family-unit", "a" * 40, "pass", ({ + "command": "python3 -m unittest tests.unit", "exit_code": 0, "executed": 4},))) + verification = verification.record(vp.CheckResult( + "configured-test", "a" * 40, "unverified", cause="sandbox denied .env")) + plan = replace(self.plan(start="rereview"), plan_id="plan-legacy", head_sha="a" * 40, + verification=verification) + ch.CycleHistoryStore(self.store.path).save_plan( + plan, cycle_id="cycle-legacy", change_request_id="4", provenance="stopped", + stop_reason="stage_not_completed") + journal = Journal(self.store.path, "owner/api", "API-7", "cycle-legacy") + for command in (test, "python3 -m unittest tests.unit", "manual:evidence-integrity", + self.LEGACY_UNAUTHORIZED): + journal.record("stage_result", component="verification_command", command=command) + return criteria, digest + + def latest_verification(self): + import cycle_history as ch + record = ch.CycleHistoryStore(self.store.path).latest_plan("owner/api", "API-7", "4") + return record, record["payload"]["plan"]["verification"] + + def test_observed_pre_dispatch_collision_names_the_check_and_its_fields(self): + """Reproduces the #393 stop at the boundary that raised it. + + Before #387 a resume that could not bind every command compared only + the plain configured command, so it derived `configured-test-` + again and collided with the persisted one: same executor, profile, + context and level, but an unbound command and the projection's + normalized provenance and reason. + """ + import cycle_history as ch + import execution_plan as ep + import verification_plan as vp + self.legacy_plan_fixture() + record, _ = self.latest_verification() + with self.assertRaises(ch.HistoryError): + ch.restore_plan(record, (self.LEGACY_TEST,)) + retained = ep.ExecutionPlan.from_dict(record["payload"]["plan"]).verification + test = self.LEGACY_TEST + spec = replace(vp.configured_check(test, "runtime", "runtime"), + id="configured-test-" + hashlib.sha256(test.encode()).hexdigest()[:16]) + frozen = next(check for check in retained.checks if check.id == spec.id) + self.assertEqual((frozen.provenance, frozen.reason), ("runtime", "runtime")) + self.assertEqual(vp.identity_conflict(frozen, spec), ("command",)) + with self.assertRaises(vp.VerificationError) as caught: + retained.extend((spec,)) + message = str(caught.exception) + fingerprint = ch.command_digest(test)[:19] + self.assertIn(f"check {spec.id} differs in command", message) + self.assertIn(f"frozen unbound {fingerprint}", message) + self.assertIn(f"proposed {fingerprint}", message) + for private in (test, "provenance", "reason"): + self.assertNotIn(private, message) + # Bound to today's authorized command, the same check is accepted and + # keeps its frozen description instead of being rewritten. + bound = ch.restore_plan(record, (test, "python3 -m unittest tests.unit", + "manual:evidence-integrity", self.LEGACY_UNAUTHORIZED)) + extended = bound.verification.extend((spec,)) + self.assertEqual(next(check for check in extended.checks if check.id == spec.id).provenance, + "runtime") + self.assertEqual(len(extended.checks), len(retained.checks)) + + def test_three_resumes_keep_identities_obligations_and_history(self): + """resolve, rereview, resolve on the legacy plan: no duplicate, no throw.""" + import verification_plan as vp + criteria, digest = self.legacy_plan_fixture() + test = self.LEGACY_TEST + _, before = self.latest_verification() + ids = [check["id"] for check in before["checks"]] + obligations = {check["id"]: (check["required"], check["criteria"]) for check in before["checks"]} + configured = vp.Check("configured-test", test, "runtime", "runtime", "project", True, + provenance="project", reason="configured test command") + unit = vp.Check("family-unit", "python3 -m unittest tests.unit", "codex", "deep_coder", + "project", True, criteria=("AC-1",)) + history = [tuple((item["id"], item["head_sha"], item["result"]) for item in before["results"])] + for index, start in enumerate(("resolve", "rereview", "resolve")): + head = "bcd"[index] * 40 + reported = {"version": 2, "conclusion": "verified", "checks": [ + {**spec.to_dict(), "head_sha": head, "result": "pass", + "evidence": [{"command": spec.command, "exit_code": 0, "executed": 2}]} + for spec in (configured, unit)]} + status = "RESOLVED" if start == "resolve" else "CHANGES_REQUESTED" + body = block(status, head_sha=head, tests=reported, security_audit_ran=True) + implementer = Talker("codex", body if start == "resolve" else None) + reviewer = Talker("claude", body if start == "rereview" else block("BLOCKED")) + with self.subTest(resume=index, start=start): + report = self.run_cycle(implementer, reviewer, start_from=start, + change_request_id="4", execution_plan=self.plan(start=start), + allowed_test_command=test, allowed_verify_commands=("python3 -m unittest",), + criteria_reader=lambda *_args, **_kw: criteria, + work_item_provider="github", work_item_repository="owner/api") + self.assertNotIn("frozen", report.stopped_because or "") + self.assertTrue(report.stages, "the resume must reach a dispatch") + self.assertFalse(any("report rejected" in line for _, line in report.notes), report.notes) + _, verification = self.latest_verification() + self.assertEqual([check["id"] for check in verification["checks"]], ids) + self.assertEqual({check["id"]: (check["required"], check["criteria"]) + for check in verification["checks"]}, obligations) + results = tuple((item["id"], item["head_sha"], item["result"]) + for item in verification["results"]) + self.assertEqual(results[:len(history[-1])], history[-1]) + self.assertIn(("configured-test", head, "pass"), results) + self.assertIn(("family-unit", head, "pass"), results) + history.append(results) + restored = vp.VerificationPlan.from_dict({**verification, "criteria": criteria.to_dict()}) + # A new HEAD keeps history but needs fresh evidence of its own; + # the unbound and the manual checks stay unverified on it. + for check_id in ("family-browser", "evidence-integrity"): + self.assertEqual(restored.current(check_id, head), "unverified") + self.assertEqual(restored.current("configured-test-" + digest, head), "unverified") + self.assertEqual(restored.cap("verified", head), "not_verified") + self.assertIn(("family-unit", "a" * 40, "pass"), history[-1]) + self.assertIn(("configured-test", "a" * 40, "unverified"), history[-1]) + + def test_partial_restore_binds_only_authorized_commands(self): + import cycle_history as ch + self.legacy_plan_fixture() + record, _ = self.latest_verification() + plan = ch.restore_plan(record, (self.LEGACY_TEST, "python3 -m unittest tests.unit"), + partial=True) + commands = {check.id: check.command for check in plan.verification.checks} + self.assertEqual(commands["configured-test"], self.LEGACY_TEST) + self.assertEqual(commands["family-unit"], "python3 -m unittest tests.unit") + self.assertEqual(commands["family-browser"], ch.command_digest(self.LEGACY_UNAUTHORIZED)) + self.assertEqual(commands["evidence-integrity"], ch.command_digest("manual:evidence-integrity")) + self.assertIn("pass", {result.result for result in plan.verification.results + if result.id == "family-unit"}) + with self.assertRaises(ch.HistoryError): + ch.restore_plan(record, (self.LEGACY_TEST,)) + + def test_persisted_metadata_is_not_an_identity_conflict(self): + import cycle_history as ch + import verification_plan as vp + criteria = vp.Criteria("github", "b" * 64, "evaluable", ("works",)) + original = vp.Check("clone-suite", "python3 -m unittest tests.clone", "claude", + "deep_coder", "disposable clone (no .env)", True, criteria=("AC-1",), + provenance="agent", reason="private reasoning") + plan = replace(self.plan(start="rereview"), plan_id="plan-meta", head_sha="a" * 40, + verification=vp.VerificationPlan(criteria, (original,))) + store = ch.CycleHistoryStore(self.store.path) + store.save_plan(plan, cycle_id="cycle-meta", change_request_id="4", provenance="stopped") + record = store.latest_plan("owner/api", "API-7", "4") + restored = ch.restore_plan(record, (original.command,)).verification + frozen = restored.checks[0] + self.assertTrue(frozen.context.startswith("ref-")) + self.assertEqual((frozen.provenance, frozen.reason), ("runtime", "runtime")) + for proposal in (original, frozen, replace(original, provenance="project", reason="other")): + with self.subTest(proposal=proposal.context[:8]): + self.assertEqual(restored.extend((proposal,)).checks, (frozen,)) + # The persisted reference is the projection's own rule. + self.assertEqual(vp.persisted_token(original.context), frozen.context) + + def test_genuine_identity_changes_are_rejected_and_borrow_no_evidence(self): + import verification_plan as vp + criteria = vp.Criteria("github", "b" * 64, "evaluable", ("works",)) + frozen = vp.Check("focused", "python3 -m unittest tests.focused", "codex", "cheap_coder", + "project", True, criteria=("AC-1",)) + plan = vp.VerificationPlan(criteria, (frozen,)).record(vp.CheckResult( + "focused", "a" * 40, "pass", ({"command": frozen.command, "exit_code": 0, "executed": 1},))) + changes = {"command": "python3 -m unittest tests.other", "executor": "claude", + "profile": "deep_coder", "context": "boundary", "level": "boundary"} + for field, value in changes.items(): + with self.subTest(field=field): + changed = replace(frozen, **{field: value}) + with self.assertRaises(vp.VerificationError) as caught: + plan.extend((changed,)) + self.assertIn(f"check focused differs in {field} (frozen ", str(caught.exception)) + self.assertNotIn("tests.other", str(caught.exception)) + # A report under the changed identity records nothing at all. + payload, updated = rc._normalize_test_payload({"head_sha": "b" * 40, "tests": { + "version": 2, "conclusion": "verified", "checks": [{**changed.to_dict(), + "head_sha": "b" * 40, "result": "pass", "evidence": [{ + "command": changed.command, "exit_code": 0, "executed": 1}]}]}}, plan) + self.assertIs(updated, plan) + self.assertEqual(updated.current("focused", "b" * 40), "unverified") + self.assertEqual(payload["tests"]["conclusion"], "not_verified") + self.assertIn("check focused differs in " + field, payload["tests"]["rejected"]) + # An agent cannot bind a persisted fingerprint by reporting its command. + unbound = replace(frozen, command="sha256:" + hashlib.sha256(frozen.command.encode()).hexdigest()) + with self.assertRaises(vp.VerificationError) as caught: + vp.VerificationPlan(criteria, (unbound,)).extend((frozen,)) + self.assertIn("frozen unbound sha256:", str(caught.exception)) + with self.assertRaises(vp.VerificationError) as caught: + plan.extend((replace(frozen, required=False, criteria=()),)) + self.assertIn("check focused would lose required, criteria", str(caught.exception)) + + def test_rejected_report_note_is_safe_for_a_terminal(self): + report = self.run_cycle(Talker("codex", block("IMPLEMENTED", head_sha="a" * 40, tests={ + "version": 2, "conclusion": "verified", "checks": [{ + "id": "forged\x1b[2Kline", "command": "python3 -m unittest tests.focused", + "executor": "codex", "profile": "cheap_coder", "context": "project", + "required": True, "head_sha": "a" * 40, "result": "pass", "evidence": "bad"}]})), + Talker("claude", block("APPROVED", head_sha="a" * 40, security_audit_ran=True)), + execution_plan=self.plan(), work_item_provider="github", work_item_repository="owner/api") + notes = [line for _, line in report.notes if "report rejected" in line] + self.assertEqual(len(notes), 1, report.notes) + self.assertNotIn("\x1b", notes[0]) + self.assertIn("must be an array", notes[0]) + + def test_pre_dispatch_identity_stop_names_check_and_fields(self): + import cycle_history as ch + import verification_plan as vp + test = self.LEGACY_TEST + other = "python3 -m unittest tests.legacy" + digest = hashlib.sha256(test.encode()).hexdigest()[:16] + criteria = vp.Criteria("github", "b" * 64, "evaluable", ("works",)) + # A crafted genuine collision: the derived ID already names another command. + verification = vp.VerificationPlan(criteria, ( + vp.Check("configured-test", other, "runtime", "runtime", "project", True), + vp.Check("configured-test-" + digest, other, "runtime", "runtime", "project", True))) + plan = replace(self.plan(start="rereview"), plan_id="plan-crafted", head_sha="a" * 40, + verification=verification) + ch.CycleHistoryStore(self.store.path).save_plan(plan, cycle_id="cycle-crafted", + change_request_id="4", provenance="stopped", stop_reason="stage_not_completed") + reviewer = Talker("claude", block("APPROVED")) + report = self.run_cycle(Talker("codex"), reviewer, start_from="rereview", + change_request_id="4", execution_plan=self.plan(start="rereview"), + allowed_test_command=test, allowed_verify_commands=("python3 -m unittest",), + criteria_reader=lambda *_args, **_kw: criteria, + work_item_provider="github", work_item_repository="owner/api") + self.assertEqual(report.stages, []) + self.assertEqual(reviewer.dispatched, []) + self.assertEqual(report.status, "BLOCKED") + self.assertEqual(report.stop_reason, "readiness_unconfirmed") + self.assertIn(f"cannot rewrite frozen check identity: check configured-test-{digest} " + "differs in command (frozen unbound sha256:", report.stopped_because) + self.assertNotIn(test, report.stopped_because) + self.assertNotIn(other, report.stopped_because) + def test_new_sensitive_diff_promotes_a_previously_omitted_audit(self): from cycle_plan import PlanRuntime import execution_plan as ep