From c1edf83161700b057713ece45e04da537ddeac98 Mon Sep 17 00:00:00 2001 From: datacas <4690232+datacas@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:22:12 +0100 Subject: [PATCH] fix(verification): an unreadable tests block or an unidentified check no longer ends a cycle BLOCKED (#477) A reported frozen check takes the identity fields it omits from the frozen check. A tests block the runtime cannot read is noted and its checks stay not_verified instead of stopping the cycle. An unidentified configured check is not recorded as declined and Codex is no longer told not to run it. Closes #477 --- docs/ROADMAP_V2.md | 2 +- docs/capability-parity-audit.md | 2 +- docs/verification.md | 14 +++++++++++++- scripts/command_permissions.py | 22 ++++++++-------------- scripts/cycle_dispatch.py | 10 +++++++--- scripts/cycle_session.py | 3 +++ scripts/verification_plan.py | 21 ++++++++++++++++++++- tests/test_compose_consent.py | 7 +++---- tests/test_run_cycle.py | 29 ++++++++++++++++++++++------- tests/test_verification_plan.py | 14 +++++++++++++- 10 files changed, 91 insertions(+), 33 deletions(-) diff --git a/docs/ROADMAP_V2.md b/docs/ROADMAP_V2.md index 982592a..74a1d40 100644 --- a/docs/ROADMAP_V2.md +++ b/docs/ROADMAP_V2.md @@ -335,7 +335,7 @@ La asignación de una issue a un milestone expresa el objetivo de planificación | Milestone | Contenido y criterio de salida | |---|---| -| [M1 — Safe, inspectable execution](https://github.com/datacas/code-cycle-toolkit/milestone/1) | #327–#330, #259/#261/#290/#294, presupuestos #262 y overrides #285; #326 coordina. Runtime fixes found while running #330: #385 (P0, manual checks restore and idempotent resume; blocks #330), #382 (host-attested verification evidence) and #383 (malformed stage questions), #388 (P2, manual-check name grammar and resume-history tests; follow-up to #387) and #389 (P1, quota-aware routing and controlled fallback handoff on resume; blocks resolving REV-001 on #384), #394 (P1, resume pending verification when a stored check has no authorized command binding) #395 (P2, quota recovery commands accept repository-registered models), #402 (P2, Orca Task-spec reader binding and stock-CLI documentation; follow-up to #330), #393 (P0, urgent, resume blocked on frozen check identity; fixed in #404) and #406 (P0, urgent, ask the user instead of blocking on any command outside a stage's command table; builds on #289; implemented in #409), plus the stops reported on 2026-11-04, all urgent: #410 (P1, execution plan receipt rejected without naming the field), #411 (P1, remote-ref inspection of the review workspace without retry), #412 (P1, stage report rejected on schema grounds ends BLOCKED after a push), #413 (P1, another rereview of the same head instead of the CI gate), #414 (P1, required verification a review stage cannot run loops on resume), #415 (P1, `cycle_status.py --line` mixes cycles of other repositories) and #416 (P1, `basedpyright` does not fit the verification limit); #383 and #364 are urgent as well. Follow-ups found while running them: #421 (P1, urgent, a base-branch advance by another actor fails running stages as a publication breach or read-only violation), #425 (P1, urgent, an accepted `not_verified` check ends a rereview as `HUMAN_INTERVENTION` instead of `VERIFICATION_INCOMPLETE`) and #422 (P2, `read_operands` misses file operands behind abbreviated grep long options and combined jq short flags; follow-up to #406) and #428 (P0, urgent, interim guidance for the `credential_reads` remedy when `issue_review` cannot read the gh store; docs, skills and test only; the runtime fix is #397), #397 (P0, urgent, `issue_review` reads private GitHub issue data through a runtime snapshot instead of the gh credential store), #398 (P0, urgent, ask for consent to run an authorized Docker Compose `run` verification command instead of refusing it) and #435 (P1, urgent, a `READY` issue review is rejected when the runtime adds `permission_outcomes` to its result; follow-up to #406), #426 (P2, how a cycle reacts to an external base-branch advance: branch update and revalidation; follow-up to #421), #432 (P2, a remote inspection failure after review masks a changed implementer HEAD or working tree; found in #411) and #433 (P2, a review report with an integer `checks[].attempts` is rejected after a completed approval; follow-up to #412) and #377 (P1, per-project and per-check verification timeouts; moved from M2 because the 10-minute limit stopped two consecutive stages on a passing `basedpyright` run that needs 12 min 25 s, see #416), #446 (P1, bounded re-read that confirms a review or rereview publication before retrying; split from #364), #450 (P0, urgent, core principle of agent capability parity: audit and remove the toolkit's own vetoes of what the agent can run) with its child issues for the vetoes no earlier issue tracked, listed in `docs/capability-parity-audit.md`: #455 (P1, consent for compound shell commands instead of a parse-time refusal), #456 (P1, consent for Git, gh and Docker table refusals), #457 (P1, the never-grant boundaries become consent with a risk warning and a record), #458 (P2, run with a disclosed reduced guarantee when the configuration defeats the hook) and #459 (P1, normalize report and receipt representations instead of rejecting them), and #187 (P1, split `CycleRecorder.stage` into typed helpers; moved from M7 because that one 755-line method makes the `basedpyright` check take more than 10 minutes, target 10 s or less, see #416 and #377) and #438 (P0, urgent, one malformed stage receipt in an accumulating comment hides every receipt next to it and blocks a clean review; receipts are read one by one, related to #409 and #412), and #460 (P2, clear a stale declined-check record on a later allow and disclose consented `$TMPDIR` prefixes; follow-up to #398) and #439 (P0, urgent, a grant covers its variants: pattern (regular expression) grants, reads outside the stage workspace and layer attribution of refusals; follow-up to #406) and #464 (P0, urgent, a finished stage ends `BLOCKED` on refused read-only commands and detached runs cannot answer consent; follow-up to #406 and #450)) and #469 (P1, `plan_projection` raises `AttributeError` on a non-object `evidence` item and a finished stage loses its report) and #470 (P1, urgent, the parallel CI gate ends `CI_UNREADABLE` when the plan has no branch-protection API; fall back to declared or SHA check runs; case of #450). Cinco perfiles resolubles, plan inspeccionable, ejecución nativa/Orca coherente, controles obligatorios no reducibles y resume trazable. #147 es independiente y #189 recomendable. Correcciones operativas asignadas siguen condicionadas a reproducción. | +| [M1 — Safe, inspectable execution](https://github.com/datacas/code-cycle-toolkit/milestone/1) | #327–#330, #259/#261/#290/#294, presupuestos #262 y overrides #285; #326 coordina. Runtime fixes found while running #330: #385 (P0, manual checks restore and idempotent resume; blocks #330), #382 (host-attested verification evidence) and #383 (malformed stage questions), #388 (P2, manual-check name grammar and resume-history tests; follow-up to #387) and #389 (P1, quota-aware routing and controlled fallback handoff on resume; blocks resolving REV-001 on #384), #394 (P1, resume pending verification when a stored check has no authorized command binding) #395 (P2, quota recovery commands accept repository-registered models), #402 (P2, Orca Task-spec reader binding and stock-CLI documentation; follow-up to #330), #393 (P0, urgent, resume blocked on frozen check identity; fixed in #404) and #406 (P0, urgent, ask the user instead of blocking on any command outside a stage's command table; builds on #289; implemented in #409), plus the stops reported on 2026-11-04, all urgent: #410 (P1, execution plan receipt rejected without naming the field), #411 (P1, remote-ref inspection of the review workspace without retry), #412 (P1, stage report rejected on schema grounds ends BLOCKED after a push), #413 (P1, another rereview of the same head instead of the CI gate), #414 (P1, required verification a review stage cannot run loops on resume), #415 (P1, `cycle_status.py --line` mixes cycles of other repositories) and #416 (P1, `basedpyright` does not fit the verification limit); #383 and #364 are urgent as well. Follow-ups found while running them: #421 (P1, urgent, a base-branch advance by another actor fails running stages as a publication breach or read-only violation), #425 (P1, urgent, an accepted `not_verified` check ends a rereview as `HUMAN_INTERVENTION` instead of `VERIFICATION_INCOMPLETE`) and #422 (P2, `read_operands` misses file operands behind abbreviated grep long options and combined jq short flags; follow-up to #406) and #428 (P0, urgent, interim guidance for the `credential_reads` remedy when `issue_review` cannot read the gh store; docs, skills and test only; the runtime fix is #397), #397 (P0, urgent, `issue_review` reads private GitHub issue data through a runtime snapshot instead of the gh credential store), #398 (P0, urgent, ask for consent to run an authorized Docker Compose `run` verification command instead of refusing it) and #435 (P1, urgent, a `READY` issue review is rejected when the runtime adds `permission_outcomes` to its result; follow-up to #406), #426 (P2, how a cycle reacts to an external base-branch advance: branch update and revalidation; follow-up to #421), #432 (P2, a remote inspection failure after review masks a changed implementer HEAD or working tree; found in #411) and #433 (P2, a review report with an integer `checks[].attempts` is rejected after a completed approval; follow-up to #412) and #377 (P1, per-project and per-check verification timeouts; moved from M2 because the 10-minute limit stopped two consecutive stages on a passing `basedpyright` run that needs 12 min 25 s, see #416), #446 (P1, bounded re-read that confirms a review or rereview publication before retrying; split from #364), #450 (P0, urgent, core principle of agent capability parity: audit and remove the toolkit's own vetoes of what the agent can run) with its child issues for the vetoes no earlier issue tracked, listed in `docs/capability-parity-audit.md`: #455 (P1, consent for compound shell commands instead of a parse-time refusal), #456 (P1, consent for Git, gh and Docker table refusals), #457 (P1, the never-grant boundaries become consent with a risk warning and a record), #458 (P2, run with a disclosed reduced guarantee when the configuration defeats the hook) and #459 (P1, normalize report and receipt representations instead of rejecting them), and #187 (P1, split `CycleRecorder.stage` into typed helpers; moved from M7 because that one 755-line method makes the `basedpyright` check take more than 10 minutes, target 10 s or less, see #416 and #377) and #438 (P0, urgent, one malformed stage receipt in an accumulating comment hides every receipt next to it and blocks a clean review; receipts are read one by one, related to #409 and #412), and #460 (P2, clear a stale declined-check record on a later allow and disclose consented `$TMPDIR` prefixes; follow-up to #398) and #439 (P0, urgent, a grant covers its variants: pattern (regular expression) grants, reads outside the stage workspace and layer attribution of refusals; follow-up to #406) and #464 (P0, urgent, a finished stage ends `BLOCKED` on refused read-only commands and detached runs cannot answer consent; follow-up to #406 and #450)) and #469 (P1, `plan_projection` raises `AttributeError` on a non-object `evidence` item and a finished stage loses its report) and #470 (P1, urgent, the parallel CI gate ends `CI_UNREADABLE` when the plan has no branch-protection API; fall back to declared or SHA check runs; case of #450) and #477 (P0, urgent, a rereview ends `BLOCKED` on a report missing a frozen check's command and on unidentified checks read as declined; follow-up to #412, #433, #398 and #450). Cinco perfiles resolubles, plan inspeccionable, ejecución nativa/Orca coherente, controles obligatorios no reducibles y resume trazable. #147 es independiente y #189 recomendable. Correcciones operativas asignadas siguen condicionadas a reproducción. | | [M2 — Evidence-based quality and verification](https://github.com/datacas/code-cycle-toolkit/milestone/2) | #146, #156–#159, #191, #260, #251, #331, #367 (condicionada a #329) y #237 refinada; verificación escalable a suites largas #376–#379 (#376 evalúa primero; #380, límite de concurrencia, condicionada a medición); apoyo de #144/#183, CI #145/#231 y trazabilidad #199. Reviews consumen evidencia comparable, behavioral funciona dentro del ciclo y legacy/desconocidos conservan su estado. #332 es opcional. | | [M3 — Supported execution surfaces and DevSecOps pilot](https://github.com/datacas/code-cycle-toolkit/milestone/3) | #219, #238-A y #238-B según su secuencia; #333/#334, #291 donde haga falta y #257; #246/#282 condicionadas. Matriz de ejecutores/proveedores declarada y validada, y piloto security sin nuevas vías de privilegio. | | [M4 — Measured cycle effectiveness](https://github.com/datacas/code-cycle-toolkit/milestone/4) | #185, #192, #180 y #127 (primera entrega: observabilidad descriptiva de revisiones). Tiempos, costes, rework, findings e intervenciones por cohortes con cobertura de datos visible. [#391](https://github.com/datacas/code-cycle-toolkit/issues/391), P2: benchmark offline de reviewers, A/B informativo por caso con etiquetas independientes; entrega experimental independiente, sin bloquear el candidato principal ni depender de #126/#331/#335. | diff --git a/docs/capability-parity-audit.md b/docs/capability-parity-audit.md index 090641c..23e185c 100644 --- a/docs/capability-parity-audit.md +++ b/docs/capability-parity-audit.md @@ -68,7 +68,7 @@ given instead of line numbers so the references stay valid. | PA-31 | When Claude's Bash sandbox cannot start, static-only mode denies verification, Docker, arbitrary-code tool choices and publication, and the checks become `not_verified` | `executors.py` (`static_only=True`); `stage_permissions._check_words` and `_check_git` | No repository code runs unconfined; no host-side push | #345, #348 | toolkit veto | #291 (checks), #457 (publication) | | PA-32 | A Claude dispatch stops `BLOCKED` when the loaded configuration could defeat the hook (`disableAllHooks`, managed `allowManagedHooksOnly`, a `tool.check` mod) or widens the sandbox; a Codex review stops `BLOCKED` when its sandbox cannot be shown to confine writes | `executors.py` `policy_preflight`; `stage_permissions.sandbox_override`, `tool_check_mods`; Codex review sandbox proof | The command table must stay in force | #142 | toolkit veto | #458 | | PA-33 | A configured verification command needs a Bash timeout of at least 600 s, and the toolkit sets `BASH_MAX_TIMEOUT_MS` to the same 600 s, so a longer check cannot finish | `stage_permissions.CLAUDE_VERIFICATION_TIMEOUT_MS`; `executors.py` dispatch environment | Avoids a check timing out early | #416: `basedpyright` needs 12 min 25 s | toolkit veto | #377 | -| PA-34 | A stage report that differs only in representation is rejected: plan receipt keys `plan_revision` / `plan_digest` instead of `revision` / `digest`, `evidence` entries in another shape, unknown keys in a closed result contract. `checks[].attempts` as an integer or a string is no longer rejected: #453 normalizes it | `scripts/review_contract.py` (`PLAN_RECEIPT_KEYS`), `scripts/issue_review.py` (`RESULT_KEYS`), `scripts/verification_plan.py` (`normalize_attempts`) | Machine-readable results | #433, #412, #435 | toolkit veto | #433 (converted by #453 for `attempts`), #459 | +| PA-34 | A stage report that differs only in representation is rejected: plan receipt keys `plan_revision` / `plan_digest` instead of `revision` / `digest`, `evidence` entries in another shape, unknown keys in a closed result contract. `checks[].attempts` as an integer or a string is no longer rejected: #453 normalizes it | `scripts/review_contract.py` (`PLAN_RECEIPT_KEYS`), `scripts/issue_review.py` (`RESULT_KEYS`), `scripts/verification_plan.py` (`normalize_attempts`) | Machine-readable results | #433, #412, #435 | toolkit veto | #433 (converted by #453 for `attempts`), #459; a `tests` block the runtime cannot read no longer stops the cycle `BLOCKED` and a reported frozen check without `command` takes it from the frozen check (#450) | | PA-35 | A grant for a configured verification command does not cover its variants (an environment prefix, `rtk`, a redirection into `$TMPDIR`), so the stage asks again | `grants.py` exact matching; `stage_permissions.operation` | Exact consent | #439 | toolkit veto | #439 | | PA-36 | A publishing stage that reported `BLOCKED` after a verified publication stopped the cycle as `stage_not_completed`. Since #464 a stage that reports `BLOCKED` without `completed_status` is judged on its structured result (`inferred_completion`), and a stop that remains names the real cause apart from the incidental refusals | `scripts/command_permissions.py` `reconcile_result`, `inferred_completion`; `scripts/cycle_dispatch.py` `blocked_cause` | Completion evidence | #364, #446, #397 | toolkit veto | #364 (converted by #449 and #464) | | PA-37 | A review or rereview publication the runtime did not observe was treated as missing and retried without confirming the remote state. Since #446 it is re-read three times with backoff before it is called absent, and a persistent read failure stops the cycle as `BLOCKED` with `publication_unverified`, which is a real limit of the provider and never triggers another publication | `scripts/cycle_publication.py` (`settle_publication`), `scripts/cycle_history.py` (`REREAD_BACKOFF`) | Completion evidence | #446 | toolkit veto | #446 (converted by #446) | diff --git a/docs/verification.md b/docs/verification.md index 1644ed3..7a5bd27 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -254,6 +254,15 @@ number that is not an integer, a boolean, an entry that is neither an object nor a string) is rejected with the field, the received type and the expected shape. `evidence` is not normalized this way. +A report that names a frozen check (`configured-test`, `verify:`) and +leaves out part of its identity (`command`, `executor`, `profile`, `context`, +`required`, `level`, `criteria`) is the same check: the runtime takes the missing +fields from the frozen one. A field the report does give still has to match. +A tests block the runtime cannot read at all never ends the cycle `BLOCKED`: +the stage's report is accepted, a note and a warning name the field, its checks +stay `not_verified`, and the verification barrier asks about them at the end like +any other unverified check. + Freeze each check before execution. A result names the same check ID, command, executor/profile and context. The runtime supplies frozen check identities to later code stages. Copy that identity, including its owning executor/profile, @@ -555,7 +564,10 @@ that obeys the denial and never tries the check again. The cycle ends the pending check; any other final state names it too. To authorize it, revoke the denial with `consent_control.py revoke --repo owner/repo --executor claude|codex --grant-id command_`, resume, and allow it when asked, or -pass `--grant command_=persistent`. A stage that reports `BLOCKED` over +pass `--grant command_=persistent`. A configured check whose +identity the runtime cannot read (`unidentified`) was declined by nobody: it is +not recorded as declined, carries no grant to answer, and the agent's own +evidence, or the plan's `unverified`, decides it. A stage that reports `BLOCKED` over the declined check without a structured completion claim stays `BLOCKED`: the runtime never invents a verdict, and the stop names the check. A denial that would break repository integrity, publication, credentials or security diff --git a/scripts/command_permissions.py b/scripts/command_permissions.py index 163cb2f..3bd2044 100644 --- a/scripts/command_permissions.py +++ b/scripts/command_permissions.py @@ -775,8 +775,9 @@ def codex_compose_consent(policy: Any, broker: Any, executor: str, role: str, try: op = operation("Bash", {"command": command}, policy, cwd) except (ValueError, OSError, TypeError) as error: - context.append(f"Declared Compose check `{command}` could not be identified ({error}). " - "Do not run it; record it unverified with that cause.") + context.append(f"Declared Compose check `{command}` could not be identified by the " + f"runtime ({error}), so no consent was asked and none is recorded. " + "Run it as declared if the project needs it and report what happened.") try: check_id = configured_check_id(list(parse_command(command, policy.tmpdir).words), policy) except ValueError: @@ -852,18 +853,11 @@ def stored_check_denials(policy: Any, executor: str, platform: str, cwd: str | N for command in dict.fromkeys(policy.verification_commands): try: op = operation("Bash", {"command": command}, policy, cwd) - except (ValueError, OSError, TypeError) as error: - # Its identity, and so any stored denial of it, cannot be read now; - # the hook refuses such a run too. It stays unverified, never passed. - try: - check_id = configured_check_id(list(parse_command(command, policy.tmpdir).words), policy) - except ValueError: - check_id = None - if check_id and check_id not in seen: - outcomes.append({"grant": None, "command": command, "answer": "unidentified", - "essential": False, "checks": [], "check": check_id, - "error": str(error), "source": "identity unavailable"}) - seen.add(check_id) + except (ValueError, OSError, TypeError): + # Its identity cannot be read now, so no stored denial can be matched + # to it: that is the toolkit's limit, not a person's decision, and + # it never declines the check (#450). If the agent ran it, its own + # evidence counts; if not, the plan leaves it unverified. continue if not op.get("check") or op["check"] in seen: continue diff --git a/scripts/cycle_dispatch.py b/scripts/cycle_dispatch.py index 1043cd3..759b212 100644 --- a/scripts/cycle_dispatch.py +++ b/scripts/cycle_dispatch.py @@ -766,9 +766,13 @@ def advance(self: Any, role: str, instruction: str = "", *, if isinstance(rejected, str): reason = self.rejected_report_reason( role, rejected, reported.payload or {}, change_request_id) - self.report.note(f" verification {reason}") - return reported, self.stop(reason, "BLOCKED", reported=reported, - stop_reason="verification_unverified") + # A report the runtime cannot read is no reason to stop (#450): + # its checks stay not_verified, which the verification barrier + # asks about at the end, and the cycle goes on. + self.report.note(f" verification {reason}; its checks stay not_verified " + "and the cycle continues") + if self.report.stage_details: + self.report.stage_details[-1].warnings.append(reason) try: self.record_plan_stage(role, reported, outcome, test_payload) except ValueError as error: diff --git a/scripts/cycle_session.py b/scripts/cycle_session.py index e32184a..33eb913 100644 --- a/scripts/cycle_session.py +++ b/scripts/cycle_session.py @@ -293,6 +293,9 @@ def note_declined_check(self, outcome: dict[str, Any], head_sha: Any) -> None: if answer in self._CHECK_ALLOWS: self.state.declined_checks.pop(check, None) return + if answer == "unidentified": + # The runtime could not read the command's identity: nobody declined it. + return if answer in {None, "pending"} and not outcome.get("executor_hook"): # A refusal still unanswered is asked before the stage reruns, and # an unanswered question stops the cycle as permission_required. diff --git a/scripts/verification_plan.py b/scripts/verification_plan.py index 949f57f..c0666e6 100644 --- a/scripts/verification_plan.py +++ b/scripts/verification_plan.py @@ -197,6 +197,25 @@ def same(field: str, old: Any, new: Any) -> bool: if not same(field, getattr(frozen, field), getattr(proposed, field))) +def _with_frozen_identity(entry: Mapping[str, Any], plan: VerificationPlan) -> Mapping[str, Any]: + """A reported check with what it leaves out taken from the frozen check of its ID. + + A report that names a check the runtime already froze (`configured-test`, + `verify:`) and only says how it ended is the same check, not a + malformed one (#450). Fields the report does give are kept as given, so a + real identity change is still caught by `extend`. + """ + frozen = next((check for check in plan.checks if check.id == entry.get("id")), None) + if frozen is None: + return entry + defaults = {"command": frozen.command, "executor": frozen.executor, + "profile": frozen.profile, "context": frozen.context, + "required": frozen.required, "level": frozen.level, + "criteria": list(frozen.criteria), "provenance": frozen.provenance, + "reason": frozen.reason} + return {**{key: value for key, value in defaults.items() if key not in entry}, **entry} + + def safe_value(field: str, value: Any) -> str: """A diagnostic form that never reveals a command, path or free text. @@ -522,7 +541,7 @@ def normalize_tests(tests: Mapping[str, Any], head_sha: str, *, if not isinstance(entry, dict): raise _invalid_field(f"checks[{index}]", entry, "a check object") try: - check = Check.from_dict(entry) + check = Check.from_dict(_with_frozen_identity(entry, plan)) except (VerificationError, KeyError, TypeError, ValueError) as error: detail = str(error) or type(error).__name__ raise VerificationError(f"checks[{index}] {detail}") from error diff --git a/tests/test_compose_consent.py b/tests/test_compose_consent.py index 22513e7..cfff31e 100644 --- a/tests/test_compose_consent.py +++ b/tests/test_compose_consent.py @@ -563,15 +563,14 @@ def test_a_denied_check_the_agent_skipped_is_still_declined(self): self.assertEqual(spoken, cp.add_stored_denials(spoken, allow_policy, "claude", "linux", str(self.clone))) - def test_a_check_whose_identity_cannot_be_read_stays_unverified(self): + def test_a_check_whose_identity_cannot_be_read_is_not_declined(self): request, broker = self.request() self.answer(request, broker, "deny") policy = replace(self.policy(), command_permissions=broker.command_entries("claude", "review")) (self.clone / "docker-compose-local.yml").unlink() spoken = 'ORCHESTRATION_RESULT\n{"status": "APPROVED"}\nEND_ORCHESTRATION_RESULT' - added = cp.add_stored_denials(spoken, policy, "claude", "linux", str(self.clone)) - [outcome] = cp._result_block(added)[2]["permission_outcomes"] - self.assertEqual(("unidentified", "verify:" + CHECK), (outcome["answer"], outcome["check"])) + # Nobody declined it: the toolkit cannot read it, so it records no denial (#450). + self.assertEqual(spoken, cp.add_stored_denials(spoken, policy, "claude", "linux", str(self.clone))) class CodexTests(ComposeConsentCase): diff --git a/tests/test_run_cycle.py b/tests/test_run_cycle.py index 1690d78..8dd0188 100644 --- a/tests/test_run_cycle.py +++ b/tests/test_run_cycle.py @@ -740,8 +740,22 @@ def test_rejected_report_note_is_safe_for_a_terminal(self): self.assertIn("received str", notes[0]) self.assertIn("expected an array", notes[0]) self.assertIn("published head (reported-unverified): " + "a" * 40, notes[0]) - self.assertEqual(report.status, "BLOCKED") - self.assertEqual(report.stop_reason, "verification_unverified") + # An unreadable report never stops the cycle by itself (#450). + self.assertNotEqual(report.status, "BLOCKED") + self.assertIn("the cycle continues", notes[0]) + + def test_an_unidentified_check_is_not_recorded_as_declined(self): + # The runtime could not read the command: nobody declined it (#450). + from cycle_session import CycleSession + session = object.__new__(CycleSession) + session.state = type("State", (), {"declined_checks": {}})() + session.note_declined_check({"check": "verify:docker compose run x", "grant": None, + "command": "docker compose run x", "answer": "unidentified"}, + "a" * 40) + self.assertEqual({}, session.state.declined_checks) + session.note_declined_check({"check": "verify:y", "grant": "command_1", "command": "y", + "answer": "deny"}, "a" * 40) + self.assertEqual(["verify:y"], list(session.state.declined_checks)) def test_null_tests_payload_preserves_previous_outcome(self): payload = {"status": "IMPLEMENTED", "tests": None} @@ -816,11 +830,12 @@ def test_rejected_report_stops_real_advance_for_each_code_stage(self): ci_policy="strict", ci_checks=checks, code_host="github", work_item_provider="github", work_item_repository="owner/api") - self.assertEqual("BLOCKED", report.status, report.explain()) - self.assertEqual("verification_unverified", report.stop_reason) - self.assertIn("published head (reported-unverified): " + "a" * 40, - report.stopped_because) - self.assertEqual([role], [stage.role for stage in report.stages]) + # The rejected report is noted, never a stop of its own (#450). + self.assertNotEqual("BLOCKED", report.status, report.explain()) + notes = [line for _, line in report.notes if "report rejected" in line] + self.assertEqual(1, len(notes), report.notes) + self.assertIn("published head (reported-unverified): " + "a" * 40, notes[0]) + self.assertEqual(role, report.stages[0].role) def test_pre_dispatch_identity_stop_names_check_and_fields(self): import cycle_history as ch diff --git a/tests/test_verification_plan.py b/tests/test_verification_plan.py index f4ca61b..2ceded9 100644 --- a/tests/test_verification_plan.py +++ b/tests/test_verification_plan.py @@ -417,9 +417,21 @@ def test_v2_rejections_describe_field_received_type_and_expected_format(self): self.assertIn(type(value).__name__, message) self.assertIn(expected, message) - def test_v2_missing_required_check_field_names_missing_value_and_format(self): + def test_v2_frozen_check_left_without_identity_fields_takes_them_from_the_plan(self): + # A report naming a frozen check and only how it ended is that check (#450). plan = vp.VerificationPlan(criteria(), (check(),)) entry = report() + for field in ("command", "executor", "profile", "context", "required"): + del entry[field] + normalized, updated = vp.normalize_tests( + {"version": 2, "conclusion": "verified", "checks": [entry]}, HEAD, plan=plan) + [reported] = normalized["checks"] + self.assertEqual(check().command, reported["command"]) + self.assertEqual(plan.checks, updated.checks) + + def test_v2_missing_required_check_field_names_missing_value_and_format(self): + plan = vp.VerificationPlan(criteria(), (check(),)) + entry = report(id="another-check") del entry["command"] with self.assertRaises(vp.VerificationError) as caught: vp.normalize_tests({"version": 2, "conclusion": "not_verified",