From d75ee7d0043d774e350ea98504775816f74fe171 Mon Sep 17 00:00:00 2001 From: arpan Date: Mon, 14 Sep 2026 03:32:32 +0530 Subject: [PATCH 1/2] Release 0.10.0: version, changelog, section 11, and the exit criteria Version 0.10.0. The CHANGELOG [0.10.0] section is undated, lists what each item added, and lists every behaviour that became stricter with what it did before. Section 11 written in one pass. Its finding: every sentence this document wrote about what a LATER ITEM would do was wrong. Three review rounds ran, all 44 citations round three checked were exact, and the citation discipline worked. What broke was one shape: section 3.4.3's "until item 2 lands", section 4.3's check 2 described as though it had a source, section 5.2's "move observe mode's policy_unapproved", section 6.4's scan line, section 4.2's "the loader checks that correspondence". Each was settled prose about code that did not exist yet; each shipped differently or not at all, and nothing turned red in between. The rule that follows is that a spec sentence whose truth depends on a later item is a test that item owes, named in that item's table, or it is not in the document. Section 5.2 amended to describe what shipped. Round three adjudicated: keep the code, fix the text. The unit of ordering is the POSITION, not the reason, because approval_required and precondition_changed are both members of BLOCKED_APPROVAL_REASONS and enforce raises them on opposite sides of _in_scope. The upgrade check ran against the RELEASED 0.9.0 from PyPI, not a fixture. A store written by 0.9.0, opened by this build: the migration runs, the chain verifies across the v6/v7 boundary (4 receipts, 0 breaks), and 0.9.0 then reads it back cleanly. Then one hop is created and 0.9.0 answers authority_unreadable for an UNRELATED principal's action, which is section 9.3's claim measured: the irreversible step is creating the first hop, not installing 0.10.0. Kept as tests so a later change goes red in the ordinary suite rather than only in a release rehearsal. Section 7.3's exit criterion is met. examples/authority-escalation now pins an upstream, and G25, G26 and G27 all grade PASS on it, each the same under --only as in a full run; the parametrized agreement test covers all three. Adding that pin found one more defect, which is why the example earns its place: verify drives its scenarios in-process, where a pinned action refuses on every call, so G2 selected the pinned action and reported the kernel broken. select() now skips a pinned action unless a scenario asks for it by name, which is G27's and nobody else's. Also: the SPDX header on the new test, the citation's version, and the two adapters' kernel ranges, which widen to admit 0.10 because v0.5's adapter contract is not reopened. Gate with Postgres: 4441 passed, 0 skipped. Demo runs with no network. `import ctrlrun` pulls in no extra and does not load verify. Runtime dependencies are still pyyaml and click. Signed-off-by: arpan --- CHANGELOG.md | 62 +++++++++ CITATION.cff | 2 +- adapters/langgraph/README.md | 2 +- adapters/langgraph/pyproject.toml | 2 +- .../src/ctrlrun_langgraph/__init__.py | 2 +- adapters/openai-agents/README.md | 2 +- adapters/openai-agents/pyproject.toml | 2 +- .../src/ctrlrun_openai_agents/__init__.py | 2 +- docs/SPEC-v0.10.md | 129 ++++++++++++++---- examples/authority-escalation/ctrlrun.yaml | 37 ++++- pyproject.toml | 2 +- src/ctrlrun/verify/scenarios.py | 11 ++ tests/test_upgrade_0_9_to_0_10.py | 129 ++++++++++++++++++ tests/test_verify_authority.py | 2 +- 14 files changed, 352 insertions(+), 34 deletions(-) create mode 100644 tests/test_upgrade_0_9_to_0_10.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f19aeee..ef6ea76d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,68 @@ All notable changes to this project are documented here. The format follows Public API names are frozen in `docs/SPEC-v0.1.md` §8. Before 1.0 they may still change, and any change to one appears here. +## [0.10.0] — Multi-agent + +One question: when one agent hands work to another, what does the second one hold? + +### Added + +- **A hop: authority that crosses an agent boundary.** A hop is `SPEC-v0.3.md` §5's delegation over + a boundary the kernel does not control, and almost nothing about it is new machinery. The same + `contained_dimension` decides it, over all eight dimensions, at creation and again at every + evaluation. The same `delegation_id` names it. `SPEC-v0.9.md` §2.7's walk charges it, so a + consumption under a hop costs the **issuer** and every ancestor to the root. + + **One rule is new, and it is the whole of what v0.10 adds: an action proposed under a hop is + evaluated against that hop's grant alone, with no fallback.** Before it, `Authority.evaluate` + passed on any matching grant, so a receiving agent holding a grant of its own was authorised by + that one, the hop was never consulted, and the issuer's budget paid nothing. Which way it went + turned on how two identifiers sorted. + +- `Control.hop(parent_id, grant, *, by, action_id=None)`, `hop=` on `@protect`, `Control.execute` + and `Control.evaluate`, and `hop` in the metadata the gateway and the ACS hook already carry. +- **`ctrlrun.receipt/v7`** adds `hop`: the hop an action ran **under**, never the one it created. +- **`ctrlrun.policy/v8`** adds `upstream:` on an action entry, pinning the server an action + authorises itself against by certificate or by the hash of its advertised tool schema. +- **`ctrlrun.guarantees/v6`**: G25 `a hop narrows or it is refused`, G26 `a hop is named on both + sides`, G27 `a swapped upstream is denied`. +- `ctrlrun inspect --hop` emits `ctrlrun.hop/v1`: who issued a hop, and **which dimensions each + link narrowed**, which is the question an operator paged at 3am actually has. +- `ctrlrun scan` names the principals holding a grant no hop bounds. It reports and does not score. + +### Changed + +Every behaviour below is **stricter** than 0.9.0, with what it did before. + +- **An action presented with a hop is decided against that hop alone.** Before: every grant the + principal held was a candidate and the narrowest-sorting one decided. An action presented with + **no** hop is decided exactly as 0.9.0 decided it, which is why every existing deployment + upgrades untouched. +- **A resumed leg is evaluated on the task and the hop.** Before (`v0.9 §6.3.2`), the task + dimension was not evaluated on a resumed leg at all, because the rehydrated action carried none; + `EXECUTION_STARTED` now carries both and `_resumed_context` reads them back. A leg suspended by + **0.9.0** carries neither and is evaluated exactly as 0.9.0 evaluated it, or every action in + flight across the upgrade would be denied. +- **A lease extension is decided against the hop the first leg held**, on every round. Before, and + briefly during this milestone, a receiver holding a grant of its own kept its reservation across + a round trip after the hop was cut. +- **Observe mode reports the refusal enforce mode would raise.** Before (`v0.9 §4.2.1b`), it + reported whichever refusal it reached first, which was not always the same one. +- **A policy that pins an upstream refuses an action the process has not verified one for.** + Before, no such key existed. In-process there is no upstream to observe, so a pinned action + refuses on every call; the ACS hook refuses such a policy at construction. + +### The upgrade, and the one irreversible thing + +`0007_budget_ledger` is still the last migration: receipts and events are stored as whole JSON +documents and `created_via` is already `TEXT`, so v0.10 needs no schema change. + +**The irreversible step is creating the first hop, not installing 0.10.0.** `CreatedVia` is a closed +vocabulary and `Authority._candidates` reads every delegation row before filtering any of them, so a +0.9.x binary meeting one `created_via='hop'` row answers `authority_unreadable` for **every action in +the deployment**, not just that delegation. Fail-closed, and a deployment that stops. A deployment +that installs 0.10.0 and creates no hop can still roll back. + ## [Unreleased] ### Added diff --git a/CITATION.cff b/CITATION.cff index a5059c37..b9449123 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -11,7 +11,7 @@ authors: - family-names: Ghoshal given-names: Arpan email: contact@arpanghoshal.com -version: 0.9.0 +version: 0.10.0 repository-code: https://github.com/CTRLRun/ctrlrun url: https://github.com/CTRLRun/ctrlrun license: Apache-2.0 diff --git a/adapters/langgraph/README.md b/adapters/langgraph/README.md index 85e11ce2..04b524bc 100644 --- a/adapters/langgraph/README.md +++ b/adapters/langgraph/README.md @@ -3,7 +3,7 @@ Route a CTRLRun `APPROVE` through **LangGraph's own `interrupt()`**, so the human answers where your LangGraph users already answer. -- **Supported kernel range:** `ctrlrun>=0.5,<0.10` +- **Supported kernel range:** `ctrlrun>=0.5,<0.11` - **Supported framework range:** `langgraph>=1.0,<2.0` - **Primitive reused:** [`interrupt()` and `Command(resume=...)`](https://langchain-ai.github.io/langgraph/how-tos/human_in_the_loop/add-human-in-the-loop/), with a checkpointer. Read 2026-09-05. - **Framework shape:** resumed in place (SPEC-v0.5 §3.5). diff --git a/adapters/langgraph/pyproject.toml b/adapters/langgraph/pyproject.toml index 13c49a91..9e6bce60 100644 --- a/adapters/langgraph/pyproject.toml +++ b/adapters/langgraph/pyproject.toml @@ -26,7 +26,7 @@ classifiers = [ # surface that has not been written. The README states the same two, and T137 asserts that what # it states is what CI installed. dependencies = [ - "ctrlrun>=0.5,<0.10", + "ctrlrun>=0.5,<0.11", "langgraph>=1.0,<2.0", ] diff --git a/adapters/langgraph/src/ctrlrun_langgraph/__init__.py b/adapters/langgraph/src/ctrlrun_langgraph/__init__.py index 9f786a25..e82d722c 100644 --- a/adapters/langgraph/src/ctrlrun_langgraph/__init__.py +++ b/adapters/langgraph/src/ctrlrun_langgraph/__init__.py @@ -17,7 +17,7 @@ buys one thing over it: the interrupt. If your graph has nowhere for a human to answer, or you are happy for `ApprovalRequired` to reach your own code, use `@protect` and stop here. -Supported kernel range: `ctrlrun>=0.5,<0.10`. Supported framework range: `langgraph>=1.0,<2.0`. +Supported kernel range: `ctrlrun>=0.5,<0.11`. Supported framework range: `langgraph>=1.0,<2.0`. `README.md` states both, and what this adapter's binding check is and is not. """ diff --git a/adapters/openai-agents/README.md b/adapters/openai-agents/README.md index 7a1a5737..35db19e8 100644 --- a/adapters/openai-agents/README.md +++ b/adapters/openai-agents/README.md @@ -3,7 +3,7 @@ Route a CTRLRun `APPROVE` through the **OpenAI Agents SDK's own tool-approval interruption**, so the human answers where this SDK's users already answer. -- **Supported kernel range:** `ctrlrun>=0.5,<0.10` +- **Supported kernel range:** `ctrlrun>=0.5,<0.11` - **Supported framework range:** `openai-agents>=0.20,<1.0` - **Primitive reused:** [`needs_approval`, `RunResult.interruptions`, `RunState.approve` / `reject`](https://openai.github.io/openai-agents-python/tools/). Read 2026-09-05. - **Framework shape:** decided before invocation (SPEC-v0.5 §3.5). diff --git a/adapters/openai-agents/pyproject.toml b/adapters/openai-agents/pyproject.toml index 78887ddc..bff8bb61 100644 --- a/adapters/openai-agents/pyproject.toml +++ b/adapters/openai-agents/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ # not before, so `>=0.5` would claim compatibility with a surface not yet written. T137 asserts # the README states these and that CI ran inside them. dependencies = [ - "ctrlrun>=0.5,<0.10", + "ctrlrun>=0.5,<0.11", "openai-agents>=0.20,<1.0", ] diff --git a/adapters/openai-agents/src/ctrlrun_openai_agents/__init__.py b/adapters/openai-agents/src/ctrlrun_openai_agents/__init__.py index dd89315d..8489ad0e 100644 --- a/adapters/openai-agents/src/ctrlrun_openai_agents/__init__.py +++ b/adapters/openai-agents/src/ctrlrun_openai_agents/__init__.py @@ -17,7 +17,7 @@ **You probably do not need this.** `@protect` covers anything in this process with no adapter and no framework support. This buys the interrupt and nothing else. -Supported kernel range: `ctrlrun>=0.5,<0.10`. +Supported kernel range: `ctrlrun>=0.5,<0.11`. Supported framework range: `openai-agents>=0.20,<1.0`. `README.md` states both, and states why this adapter's binding is **attribution** where LangGraph's is prevention. diff --git a/docs/SPEC-v0.10.md b/docs/SPEC-v0.10.md index d9314ad6..a3988905 100644 --- a/docs/SPEC-v0.10.md +++ b/docs/SPEC-v0.10.md @@ -1136,9 +1136,23 @@ a refactor rather than as a fifth patch, and it is the reason this item gets the ### 5.2 What lands -**The order is declared once, as data, and both modes walk it.** One ordered sequence of checks, -each naming its reason, with the enforcing path raising at the first that fails and the observing -path recording the first that fails. Neither keeps a copy of the order. +**The order is declared once, as data, and every check that can refuse declares where it sits in +it.** The enforcing path raises at the first that fails; the observing path records the one +**earliest in the declared order**, whatever order it happened to reach them in. + +**The unit of ordering is the POSITION, not the reason**, and that is the correction three review +rounds converged on. A first design ranked the reason. It cannot work, for two structural reasons: +`approval_required` and `precondition_changed` are both members of `BLOCKED_APPROVAL_REASONS` and +enforce mode raises them on **opposite sides** of `_in_scope`, so no single rank for the second is +both above and below `out_of_scope`; and the attempt-ceiling fast path runs in `Control.execute` +before `_secure` is called at all, so its reason outranks everything `_secure` decides. A review +demonstrated three live regressions from the reason-ranked version, on pairs the previous build had +right. + +**No check moves**, and that is deliberate: v0.9 aligned three cases by reordering and the three +reorderings produced four regressions between them (§13.8), which is §5.1's whole argument. What +changes is that each `observation.block()` call site names its position, so the observing path can +report the reason enforce mode would raise without either path being rearranged. **The list starts at `Control.execute`'s entry, not at `_secure`.** An earlier draft said "`_secure` raising ... and `_observe_secure` recording", which reads as though the sequence lived inside those @@ -1163,9 +1177,12 @@ the door this paragraph left open. **The order to declare is already written down, as a comment.** `control.py:1296-1297` says `principal_expired -> authority -> policy -> approval -> reservation -> execution`. Item 4's work is -to make that comment the data both paths walk, extended with the checks `_secure` adds beneath -`approval`, and to move observe mode's `policy_unapproved` to the point the list gives it rather -than the point `_observe_secure` happens to run. +to make that comment the data every refusing check names itself against, extended with the points +`_secure` adds beneath `approval` and with the ceiling fast path above it. + +**Nothing is moved to a point**, which an earlier draft of this paragraph asked for. Observe mode's +`policy_unapproved` stays where it is and declares `POLICY_UNAPPROVED`, which is above authority; +the reported reason is then enforce mode's without either path being rearranged. **So this item's extent is `Control.execute` and the two `_secure` methods**, and §5.4's "two methods" is amended here rather than left to contradict this paragraph. Moving one check inside @@ -1634,21 +1651,85 @@ installing 0.10.0. A deployment that installs and creates none can still roll ba *One subsection per question the drafting could not close, each stating what the code decided and which section carries it. `SPEC-v0.4.md` §12 through `SPEC-v0.9.md` §13 are the format.* -**This section is empty on purpose, and item 6 writes it in one pass**, on the pace decision -`SPEC-v0.9.md` §13 records: the spec amendment an item owes as it lands is its §9 name row, its MUST -sentences and its §10 fail-closed row, and the prose explaining what building settled is written -once over the finished milestone rather than five times over guesses. - -**The cost of that, and what pays it**, restated because v0.9 found it real: written at the end, this -section loses the during-the-milestone signal that a reviewer uses to tell which claims have been -stress-tested by somebody other than their author. What replaces it is unchanged, **an item that -settles something surprising leaves a line in its `CHANGELOG` entry when it lands**, and item 6 -writes §11 from those lines. An item whose PR body reports a question it could not settle has -already written its §11 entry and should say so. - -**And one thing is already known to belong here, so it is named rather than rediscovered.** -`SPEC-v0.9.md` §13.8's rate is the number to beat: across two review rounds on finished v0.9 code, -roughly seventeen defects, about half of them introduced by fixing the other half, and not one of -them an action running that should have been refused. Every one was reporting, tooling, or observe -mode. **Item 4 rewrites observe mode**, which is where that half lived, so this milestone's own -version of that count is the first thing §11 should carry. +### 11.0 What the milestone settled about itself + +**Every sentence this document wrote about what a later item would do was wrong.** That is the +finding, and it is sharper than v0.9's because it names a shape rather than a rate. + +`SPEC-v0.9.md` §13.0 established that a specification is reliable on facts checkable by reading one +line and unreliable on facts that need a value followed through two modules, and this document took +that seriously: three review rounds ran, **every one of the 44 line citations round three checked +was exact**, and §1.4 recorded two probes that changed the draft before it was written. The +citation discipline worked. + +What broke was a different class, and round three named it: **a sentence about what a later item +would do.** §3.4.3's "one residual, until item 2 lands". §4.3's check 2, described in a table as +though it had a source. §5.2's "move observe mode's `policy_unapproved`". §6.4's `scan` line. +§4.2's "the loader checks that correspondence". Each was written as settled prose about code that +did not exist yet; each shipped differently or not at all, and nothing turned red in between. + +**The rule that follows, and it is this milestone's contribution to the series: a spec sentence +whose truth depends on a later item is a test that item owes, named in that item's table, or it is +not in the document.** A residual that expires when another PR merges needs a red test at the flip, +not a note. + +### 11.1 The mutation table found three guards nothing exercised, and the independent reviews +found what the mutation table could not + +Items 2, 4 and 5 each shipped a guard no test covered, each caught by mutating the source. That is +v0.9's lesson holding. **What it did not catch is the class that mattered most**, and the three +independent reviews did: + +- a **reason-ranked** decision order, which cannot express enforce mode's real order because the + approval axis straddles the scope check and the ceiling fast path sits above `_secure` + entirely. The review demonstrated three live regressions on pairs the parent commit had right; +- `Control.resume` dropping the hop one frame below where it recovered it, so a receiver holding + any grant of its own kept its reservation **after the hop was cut**; +- **nothing in the product observing an upstream**, so two of §10's rows described outcomes no + shipped code path could produce. + +Each is a claim that crosses two modules, and each was invisible to a test written by the person +who wrote the code. The standing rule that an independent reviewer reads **every file that calls +into the changed code, not just the diff**, is what found all three, and `v0.3`'s own record says +why: a self-review of that spec found four defects, and an independent one found two authorization +holes visible only from a file the spec did not mention. + +### 11.2 A test can be green, mutated, and still prove nothing + +Item 4's T498 was generated, non-empty, and asserted the wrong thing: it compared +`_Observation.block` against the same rank function `block` itself calls. A review inverted the +declared order at §5.1's own first named case and **all 4,062 tests stayed green**. + +The rule §5.3 already carried, restated because it needed teeth: a property test over the kernel's +internals is a restatement of an implementation. **T498 drives `Control.execute` in both modes**, +and the acceptance criterion for it is that inverting `DECISION_ORDER` turns something red. + +### 11.3 The order is a property of positions, not of reasons + +§5.2 prescribed one ordered list both modes walk, with checks moved to declared points. Item 4 +shipped rank-selection instead, on the sound argument that v0.9's three reorderings produced four +regressions and a change that moves nothing cannot regress a position. **That argument was right +about the risk and wrong about the mechanism.** + +A reason cannot carry the ordering, for two reasons that are structural rather than incidental: +`precondition_changed` and `approval_required` are both members of `BLOCKED_APPROVAL_REASONS` and +enforce mode raises them on opposite sides of `_in_scope`, so no single rank for the first is both +above and below `out_of_scope`; and the attempt-ceiling fast path runs in `execute` before `_secure` +is called at all. So the **call site declares where it is**, and the reason travels as evidence. + +### 11.4 What v0.10 did not close + +Stated here rather than discovered by a reader. + +- **A receiving agent can decline to present the hop it was given.** §2.3.2. The kernel records + which happened and §6.4's `scan` line makes the deployment rule checkable; it cannot compel the + choice. +- **A receiver holding two hops presents whichever it likes.** §3.1.1, and the task that would + narrow it arrives from the caller too. +- **Cross-store propagation.** §3.2, forced by rule 2 and refused fail-closed. +- **A hop created inside an action is not named on its creator's receipt.** §3.4's issuer row + describes it; `Receipt.hop` carries the hop an action ran *under*, and the created one is named + by `DELEGATION_CREATED` and its `action_id`. Rule 3 holds through the event, not the receipt. +- **`Receipt.hop` is read from a context variable at receipt time**, so a nested `evaluate` inside + an executor overwrites it. Pre-existing in class for `Receipt.task` since v0.9. + diff --git a/examples/authority-escalation/ctrlrun.yaml b/examples/authority-escalation/ctrlrun.yaml index c722e712..9f45a086 100644 --- a/examples/authority-escalation/ctrlrun.yaml +++ b/examples/authority-escalation/ctrlrun.yaml @@ -4,7 +4,7 @@ # `authority:` needs `ctrlrun.policy/v3`: a reader that ignored the section would run every # action with no authority check at all, and the schema string is the only thing standing # between those two outcomes. -schema: ctrlrun.policy/v3 +schema: ctrlrun.policy/v8 authority: # How deep a chain may go. The default is 3; it is stated here because a reader deciding @@ -15,6 +15,16 @@ authority: # A human's own authority: €100,000 of refunds, and the right to delegate a narrower # slice of it. `delegable: true` requires an `expires_at` — authority that can mint more # authority and never lapses is the one shape this model refuses to write down. + # SPEC-v0.10 §4, §7.3. The grant that covers the pinned action below, so `ctrlrun verify` + # can grade G27 against this document rather than report it `N/A`. No `constraints:`, + # because a payout carries no `amount` for one to compare and a constraint whose argument is + # absent never holds (`v0.1 §3.2`), which would leave the grant matching nothing. + - id: payouts + subject: { agent: "payout-agent" } + actions: ["stripe.payout"] + resources: ["payout:*"] + environments: ["production"] + - id: head-of-support subject: { agent: "head-of-support", user: "dana@example.com" } actions: ["stripe.refund"] @@ -37,3 +47,28 @@ actions: - when: { amount_gte: 0, amount_lte: 1000000 } # to €10,000.00 — a human decides decision: approve - decision: deny + + # SPEC-v0.10 §4. **Which server this action authorises itself against.** A grant says who may + # propose an action; this says which upstream the action may be sent to, so a swapped MCP + # server behind the same name, or a tool whose schema moved under an approved action name, is + # a DENY rather than an authorised call to something nobody reviewed. + # + # The honest slice of `ASI04` and nothing more: CTRLRun decides actions and never inspects a + # package, a model, a registry or a build. + # + # **This entry is here to be read and graded, and `main.py` does not call it**, which is the + # one thing about it that needs saying out loud. §4.4: pinning is enforced by the surface that + # holds the connection, which is `ctrlrun gateway`. In-process there is no upstream to observe, + # so an action that pins one refuses on every in-process call with `upstream_unverified`. That + # is correct and it is loud, and it is not a demo. Front this policy with + # `ctrlrun gateway --upstream https://your-mcp-server` and the pin is checked three ways: + # at startup, at the decision, and at the TLS handshake. + stripe.payout: + effect: "payout:{payout_id}" + resource: "payout:{payout_id}" + decision: approve + upstream: + # Replace with your server's leaf certificate digest. A LIST, so the current and the next + # certificate can both be named across a rotation without an outage (§4.2). + tls_cert_sha256: + - "sha256:0000000000000000000000000000000000000000000000000000000000000000" diff --git a/pyproject.toml b/pyproject.toml index dd787cbe..42f2db66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ctrlrun" -version = "0.9.0" +version = "0.10.0" description = "The execution safety layer for AI agents." # Mirrors the repository's GitHub topics, so PyPI search and GitHub search agree. keywords = [ diff --git a/src/ctrlrun/verify/scenarios.py b/src/ctrlrun/verify/scenarios.py index 6b5ac4ee..7c105264 100644 --- a/src/ctrlrun/verify/scenarios.py +++ b/src/ctrlrun/verify/scenarios.py @@ -806,6 +806,17 @@ def select( for name in sorted(self.policy.actions): if action_filter is not None and not action_filter(name): continue + if action_filter is None and self.policy.upstream_pin(name): + # SPEC-v0.10 §4.4 — an action that pins an upstream refuses on **every** + # in-process call, because in-process there is no upstream to observe. `verify` + # drives its scenarios in-process, so such an action can only be driven by a + # scenario that seeds an observation first, which is G27's and nobody else's. + # + # Without this, a shipped example that pins (which §7.3's exit criterion + # requires) turns every guarantee that happens to select that action into a + # `VerifyInternalError`: the pin refuses, and the scenario reports the kernel + # broken. Found by adding the pin §7.3 asks for and watching G2 fail. + continue if needs_effect and self.policy.effect_template(name) is None: continue ceiling = self.policy.max_attempts(name) diff --git a/tests/test_upgrade_0_9_to_0_10.py b/tests/test_upgrade_0_9_to_0_10.py new file mode 100644 index 00000000..76863e5d --- /dev/null +++ b/tests/test_upgrade_0_9_to_0_10.py @@ -0,0 +1,129 @@ +# SPDX-FileCopyrightText: 2026 The CTRLRun contributors +# SPDX-License-Identifier: Apache-2.0 +"""The upgrade path, and the one irreversible thing a v0.10 release does. + +`SPEC-v0.10 §9.3`. Run against the **released** 0.9.0 from PyPI by the release pass, not against a +fixture: a fixture is a claim about what 0.9.0 did, and the check exists because the claim might be +wrong. What is kept here is the shape and the invariant, so a later change that breaks it goes red +in the ordinary suite rather than only in a release rehearsal. + +Measured during the 0.10.0 release pass, `pip install ctrlrun==0.9.0` into a clean venv: + + 0.9.0 wrote: 3 receipts; chain ok: True; schemas: ['ctrlrun.receipt/v6'] + this build: migration ran, store opens: True + schemas in one chain: ['ctrlrun.receipt/v6', 'ctrlrun.receipt/v7'] + chain verifies ACROSS it: True (verified 4, breaks 0) + 0.9.0 again: reads 4 receipts, chain ok, breaks [] + after ONE created_via='hop' row: + 0.9.0 decides an UNRELATED principal's action: False authority_unreadable +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +import pytest + +from ctrlrun.action import Action, Principal +from ctrlrun.authority import Authority, grant_from_yaml +from ctrlrun.control import Control +from ctrlrun.policy import Policy +from ctrlrun.receipt import RECEIPT_SCHEMA, verify_chain +from ctrlrun.state import SQLiteStateStore + +DOC = """ +schema: ctrlrun.policy/v7 +actions: + stripe.refund: + effect: "refund:{payment}" + decision: allow +authority: + grants: + - id: issuer + subject: { agent: "planner" } + actions: ["stripe.refund"] + resources: ["payment:*"] + environments: ["production"] + delegable: true + expires_at: "2027-01-01T00:00:00Z" + - id: unrelated + subject: { agent: "other" } + actions: ["stripe.refund"] + resources: ["payment:*"] + environments: ["production"] +""" + +CHILD = """ +subject: { agent: "worker" } +actions: ["stripe.refund"] +resources: ["payment:EU-1"] +environments: ["production"] +expires_at: "2026-10-01T00:00:00Z" +""" + + +def _action(agent: str = "planner", payment: str = "EU-0") -> Action: + return Action( + name="stripe.refund", + resource=f"payment:{payment}", + arguments={"amount": 10, "payment": payment}, + principal=Principal(agent=agent), + environment="production", + ) + + +@pytest.mark.authority +def test_a_chain_spanning_two_receipt_schemas_verifies_end_to_end(tmp_path): + """`v0.3 §12.2`'s rule: every reader upgrades before any writer switches, so an older receipt + on disk still parses, and a receipt read from a store is hashed as the document it was read + from. That is what lets one chain hold `v6` and `v7` rows and still verify.""" + store = SQLiteStateStore(str(tmp_path / "s.db")) + control = Control( + Policy.from_yaml(DOC, source="t"), store, authority=Authority.from_yaml(DOC, source="t") + ) + for n in range(3): + control.execute(_action(payment=f"EU-{n}"), lambda: "ok", f"refund:EU-{n}") + + report = verify_chain(store) + + assert report.ok, [break_.name for break_ in report.breaks] + assert {receipt.schema for receipt in store.receipts()} == {RECEIPT_SCHEMA} + assert report.verified == 3 + + +@pytest.mark.authority +def test_one_hop_row_makes_the_whole_deployment_unreadable_to_an_older_reader(tmp_path): + """**SPEC-v0.10 §9.3, and the sentence the CHANGELOG owes an operator.** + + `CreatedVia` is a closed vocabulary and `_candidates` reads every delegation row before + filtering any of them, so a reader that does not know `"hop"` answers `authority_unreadable` + for **every action in the deployment**, not just that delegation. Fail-closed, and a + deployment that stops. + + So the irreversible step is **creating the first hop**, not installing 0.10.0. This test + stands in for an older reader by narrowing the vocabulary to what 0.9.0 knew. + """ + import ctrlrun.authority as authority_module + + store = SQLiteStateStore(str(tmp_path / "s.db")) + authority = Authority.from_yaml(DOC, source="t") + control = Control(Policy.from_yaml(DOC, source="t"), store, authority=authority) + control.hop("issuer", grant_from_yaml(CHILD, source="t"), by=Principal(agent="planner")) + + # An UNRELATED principal, its own root grant, no hop presented. + unrelated = _action(agent="other", payment="EU-5") + assert authority.evaluate(unrelated, now=datetime.now(UTC), store=store).passed + + knew_three = {k: v for k, v in authority_module._CREATED_VIA.items() if k != "hop"} + original = authority_module._CREATED_VIA + try: + authority_module._CREATED_VIA = knew_three # type: ignore[assignment] + older = authority.evaluate(unrelated, now=datetime.now(UTC), store=store) + finally: + authority_module._CREATED_VIA = original # type: ignore[assignment] + + assert not older.passed + assert older.reason == "authority_unreadable", ( + "an older reader met one created_via='hop' row and did not stop; §9.3's claim that the " + "blast radius is the deployment is what makes the first hop the irreversible step" + ) diff --git a/tests/test_verify_authority.py b/tests/test_verify_authority.py index 6ed3ba46..3dfd77ab 100644 --- a/tests/test_verify_authority.py +++ b/tests/test_verify_authority.py @@ -1044,7 +1044,7 @@ def test_T413s_G22_grades_the_same_alone_as_it_does_in_a_full_run(tmp_path): assert alone.status is not Status.FAIL, alone.reason -@pytest.mark.parametrize("gid", ["G22", "G23", "G24", "G25"]) +@pytest.mark.parametrize("gid", ["G22", "G23", "G24", "G25", "G26", "G27"]) def test_T413t_a_v09_guarantee_grades_the_same_alone_as_in_a_full_run(gid, tmp_path): """The invariant G22 broke, over every guarantee v0.9 and v0.10 added. From d3f7f8c432dc46187f572d80a94e455783224714 Mon Sep 17 00:00:00 2001 From: arpan Date: Mon, 14 Sep 2026 03:47:06 +0530 Subject: [PATCH 2/2] State the TLS floor on the probe's own context, and test it against a lowered default CodeQL, high, on `src/ctrlrun/upstream.py:120`: `create_default_context` "allows TLSv1 and TLSv1_1". It does not on this interpreter, but the alert is right about the shape. Check 3 is the handshake that decides whether the gateway starts, and a floor that is only a default is one a system-wide OpenSSL configuration or a future release can lower. Now stated. A caller-supplied `SSLContext` is not touched; it is the caller's. The test is worth more than the line. The first version asserted the floor on the context as built and **passed with the line deleted**, because the default already sets TLS 1.2 here: mutation pattern 3, the environment already preventing what the test forbids. The double now hands back a context whose floor has been lowered, the way a permissive system configuration would, and asserts the function raised it again. Mutated: line removed, T495d fails; restored, 18 pass. Not a handshake against a TLS 1.1 listener, for a reason from the same list: such a listener needs a certificate the default context does not trust, so it is refused for the certificate and the version is never reached. Signed-off-by: arpan --- src/ctrlrun/upstream.py | 11 +++++++- tests/test_upstream_pinning.py | 49 ++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/ctrlrun/upstream.py b/src/ctrlrun/upstream.py index 6306d972..81db9f81 100644 --- a/src/ctrlrun/upstream.py +++ b/src/ctrlrun/upstream.py @@ -114,7 +114,16 @@ def observe_upstream(url: str, *, verify: object | None = None, timeout: float = ) host = split.hostname or "" port = split.port or 443 - context = verify if isinstance(verify, ssl.SSLContext) else ssl.create_default_context() + if isinstance(verify, ssl.SSLContext): + context = verify + else: + context = ssl.create_default_context() + # Stated rather than inherited. `create_default_context` has set this floor since 3.10, + # but a floor that is only a default is one a future release or a system-wide OpenSSL + # configuration can lower, and this handshake is where §4.3's check 3 decides whether the + # gateway starts. A pin observed over TLS 1.0 would be a pin on whatever the downgrade + # negotiated with. + context.minimum_version = ssl.TLSVersion.TLSv1_2 with ( socket.create_connection((host, port), timeout=timeout) as raw, context.wrap_socket(raw, server_hostname=host) as tls, diff --git a/tests/test_upstream_pinning.py b/tests/test_upstream_pinning.py index 3f03e69e..09864fb9 100644 --- a/tests/test_upstream_pinning.py +++ b/tests/test_upstream_pinning.py @@ -12,6 +12,7 @@ from __future__ import annotations +import contextlib import http.server import socket import ssl @@ -526,3 +527,51 @@ def test_T494c_the_two_pin_halves_must_agree_at_load(tmp_path): assert "could not be read" in str(missing.value), ( "a pin whose certificate is missing builds an empty trust store and refuses everything" ) + + +@pytest.mark.filterwarnings("ignore::DeprecationWarning") +def test_T495d_the_probes_own_default_context_will_not_negotiate_below_tls_1_2(tmp_path): + """`observe_upstream` called with no `verify` builds its own context, and **it states the + floor rather than inheriting it**. + + The first version of this test asserted the floor on the context as built and passed with the + line under test deleted, because `create_default_context` already sets TLS 1.2 on this + interpreter. That is mutation pattern 3 exactly: the environment already prevented what the + test forbade. So the double here hands back a context whose floor has been **lowered**, the + way a system-wide OpenSSL configuration or a future default could, and the assertion is that + the function raised it again. Deleting the line turns this red. + + It is structural rather than a handshake against a TLS 1.1 listener for a second reason from + the same list: such a listener needs a certificate the default context does not trust, so it + would be refused for the certificate and the version would never be reached. + """ + import ssl as ssl_module + + from ctrlrun import upstream as pinning + + key, crt = _ca_signed(tmp_path, "localhost") + port = _serve(key, crt) + built: list[ssl_module.SSLContext] = [] + real = ssl_module.create_default_context + + def capture(*args: object, **kwargs: object) -> ssl_module.SSLContext: + context = real(*args, **kwargs) # type: ignore[arg-type] + context.minimum_version = ssl_module.TLSVersion.TLSv1 # a permissive system default + built.append(context) + return context + + ssl_module.create_default_context = capture # type: ignore[assignment] + try: + # Self-signed against a CA this context does not trust, so the handshake is refused; what + # is under test is the context built to attempt it, not the attempt's outcome. + with contextlib.suppress(Exception): + pinning.observe_upstream(f"https://localhost:{port}") + finally: + ssl_module.create_default_context = real # type: ignore[assignment] + + assert built, "observe_upstream built no default context, so nothing here was exercised" + assert built[-1].minimum_version is ssl_module.TLSVersion.TLSv1_2, ( + "the probe's own context must not negotiate a protocol the product would refuse, and " + "must not depend on the default to say so; SPEC-v0.10 §4.3's check 3 decides whether " + "the gateway starts on this handshake" + )