Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
286 changes: 219 additions & 67 deletions CHANGELOG.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ authors:
- family-names: Ghoshal
given-names: Arpan
email: contact@arpanghoshal.com
version: 0.6.1
version: 0.7.0
repository-code: https://github.com/CTRLRun/ctrlrun
url: https://github.com/CTRLRun/ctrlrun
license: Apache-2.0
Expand Down
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,19 +243,29 @@ Every protected call, whichever way it arrives, goes through the same six steps.
much autonomy does *this action* get?). Unknown action, missing policy or missing principal
is `deny`.
3. **Approve.** A human answers against the action hash. The approval is single-use, expires,
and matches nothing but that exact action.
and matches nothing but that exact action. Name a `preconditions=` provider and the approval
is also bound to the resource state it was granted against, rechecked strictly before the
reservation: that **narrows** the window between the answer and the execution, from minutes of
deliberation to milliseconds. It does not close it, because the recheck is a network call and
cannot run inside the atomic write.
4. **Reserve.** The effect key, `refund:txn_1` or `namespace:prod-eu:checkout`, is taken in one
atomic write. A second caller, in another process or on another host, is refused.
5. **Execute.** Your function runs. Only `NotExecuted`, raised by you, means `FAILED`; every
other exception and every timeout means `AMBIGUOUS`.
other exception and every timeout means `AMBIGUOUS`. Deciding which one you are looking at is
the hard part, so `ctrlrun.transport` does it for you: `urlopen`, `HTTPConnection` and
`HTTPSConnection` from stdlib `urllib` and `http.client`, which raise `NotExecuted` only where
the connection they opened was handed no request byte. After one byte, every failure stays the
exception it was, and the outcome is `AMBIGUOUS`. No setting widens that.
6. **Record.** A portable JSON receipt: who, what, decision, approval, effect key, outcome, and
the hash of the policy that decided it, chained to the receipt before it.

State lives in SQLite by default, a file with no server and no ops, and the reservation holds
across processes rather than merely across threads. Point it at Postgres when more than one
host writes: `pip install "ctrlrun[postgres]"`, one URL, the same guarantees graded by the same
suite. Prove it in your own setup with `ctrlrun verify`, which runs the kernel's own failure
scenarios against *your* policy in a scratch store, with no network.
scenarios against *your* policy in a scratch store. It reaches no network: the only sockets it
opens are to the store you named and to loopback listeners it bound itself, which is how it
grades the transport classifier.

