diff --git a/CHANGELOG.md b/CHANGELOG.md index 36db8ef2..b68740e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,94 @@ any change to one appears here. ## [Unreleased] +## [0.12.0] — Hardening + +One question: **is the thing this project says about itself checkable?** + +No new capability. Three claims that were prose became tests, and each of the three was false or +incomplete when the test was written, which is the milestone's whole argument. + +### Changed + +- **The module map is acyclic, and `ARCHITECTURE.md` §6's rule is now a test.** §6 has said + *dependencies point downward only* since v0.1, and from v0.7 it was false: a review found + `state -> receipt -> policy -> authority -> state` and the sentence was amended to record the + cycle rather than fix it. Nothing broke at run time, because the two edges out of `policy.py` + are function-level, so `import ctrlrun` resolved in one order and the suite passed for five + milestones. + + Broken in two places. `Decision` and `POLICY_UNAPPROVED` moved to **`ctrlrun.decision`**, which + imports nothing from the package: that was the whole of what a receipt needed from the decider, + and an evidence type reaching **up** into it is the edge that most contradicts §6's table. Then + the policy **document grammar** -- schemas, the strict YAML loader, the condition parser and + evaluator, type-strict equality -- moved to **`ctrlrun.grammar`**, so `authority.py` no longer + imports `policy.py` at all. `SPEC-v0.3.md` §4.5 requires the two axes to share **one** condition + evaluator, and that is better served than before: the one evaluator is owned by neither axis. + + **No public name moved.** `policy.py` re-exports all thirty-four, so + `from ctrlrun.policy import Decision, Condition, parse_conditions` resolves to the same objects + and `SPEC-v0.1.md` §8's frozen `__init__` block is unchanged. + + `tests/test_module_graph.py` walks every module's AST and separates two questions §6 kept + conflating: the **import-order** graph, which is module-level imports, and the **layering** + graph, which counts deferred imports and is what the table describes. The recorded cycle is + invisible to the first, so a guard built only on module-level imports **passes on 0.11.0's + tree**. Writing it found a second cycle nobody had recorded, `jwt_identity` and `revocation` + sharing a security-critical redirect handler through a deferred import; `_NoRedirects` moved + down to `revocation.py` and keeps logging under `ctrlrun.policy`'s logger name so no operator's + handler is re-routed. + +- **The gateway told MCP clients to call a Python API.** A `-41002` relayed + `str(ApprovalRequired)` verbatim, and that exception carries the decorator's wording: *"run + `ctrlrun approve …`, then retry inside `ctrlrun.with_approval(…)`"*. On the one path where the + caller may be in any language, and is often a model reading the error as text, it pointed at a + context manager the caller cannot reach. It now says what the gateway's own documentation + already said: a human approves and **this same call runs**. + +### Added + +- **Property tests over generated inputs** (`tests/test_properties.py`, `hypothesis`). The + invariant that matters is the one v0.11 item 1 violated: **every break a tamper reports names a + row the store actually holds.** That defect reported `content_altered 99` and `missing 100` on + an eight-row chain, because position came from the document rather than the `seq` column. + Reinstating it fails these tests immediately. + + The first version of that property was *one tamper is one break*, and hypothesis falsified it on + its second example: altering row `n` also breaks the link at `n + 1`. Two is correct and the + expectation was wrong, which is the same mistake the file exists to catch. `derandomize=True`, + so a counterexample found in CI reproduces locally by construction. + +- **A CycloneDX SBOM of the wheel, measured from the wheel** (`scripts/sbom.sh`). Generated by + installing the built wheel into an empty environment and recording what resolves, not by reading + `pyproject.toml`: a manifest-derived SBOM is the project's opinion of its own dependencies. The + answer is two, `PyYAML` and `click`. CI generates and checks it on every pull request, and + `release.yml` writes it into `dist/` **before** the attestation step, so it is signed with the + distributions and attached to the release. + + The seed packages are removed before the scan, and that is not cosmetic: `python -m venv` adds + pip, and on 3.11 setuptools too, and a scanner cannot tell *"ctrlrun needs this"* from *"the venv + came with this"*. The first version removed only pip, passed on 3.12 and went red in CI on 3.11 + with `['PyYAML', 'click', 'setuptools']`. The assertion compares by **equality**, which is why it + caught a document that would have overstated what a consumer takes on. + +### Fixed + +- **A shared-directory race in the cookbook tests.** Two tests ran the same recipe in + `examples/cookbook//` on different xdist workers; one's `rm -f verify-report.json` landed + between the other's write and read, and `bash -euo pipefail` turned it into a failure with + nothing wrong in the library. Measured at four concurrent runs: shared directory 2 of 4 fail, + one copy per run 4 of 4 pass. Each test now runs in its own copy, and the suite stops writing + into the working tree. + +- **Both adapter READMEs failed on copy-paste.** Their examples show `identity=...` as an + ellipsis, and dropping it leaves the first call refused with `no principal is available`. Both + now state the requirement and say why it is fail-closed. + +- **`adapters/PUBLISHED.toml` had gone stale in the other direction.** 0.11.0 published both + adapters at 1.2.0 with `<0.12` and never updated the record, so the file spent a release + claiming 1.1.0 / `<0.11`. Both adapters go to **1.3.0** with `ctrlrun>=0.5,<0.13`, because the + published 1.2.0 excludes this kernel, and `RECORDED` now freezes 1.2.0's range too. + ## [0.11.0] — Evidence One question: **can the record be trusted after the fact, and kept?** diff --git a/CITATION.cff b/CITATION.cff index 929dd1b5..725a9641 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -11,7 +11,7 @@ authors: - family-names: Ghoshal given-names: Arpan email: contact@arpanghoshal.com -version: 0.11.0 +version: 0.12.0 repository-code: https://github.com/CTRLRun/ctrlrun url: https://github.com/CTRLRun/ctrlrun license: Apache-2.0 diff --git a/adapters/PUBLISHED.toml b/adapters/PUBLISHED.toml index a80c6808..2363fb3d 100644 --- a/adapters/PUBLISHED.toml +++ b/adapters/PUBLISHED.toml @@ -26,6 +26,14 @@ # ctrlrun<0.11,>=0.5 # # Which is the v0.10 defect this file was written for, recurring one release later, in the file. +# +# **And it went stale a third time, in the opposite direction.** The 0.11.0 release tagged and +# published both adapters at 1.2.0 with `<0.12`, and nobody came back here afterwards, so this +# file spent a release claiming 1.1.0 / `<0.11` while PyPI served 1.2.0 / `<0.12`. The guard below +# said nothing, because a tree that differs from the record *and* carries a higher version is +# exactly the shape of a correct pending release. The rule in the line above -- update the row +# **after** the upload succeeds -- is the one that was not followed, and it is easy to miss +# precisely because the upload is the last thing that happens. # A record edited to match a claim is not a record. `RECORDED` in `test_packaging.py` freezes the # range each published version declares, so raising one without a new version now fails. # @@ -40,9 +48,9 @@ # that gets skipped in the run that mattered. [langgraph] -version = "1.1.0" -kernel = ">=0.5,<0.11" +version = "1.2.0" +kernel = ">=0.5,<0.12" [openai-agents] -version = "1.1.0" -kernel = ">=0.5,<0.11" +version = "1.2.0" +kernel = ">=0.5,<0.12" diff --git a/adapters/langgraph/README.md b/adapters/langgraph/README.md index 351fb576..0201191b 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.12` +- **Supported kernel range:** `ctrlrun>=0.5,<0.13` - **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 9b3e9285..b3b6eb1e 100644 --- a/adapters/langgraph/pyproject.toml +++ b/adapters/langgraph/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ctrlrun-langgraph" -version = "1.2.0" +version = "1.3.0" description = "Route a CTRLRun APPROVE through LangGraph's own interrupt()." readme = "README.md" requires-python = ">=3.11" @@ -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.12", + "ctrlrun>=0.5,<0.13", "langgraph>=1.0,<2.0", ] diff --git a/adapters/langgraph/src/ctrlrun_langgraph/__init__.py b/adapters/langgraph/src/ctrlrun_langgraph/__init__.py index d0ec31d4..8c0f1528 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.12`. Supported framework range: `langgraph>=1.0,<2.0`. +Supported kernel range: `ctrlrun>=0.5,<0.13`. 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 985f5c0f..e89f3236 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.12` +- **Supported kernel range:** `ctrlrun>=0.5,<0.13` - **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 87292b75..49802d70 100644 --- a/adapters/openai-agents/pyproject.toml +++ b/adapters/openai-agents/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ctrlrun-openai-agents" -version = "1.2.0" +version = "1.3.0" description = "Route a CTRLRun APPROVE through the OpenAI Agents SDK's tool-approval interruption." readme = "README.md" requires-python = ">=3.11" @@ -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.12", + "ctrlrun>=0.5,<0.13", "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 edaa8f3d..49b1e598 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.12`. +Supported kernel range: `ctrlrun>=0.5,<0.13`. 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/pyproject.toml b/pyproject.toml index 1172b979..ae32011e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ctrlrun" -version = "0.11.0" +version = "0.12.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/tests/test_packaging.py b/tests/test_packaging.py index 0b490d71..9dc0256f 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -904,8 +904,8 @@ def test_a_widened_kernel_range_is_not_shipped_without_a_new_version(adapter): #: #: Checked 2026-09-14, during the 0.11.0 release pass, for both adapters at both versions. RECORDED: dict[str, dict[str, str]] = { - "langgraph": {"1.0.0": ">=0.5,<0.6", "1.1.0": ">=0.5,<0.11"}, - "openai-agents": {"1.0.0": ">=0.5,<0.6", "1.1.0": ">=0.5,<0.11"}, + "langgraph": {"1.0.0": ">=0.5,<0.6", "1.1.0": ">=0.5,<0.11", "1.2.0": ">=0.5,<0.12"}, + "openai-agents": {"1.0.0": ">=0.5,<0.6", "1.1.0": ">=0.5,<0.11", "1.2.0": ">=0.5,<0.12"}, }