diff --git a/MANIFEST.in b/MANIFEST.in index 242213b..d013c72 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -22,6 +22,11 @@ include docs/.mintignore recursive-include tools *.py *.txt # The README's header assets, and the sources they are rendered from. recursive-include docs/assets *.svg *.png *.gif *.tape *.sh *.txt +# The website's media. Mintlify deploys from `docs/`, so a page's video has to live under it, +# and `test_the_sdist_carries_everything_the_tests_read` then requires it to ship. That test +# is checked against `git ls-files`, so the only way out would be to untrack the file, which +# would take it off the site. The sdist carries the megabyte. +recursive-include docs/images *.mp4 *.jpg recursive-include tests *.py # SPEC-v0.4 §4.3 — T115 validates `--junit` against a checked-in schema, and reads the README # beside it for the schema's provenance and licence. A test that ships without its data is a diff --git a/README.md b/README.md index 0553c66..93020bc 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,13 @@
- The last check before an AI agent does something it can't undo.
- Autonomy belongs to the action, not the agent.
- A consequential action happens at most once, exactly as approved, and leaves a receipt — and when the outcome is unknown, CTRLRun says so instead of guessing.
+ Execution safety for AI agents.
+ The model guesses. CTRLRun does not.
A Python library that sits between the decision to act and the call that acts.
+ A consequential action happens at most once, exactly as approved, and leaves a receipt.
+ When the outcome is unknown, CTRLRun says so instead of guessing.
+
Runs in production on a single file, or on Postgres across hosts. Apache-2.0.
-- A refund is the example, not the scope. The same boundary goes in front of a deployment, - a deletion, an IAM grant, a message that leaves the building — any action an agent takes that - the world remembers. Nine domains, and how it - transfers. -
+```bash +pip install ctrlrun && ctrlrun demo +``` -## The refund nobody approved +## What it does -A ticket asks for a €500 refund. The agent calls the refund tool with €5,000 — one extra zero. -The tool is in its list, the arguments are well-formed, and the model is completely confident. +A ticket asks for a €500 refund. The agent calls the refund tool with €5,000, one extra zero. +The tool is in its list, the arguments are well formed, and the model is completely confident. Nothing above the call disagrees, because nothing above the call is a check: a tool being callable is not permission to call it with those arguments. CTRLRun is that check. It reads the arguments about to leave your process and answers what may -happen to them. Under the demo's policy €500 is autonomous and €5,000 is not, so the extra zero -meets a human instead of the provider. Nor does an approval travel: the demo's second scenario -approves €2,000, executes €5,000 under that approval, and it authorises nothing, because it was -bound to the action the human actually read. - -That is the half people expect. The other half is the same agent making a *correct* €500 refund -that commits at the provider while the reply is lost coming back. The agent sees an error and -retries, because retry libraries, agent frameworks and tool loops collapse *this failed* into *I -do not know what happened*. CTRLRun keeps them apart: a lost reply is `AMBIGUOUS`, never -`FAILED`, and a retry against an `AMBIGUOUS` effect is refused until a human, or a `reconcile` -hook, says what happened. - -```bash -pip install ctrlrun && ctrlrun demo -``` - -No Python to hand? [Break a protected action in your browser](https://ctrlrun.dev/docs/try-it): -one refund under one policy on the released wheel, in the tab, with nothing sent anywhere. -Approve €2,000, execute €5,000, lose a reply, retry — and read what refused you. - -The same boundary has five other faces: two workers running one `kubectl delete namespace`, an -approval for `grant reader` spent on `grant admin`, a `delete_customer` nobody put in the -policy, quarterly numbers mailed to a personal address, and a web page that talks the agent -into a refund. [Why](https://ctrlrun.dev/docs/why) is the 700-word version. +happen to them. Four rules do the work, and each one is a test in this repository before it is +a sentence here. -## What `ctrlrun demo` shows +| | | +|---|---| +| **Exact means exact** | Changed arguments need a new approval. | +| **Once stays once** | Same effect key, shared store, no repeat. | +| **Unknown means wait** | Confirm the outcome before retrying. | +| **Every answer is kept** | Requests, decisions and results, refusals included. | -Five ways an agent action goes wrong, and what stops each one, in process, in under a second, -with no network and no external service. The animation above is the first two. In the first, -the refund commits at the remote, the reply is lost, the agent retries, and the retry is -refused — **`remote refund calls: 1`**, so the customer was refunded once and not twice, and -nothing but a human resolving the effect moves it on. In the second, a human approves a €2,000 -refund, the agent executes €5,000 under that approval, and the approval matches nothing but the -action the human saw. +The third one is the half people forget. A correct €500 refund commits at the provider and the +reply is lost coming back, so the agent retries. Retry libraries, agent frameworks and tool +loops collapse *this failed* into *I do not know what happened*. CTRLRun keeps them apart: a +lost reply is `AMBIGUOUS`, never `FAILED`, and a retry against an `AMBIGUOUS` effect is refused +until a human, or a `reconcile` hook, says what happened.ctrlrun demo shows: five failures and five refusals, byte for bytectrlrun verify against a policy with approvals, effects and grants: 11/11CONTROL THE ACTION. KEEP THE AUTONOMY.
-Control what AI agents can do in your systems, what needs human approval, and what must never happen.
- -Apache-2.0 · Runs in your environment
SQLite on one host · Postgres across hosts
THE TOOL WAS IN ITS LIST. THAT IS NOT PERMISSION.
CTRLRun checks the action, not the reasoning.
It reads the arguments about to leave your process, and answers whether they may go.
CONTROL THE ACTION. KEEP THE AUTONOMY.
+Control what agents deploy, delete, send, or change. Any domain. Your rules.
+ONE EXAMPLE / THE MODEL HALLUCINATES AN AMOUNT
+The ticket saysrefund $500
+The agent asks for$5,000
+Your model chooses the action. CTRLRun decides whether it may execute. Pick one of 48 domains, then pick the moment it goes wrong: an approval the agent edited, a retry of something that already happened, a reply that never came back. No login. No API key. No setup.
- +HOW IT WORKS
A library inside your process.
Not a service in front of it.
Models will be wrong sometimes. CTRLRun decides whether being wrong can reach your systems.
-What it is not. It does not read prompts, choose models, rank tools, or judge whether the agent's reasoning was any good. It is not an LLM, and it does not call one. It checks the action, not the thinking.
Read the architecture →Changed arguments need a new approval.
Same effect key. Shared store. No repeat.
Confirm the outcome before retrying.
Requests, decisions and results, refusals included. Test rules before enforcing them.
CTRLRun does not read prompts, choose models, rank tools, or judge whether the agent’s reasoning was any good. It is not an LLM, and it does not call one. It checks the action, not the thinking. Read the architecture →
-FOR THE PEOPLE BUILDING IT
Wrap the call that causes the real-world effect. Policy decides whether it can run, needs approval, or must stop. Execution state survives restarts, so an uncertain outcome still blocks a blind retry.
Start integratingpip install ctrlrun{"import ctrlrun\n\n@ctrlrun.protect(\n \"stripe.refund\",\n effect=\"refund:{payment_id}\"\n)\ndef refund(payment_id, amount):\n ..."}allow → executeapprove → wait for exact approvaldeny → do not executeunknown outcome → do not retry blindlyPython, agent frameworks, or an MCP gateway: the execution boundary stays the same, and it protects workers, webhooks and scheduled jobs too. Not only agents →
-FORTY-NINE SECONDS
Plays muted with captions. Use the controls for sound.
+FOR AGENTS MOVING INTO PRODUCTION
Leave with a concrete execution-control map: what can run automatically, what needs a person, and what must stop until the outcome is known.
Architecture reviews · Integration sprints · Production support
CTRLRun OSS remains Apache-2.0 and free.
A LIBRARY INSIDE YOUR PROCESS
Protect the function that changes the world.
Protect your first action →ctrlrun gateway at an existing MCP server. Five minutes ↗pip install ctrlrun{"import ctrlrun\n\n@ctrlrun.protect(\n \"cloud.deploy\",\n effect=\"deploy:{release_id}\"\n)\ndef deploy(release_id):\n return cloud.deploy(release_id)"}Connect your policy and identity. Full setup →
Reads the ticket, picks the tool, chooses the arguments, and decides to try again when a call errors. This is the part you built, and CTRLRun never touches it.
+An LLM reads the ticket and picks the tool and the arguments. It believes it is right. Sometimes it is not. CTRLRun never touches this part.
Sees no prompt, no reasoning, no chat. It sees one thing: the action about to leave your process, with its exact arguments. It answers whether that may execute now, and records what happened.
+Sees no prompt and no reasoning. It sees the action about to leave your process, with its exact arguments, and answers whether it may execute now.
Stripe, your database, the Kubernetes API, an email server. None of them can tell a first attempt from a retry, and a lost reply looks exactly like a failure.
+Stripe, your database, the Kubernetes API. None of them can tell a first attempt from a retry, and a lost reply looks exactly like a failure.
CTRLRun is not a model, a prompt layer, or a guardrail on what the agent says. It is the check on what the agent does, in the last moment before the effect is real.
+Now play the part of the agent. Choose a domain, the action it proposes, and the moment it goes wrong.
TRY THE EXECUTION BOUNDARY
-Your model chooses the action. CTRLRun decides whether it may execute.
-Pick one of 48 domains, then pick the moment it goes wrong: an action the agent was never allowed to take, an approval the agent edited, a retry of something that already happened, a reply that never came back. You get the decision, the check that produced it, and the line your own code would see.
-No login. No API key. No setup. Nothing here executes.
Every scenario has its own link, so you can send someone straight to the one that matters.
INTERACTIVE SCENARIOS
An LLM picks the action and the arguments, and it is confident either way. CTRLRun sits at the moment that action would leave your process and answers one question: may this execute now? Choose a domain below and watch it answer.
This page is an illustration: the example rules are not industry defaults, nothing here executes, and no request is sent. No login or API key needed. Run the released wheel instead ↗
+THIS PAGE IS AN ILLUSTRATION
Nothing on this page runs CTRLRun. It draws the decisions so you can see where they land in a domain you recognise. One page further, real Python and the released wheel load into your tab, and every refusal you read there is the library's own.
An illustration of the checks, with example rules that are not industry defaults. No code runs, no request is sent.
Real Python, the released ctrlrun wheel, in your browser. Change a refund after approving it and read the exception the library raises. Run it in your browser →
WHERE TO GO NEXT
Apache-2.0 · Runs in your environment
Where CTRLRun sits, what it checks, and what it deliberately does not do. The plain-English version, then the architecture.
pip install ctrlrun, then wrap the function that causes the effect. The quickstart adds your policy, identity and approval flow.
Approval binding, effect keys, and outcomes and AMBIGUOUS: the three the demo leans on hardest.
The medical affairs workbench puts the same kernel under a document release instead of a payment.
Not sure where the controls belong in your architecture? A focused review maps which of your agent's actions can run automatically, which need a person, and which must stop until the outcome is known.
Get an execution-safety review →ctrlrun demo shows"
+
+
def _readme_demo_section() -> str:
readme = Path(__file__).resolve().parents[1] / "README.md"
if not readme.exists(): # installed without the source tree
pytest.skip("no repository checkout")
- section = readme.read_text(encoding="utf-8").split("## What `ctrlrun demo` shows")[1]
- return section.split("\n## ")[0]
+ text = readme.read_text(encoding="utf-8")
+ assert _DEMO_BLOCK_ANCHOR in text, "the README no longer carries the demo transcript"
+ return text.split(_DEMO_BLOCK_ANCHOR, 1)[1].split("", 1)[0]
def test_the_readme_demo_section_quotes_the_demo_output_verbatim(demo_run):
diff --git a/tests/test_docs_production.py b/tests/test_docs_production.py
index d8ade53..17eed19 100644
--- a/tests/test_docs_production.py
+++ b/tests/test_docs_production.py
@@ -390,7 +390,11 @@ def test_every_production_page_is_in_the_production_group():
assert listed[0] == "docs/production/index", "the section's front door comes first"
-READINESS_HOMES = ("README.md", "docs/docs.mdx", "docs/docs/production/index.mdx")
+#: The README carried the readiness block until 2026-09-09, when the page was cut to what
+#: CTRLRun does, how to use it and how it works. The block has two homes on the site, where a
+#: reader who wants the numbers goes, and the generator still refuses a shrunken suite: what
+#: was dropped is one embedding, not the guard.
+READINESS_HOMES = ("docs/docs.mdx", "docs/docs/production/index.mdx")
@pytest.mark.parametrize("home", READINESS_HOMES)
diff --git a/tests/test_readme_assets.py b/tests/test_readme_assets.py
index 2c5c4b2..731bfd2 100644
--- a/tests/test_readme_assets.py
+++ b/tests/test_readme_assets.py
@@ -110,9 +110,21 @@ def test_every_expected_line_is_one_the_demo_prints():
assert missing == [], f"the recording ends on lines the demo does not print: {missing}"
+#: The README carries the transcript inside a collapsed block rather than a section of its own,
+#: since the 2026-09-09 rewrite cut the page to what it does, how to use it, and how it works.
+#: The guard is unchanged and only its anchor moved: the block is still in the file, and every
+#: line the recording ends on still has to be a line the README quotes.
+DEMO_BLOCK_ANCHOR = "ctrlrun demo shows"
+
+
+def readme_demo_block() -> str:
+ text = README.read_text(encoding="utf-8")
+ assert DEMO_BLOCK_ANCHOR in text, "the README no longer carries the demo transcript"
+ return text.split(DEMO_BLOCK_ANCHOR, 1)[1].split("", 1)[0]
+
+
def test_every_expected_line_is_one_the_readme_quotes():
- section = README.read_text(encoding="utf-8").split("## What `ctrlrun demo` shows")[1]
- quoted = {_RUN_VARYING.sub("*", line.rstrip()) for line in section.splitlines()}
+ quoted = {_RUN_VARYING.sub("*", line.rstrip()) for line in readme_demo_block().splitlines()}
missing = [
line for line in _expected_lines() if _RUN_VARYING.sub("*", line.rstrip()) not in quoted
@@ -145,16 +157,23 @@ def test_the_header_carries_the_fixed_copy_and_the_five_badges():
requirement is inverted rather than deleted — no table above the first H2, the marker
still in the file, and the first section named — because a header that quietly grew a
table again would otherwise pass.
+
+ The copy was rewritten on 2026-09-09 to match ctrlrun.dev's, which leads with the category
+ and the claim rather than three slogans, and the page was cut the same day to three
+ questions: what it does, how to use it, how it works. What this test requires is unchanged:
+ the fixed lines are pinned so the header cannot drift untested, the category noun is still
+ asserted, and the first section is still the failure itself.
"""
text = README.read_text(encoding="utf-8")
head = text.split("\n## ", 1)[0]
- assert "The last check before an AI agent does something it can't undo." in head
- assert "Autonomy belongs to the action, not the agent." in head
+ assert "Execution safety for AI agents." in head
+ assert "The model guesses. CTRLRun does not." in head
assert (
"A consequential action happens at most once, exactly as approved, and leaves a "
- "receipt — and when the outcome is unknown, CTRLRun says so instead of guessing." in head
+ "receipt." in head
)
+ assert "When the outcome is unknown, CTRLRun says so instead of guessing." in head
# The category noun, which the hero went without until 0.6: a reader had to reverse-engineer
# what CTRLRun *is* from three slogans. `docs/docs.mdx` carried it and the README did not.
assert "A Python library that sits between the decision to act and the call that acts." in head
@@ -170,4 +189,4 @@ def test_the_header_carries_the_fixed_copy_and_the_five_badges():
assert marker not in head, "the capability matrix is not the first screen"
assert marker in text, "the capability matrix was moved, not dropped"
assert head.count("\n|---|") == 0, "no table above the first H2"
- assert text.split("\n## ", 2)[1].startswith("The refund nobody approved")
+ assert text.split("\n## ", 2)[1].startswith("What it does")
diff --git a/tests/test_release_v0_6.py b/tests/test_release_v0_6.py
index 10c4c40..221953b 100644
--- a/tests/test_release_v0_6.py
+++ b/tests/test_release_v0_6.py
@@ -89,13 +89,12 @@ def _load(name: str) -> set[str]:
#: Sentences that **disclaim** one of the words. §8's T180 describes the whole
#: list this way, and this half of it is.
DISCLAIMS: dict[str, tuple[str, ...]] = {
+ # The 2026-09-09 rewrite cut the README to what CTRLRun does, how to use it and how it
+ # works, and the two paragraphs that used to carry this are now one bullet in "What it does
+ # not do". Both halves survived the cut, which is the half §6.4 cares about: the chain
+ # detects alteration, and the same sentence says alteration is not authorship.
"README.md": (
- "- The receipt chain detects alteration, and alteration is not authorship. Receipts are not", # noqa: E501
- "signed, the chain is no evidence of who wrote one, and it is not tamper-proof: it does not", # noqa: E501
- # The "New in 0.6" section, which says what the release added and therefore has to say
- # what the chain is not in the same breath -- a release note listing "receipt integrity"
- # with the disclaimer left downpage is the half-sentence §6.4 exists to stop.
- " **alteration**, which is not authorship: receipts are not signed.",
+ "is detected. They are not signed: alteration is not authorship. The badge above means the",
),
"CHANGELOG.md": (
'- **`docs/docs/ROADMAP.md`\'s v0.6 bullet said "receipt integrity (hash chain / signatures)", and the', # noqa: E501
diff --git a/tests/test_verify_action.py b/tests/test_verify_action.py
index f25e8fa..39a859e 100644
--- a/tests/test_verify_action.py
+++ b/tests/test_verify_action.py
@@ -390,28 +390,28 @@ def test_the_job_summary_carries_the_not_applicable_rows_in_full():
assert report.summary_line() in summary
-# --- the README quotes the real output (SPEC-v0.4 §4.1; the CLAIMS.md standard) -------------
+# --- the verify page quotes the real output (SPEC-v0.4 §4.1; the CLAIMS.md standard) --------
-
-def _readme_verify_section() -> str:
- readme = _repository_file(README)
- section = readme.split("## Prove it holds in your setup")[1]
- return section.split("\n## ")[0]
+#: The README carried a copy of this report until 2026-09-09, when the page was cut to what
+#: CTRLRun does, how to use it and how it works, and the report went with the rest of the
+#: verify section. The guard moved rather than went: `docs/docs/verify.md` is now the single
+#: home of the verbatim output, so the "two copies can drift" test below has nothing left to
+#: compare and is gone, and this one reads the page instead of the README.
def _quoted_report() -> list[str]:
- block = _readme_verify_section().split("```console")[1].split("```")[0]
+ block = _repository_file(VERIFY_DOC).split("```console")[1].split("```")[0]
return [line for line in block.splitlines() if line.strip() and not line.startswith("$")]
@pytest.mark.authority
-def test_the_readme_quotes_the_real_verify_output():
- """The demo section has had this guard since v0.1; the verify section gets the same one.
+def test_the_verify_page_quotes_the_real_verify_output():
+ """The demo transcript has had this guard since v0.1; the verify report gets the same one.
- Every line the README quotes has to be a line `ctrlrun verify` actually prints, so a
- change to the report that nobody carried across fails here rather than shipping a README
- that lies. The version line is normalised: it moves at every release, and the README is
- not the place that number is kept honest — `pyproject.toml` is.
+ Every line the page quotes has to be a line `ctrlrun verify` actually prints, so a change
+ to the report that nobody carried across fails here rather than shipping a page that lies.
+ The version line is normalised: it moves at every release, and a document is not the place
+ that number is kept honest — `pyproject.toml` is.
"""
report = run(AUTHORITY_PAYMENTS)
@@ -419,7 +419,7 @@ def test_the_readme_quotes_the_real_verify_output():
re.sub(r"ctrlrun \S+,", "ctrlrun