<!-- generated from capabilities.yaml (readme) — edit the YAML, never this table -->
| Guarantee | `@protect` | Gateway | Adapter |
Expand Down
2 changes: 1 addition & 1 deletion adapters/langgraph/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.7`
- **Supported kernel range:** `ctrlrun>=0.5,<0.8`
- **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).
Expand Down
2 changes: 1 addition & 1 deletion adapters/langgraph/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.7",
"ctrlrun>=0.5,<0.8",
"langgraph>=1.0,<2.0",
]

Expand Down
2 changes: 1 addition & 1 deletion adapters/langgraph/src/ctrlrun_langgraph/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,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.7`. Supported framework range: `langgraph>=1.0,<2.0`.
Supported kernel range: `ctrlrun>=0.5,<0.8`. Supported framework range: `langgraph>=1.0,<2.0`.
`README.md` states both, and what this adapter's binding check is and is not.
"""

Expand Down
2 changes: 1 addition & 1 deletion adapters/openai-agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.7`
- **Supported kernel range:** `ctrlrun>=0.5,<0.8`
- **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).
Expand Down
2 changes: 1 addition & 1 deletion adapters/openai-agents/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.7",
"ctrlrun>=0.5,<0.8",
"openai-agents>=0.20,<1.0",
]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,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.7`.
Supported kernel range: `ctrlrun>=0.5,<0.8`.
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.
Expand Down
57 changes: 57 additions & 0 deletions docs/SPEC-v0.7.md
Original file line number Diff line number Diff line change
Expand Up @@ -3826,3 +3826,60 @@ so there is nothing there for verify to grade; T261b is where that residual is k
and whichever lands second rebases the two lines.

### 12.6 Item 6: the release

**The changelog is written as a release and not as six bullet lists**, because six items merged in
parallel lanes and each wrote its own entry in the order it landed. A reader upgrading needs two
things the concatenation did not give them: **every behaviour that became stricter, beside what
0.6.1 did**, and **every residual, where an operator reads it rather than only in §12**. Both are
their own section above `Added`, and the residual list is the one this section exists to argue
for: the reconcile route's wasted human answer and three provider calls (§12.4), the ceiling
bounding attempts and not executor invocations (§5.7), the refused attempt number being spent
(§5.5), the register seeing only this library's own sends (§12.2.9), attempt identity under a
reused `action_id` (§12.3a), §6.4's residual, and the recheck that narrows and does not close.
A milestone whose specification states seven residuals and whose changelog states none would be
the prevention-versus-attribution rule failing at the last surface it passes through.

**The version bump broke two things nothing else would have caught, and both were real.** Both
adapters declared `ctrlrun>=0.5,<0.7`, which **excludes** the kernel they ship beside, so
`pip install ctrlrun-langgraph` would have refused to resolve or silently downgraded `ctrlrun` to
0.6. That is the defect `0.5,<0.6` produced at 0.6.0 and the test written for it
(`test_each_adapter_declares_a_kernel_range_that_contains_this_kernel`) caught this one the
moment `pyproject.toml` moved. The range is now `>=0.5,<0.8` in all six places the two adapters
state it, which is the guard beside it, `test_no_adapter_source_file_states_a_stale_kernel_range`.
And `CITATION.cff` carried `0.6.1`. Neither is a release-pass edit anybody would have thought to
make; both are tests written when the same thing went wrong before.

**T271 runs the demo in a process with the network taken away.** The T11 fixture runs it in the
test process through `CliRunner`, where nothing has been taken away, so "under 60 seconds with no
network" was two claims of which only the first was measured. T271 runs the CLI in a subprocess
whose `sitecustomize` is `conftest.py`'s one guard, the same one T107, T230, the examples and the
cookbook use. Measured: 0.13 s, five scenarios, nothing reached.

**The documentation repository's snippet harness had the second guard §12.2.7 warned about.**
`tools/docs_audit/snippets.py` carried its own `NO_NETWORK`, which refused every connect, and the
cookbook's `verify-in-github-actions` recipe runs `ctrlrun verify`, so once G12 existed that
recipe exited 3 on a correct kernel. §12.2.7 moved the *library's* two copies onto one definition
and did not know about this third one, in another repository. It is now a verbatim copy of
`conftest.py`'s guard, with both edges tested there: a self-bound loopback port is admitted, and a
loopback port the process did not bind is not.

**`render_api` did not enumerate `ctrlrun.transport`, and could not have.** Its page list is
`ctrlrun.__all__` plus a hand-written `EXTRA_NAMES` for what lives behind an extra. `transport.py`
is core, stdlib and deliberately **not** imported by `import ctrlrun` (§2.8, T228), so it is in
neither, and the five public names of the module that decides `FAILED` versus `AMBIGUOUS` had no
reference page while `NotExecuted` had one. They are in `EXTRA_NAMES` now, with a comment saying
why a module that needs no extra is in a list named for extras.

**Seven `CLAIMS.md` rows cited a line that had become the end of a docstring.** The repointer
refuses rather than guesses, so it reported them and wrote nothing, which is the behaviour that
made this visible at all: they cite the branch where only `NotExecuted` maps to `FAILED`, which is
a statement and not a definition, and the citation had been pointed at `control.py:1416` before
item 3's token binding moved it. Repointed at the `except NotExecuted` clause; thirty more rows
moved with the code.

**What the readiness block says before the tag, and why it is left that way.** It reads *Version
0.7.0 is in development; PyPI has 0.6.1*, because the generator takes "released" from the newest
**dated** changelog heading and this release's heading is undated until the tag. That is the line
flipping itself on the day the release lands, which is what it was built to do; regenerating the
block is part of the tag and not of this pull request. The **No external security audit** line is
untouched: it is gated on v0.12 (`ROADMAP.md`), never on this release.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "ctrlrun"
version = "0.6.1"
version = "0.7.0"
description = "The execution safety layer for AI agents."
# Mirrors the repository's GitHub topics, so PyPI search and GitHub search agree.
keywords = [
Expand Down
38 changes: 38 additions & 0 deletions tests/test_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import json
import os
import re
import subprocess
import sys
import time
from dataclasses import replace
from fnmatch import fnmatch
Expand Down Expand Up @@ -546,6 +548,42 @@ def test_T11_demo_runs_in_under_sixty_seconds(demo_run):
assert elapsed < 60


def test_T271_demo_runs_offline_in_a_process_with_the_network_taken_away(tmp_path, no_network):
"""The definition of done says *under 60 seconds with no network*, and the fixture above
cannot show it: it runs in this process, through `CliRunner`, where nothing has been taken
away. "Runs with no network" stays a claim until something takes the network away
(`CONTRIBUTING.md`), so this runs the installed console script in a subprocess whose
`sitecustomize` is `conftest.py`'s one guard, the same one T107, T230, the examples and the
cookbook use.

The guard admits exactly what `SPEC-v0.7.md` §8.9 admits and nothing more: an IPv4 connect
to the `127.0.0.1` literal at a port this process bound through a stream socket that is
still open. The demo binds none, so it reaches nothing at all, and a demo that grew a
fetch would fail here rather than in a reader's terminal.
"""
environment = dict(os.environ)
environment["PYTHONPATH"] = os.pathsep.join(
part for part in (str(no_network), environment.get("PYTHONPATH", "")) if part
)

started = time.monotonic()
finished = subprocess.run(
[sys.executable, "-c", "from ctrlrun.cli.main import main; main()", "demo"],
cwd=tmp_path,
env=environment,
capture_output=True,
text=True,
timeout=120,
)
elapsed = time.monotonic() - started

assert finished.returncode == 0, f"{finished.stdout}\n{finished.stderr}"
assert elapsed < 60, f"the demo took {elapsed:.1f}s with the network taken away"
for number, heading in enumerate(SCENARIO_HEADINGS, start=1):
assert f"{number}. {heading}" in finished.stdout
assert "runs with no network" not in finished.stderr


def test_T93_demo_prints_all_five_scenario_headings(demo_run):
result, _, _ = demo_run

Expand Down
8 changes: 8 additions & 0 deletions tests/test_preconditions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1963,9 +1963,17 @@ def _guarantee_titles() -> str:
#:
#: Sentences that **disclaim**: they say the recheck narrows, and name what it does not do.
DISCLAIMS: dict[str, tuple[str, ...]] = {
"README.md": (
"It does not close it, because the recheck is a network call and cannot run inside the "
"atomic write.",
),
"CHANGELOG.md": (
"A precondition fingerprint **narrows** the window between a human's approval and the "
"action's execution; it does not close it.",
# The release's own "What this release does not close" list, which states the residual
# where an operator reads it rather than only in the specification.
"narrows the window between a human's approval and the action's execution, and does not "
"close it.",
"and precondition fingerprints, which **narrow** the window between a human's approval "
"and the action's execution and do not close it",
),
Expand Down
Loading