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.

@@ -34,58 +36,36 @@ ctrlrun demo: a refund commits at the remote, the response is lost, the agent retries, and the retry is refused, remote refund calls: 1. Then a human approves a €2,000 refund, the agent executes €5,000, and that is refused too.

-

- 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.
-The full transcript, byte for byte what the demo prints +What ctrlrun demo shows: five failures and five refusals, byte for byte ```console $ ctrlrun demo @@ -136,16 +116,35 @@ Read them: CTRLRUN_STATE=.ctrlrun/demo/state.db ctrlrun receipts ``` Approval and delegation ids are generated per run; everything else is exactly what the demo -prints, and a test fails if the two drift apart. +prints, and a test fails if the two drift apart. No network, no external service, under a +second. [Run it in your browser](https://ctrlrun.dev/docs/try-it) with nothing installed.
-## Protect your first action +**Where it stops.** It does not detect prompt injection: it contains the consequence rather +than reading the cause. It cannot promise exactly-once against a remote it does not control, it +refuses to *knowingly* act twice, and it rolls nothing back. Receipts are chained, so an alteration +is detected. They are not signed: alteration is not authorship. The badge above means the +**declared guarantees pass** in the setup they ran against, and it does not mean secure, safe, +compliant, certified or audited: +[what the badge means](https://github.com/CTRLRun/ctrlrun/blob/main/docs/docs/verify.md#what-the-badge-means) +· [`OWASP-AGENTIC-TOP10.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/docs/OWASP-AGENTIC-TOP10.md) +names the four entries this does not address. + +If an agent only reads and answers, you do not need CTRLRun. The moment it can **send, pay, +refund, delete, deploy, grant, revoke, approve, submit, purchase or cancel**, you do. -Wrap the call that has the consequence, and let one YAML file say how much autonomy it gets. -The rule is the same in every domain: cheap to undo is autonomous, anything that hands out -power or leaves the building needs a human, and anything that destroys the evidence is not an -agent action at any size. Save this as `ctrlrun.yaml`. +## Use it in three steps + +**1. Install it.** + +```bash +pip install ctrlrun +``` + +**2. Write down what the agent may do.** One file, `ctrlrun.yaml`. Cheap to undo is autonomous, +handing out power needs a human, destroying the evidence is not an agent action at any size. +Anything not listed is denied; there is no default-allow. ```yaml runnable schema: ctrlrun.policy/v2 @@ -169,8 +168,7 @@ actions: decision: deny ``` -Anything not listed here is denied; there is no default-allow. Now `agent.py`, where `directory` -is a stand-in that records calls instead of making them: +**3. Wrap the call that has the consequence**, and name the effect it has in the world. ```python runnable file=agent.py import sys @@ -214,95 +212,63 @@ reader: granted admin: a human decides: apr_649156806800a3545de597c028c9dae5 ``` -The human answers from the shell. The grant names the action hash it authorizes, and when it -lapses: +A human answers from the shell, and the grant names the exact action hash it authorizes: ```bash runnable ctrlrun approve "$(cat request_id.txt)" ``` -```text -granted apr_649156806800a3545de597c028c9dae5 for sha256:ade45e6f6f6d5ea32ca9ddc0a1806973a729c0c2b8b5da9d422f5501fd14f574 -expires 2026-09-07T11:59:30.626Z -``` - -Now the agent presents that approval — and tries to spend it on a bigger role: - -```python runnable file=approved.py -import sys - -import ctrlrun - -from agent import directory, grant_role - -request_id = open("request_id.txt").read().strip() - -with ctrlrun.context(agent="onboarding-agent"), ctrlrun.with_approval(request_id): - print("admin with approval:", grant_role(user_id="u_412", role="admin")["status"]) - try: - grant_role(user_id="u_412", role="owner") - except ctrlrun.ApprovalMismatch: - print("owner on the same approval: refused") - else: - sys.exit("a mutated action ran on a spent approval; that is the bug this exists to stop") - -print("directory calls:", len(directory.calls)) -``` - -```text -admin with approval: granted -owner on the same approval: refused -directory calls: 1 -``` - -The approval was bound to the hash of the action the human saw, so it matched `admin` on `u_412` -and nothing else. One call reached the fake directory in that process; the `owner` grant never -did. Every one of them left a receipt: - -```bash runnable -ctrlrun receipts --last 3 -``` - -```text -2026-09-07T11:44:30.625Z ctr_1faa628d5922874e8eb97f77f55c3d40 iam.grant_role allow/committed grant:u_412:reader onboarding-agent -2026-09-07T11:44:38.246Z ctr_73314d54e87cd150de07444e989b793b iam.grant_role approve/committed grant:u_412:admin onboarding-agent -2026-09-07T11:44:38.247Z ctr_09a9fdec28dc034b23376ffc6a1a279d iam.grant_role approve/blocked grant:u_412:owner onboarding-agent -``` +Present it with `ctrlrun.with_approval(request_id)` and the agent gets that action and nothing +next to it: the same approval spent on `owner` instead of `admin` raises `ApprovalMismatch`. +Every attempt, refusals included, leaves a receipt you can read with `ctrlrun receipts`. That is the whole integration: a policy file, a decorator, a context, and `with_approval` to -present a grant. Money is one more action with a rule — `amount_gte`/`amount_lte` in place of -`role_in`, and both ends of every band bound, because an upper bound alone lets a negative -amount through and a refund of a negative amount is a charge. -[Protect your first action](https://ctrlrun.dev/docs/get-started/quickstart) is the -same walkthrough with every output explained · [Try it in your browser](https://ctrlrun.dev/docs/try-it) · +present a grant. Everything else is in the documentation. +[Protect your first action](https://ctrlrun.dev/docs/get-started/quickstart) walks the same path +with every output explained · [Policy YAML reference](https://ctrlrun.dev/docs/reference/policy-yaml) · -[Cookbook](https://ctrlrun.dev/docs/cookbook/index): refunds, deploys, IAM, deletions, email, MCP, -each a recipe that runs. +[Cookbook](https://ctrlrun.dev/docs/cookbook/index): refunds, deploys, IAM, deletions, email, MCP. ## How it works -Every protected call, whichever way it arrives, goes through the same six steps: +Every protected call, whichever way it arrives, goes through the same six steps. ```text normalize → decide → approve → reserve → execute → record ``` -1. **Normalize.** The call becomes an `Action` — a name, canonical arguments (sorted keys, no - floats), a resource, the principal — and its SHA-256 is the action hash. +1. **Normalize.** The call becomes an `Action`: a name, canonical arguments (sorted keys, no + floats), a resource, the principal. Its SHA-256 is the action hash. 2. **Decide.** Authority first (may *this principal* propose this at all?), then policy (how 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. -4. **Reserve.** The effect key — `refund:txn_1`, `namespace:prod-eu:checkout` — is taken in one +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`, and an `AMBIGUOUS` effect blocks a - blind retry until a human or a `reconcile` hook says what happened. + other exception and every timeout means `AMBIGUOUS`. 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. -## Three ways to use 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. + + +| Guarantee | `@protect` | Gateway | Adapter | +|---|---|---|---| +| **Approval binding** — An approval is bound to the exact action; a mutated or replayed one is refused. | yes | yes | prevention or attribution, per adapter | +| **One effect, once** — One logical effect happens at most once, across threads, processes and hosts. | yes | yes | yes | +| **Unknown is not failed** — An unknown outcome is AMBIGUOUS, never FAILED, and blocks a blind retry. | yes | yes | yes | +| **Fail closed** — An unknown action, a missing policy or a missing principal is denied. | yes | yes | yes | +| **Authority and delegation** — With authority on, every principal needs a grant, and delegation cannot widen one. | yes | yes | yes | +| **Receipts** — Every executed action leaves a portable JSON receipt of who, what and outcome. | yes | yes | yes | + + +### Three ways to use it **You probably do not need an adapter.** `@protect` covers anything running in this process: a raw model call, a LangChain tool, a hand-rolled loop, a cron job. The gateway covers anything @@ -311,79 +277,34 @@ that reaches its tools over MCP, in any language. | You have | Use | Needs | |---|---|---| | Python in this process | the `@protect` decorator, shown above | nothing beyond `pip install ctrlrun` | -| Tools behind an MCP server, in any language | the gateway | `pip install "ctrlrun[gateway]"` | +| Tools behind an MCP server, in any language | the gateway: `pip install "ctrlrun[gateway]"` | one command, no change to agent or server code | | A framework with its own approval interrupt | an adapter | the framework to have a human-in-the-loop primitive | -**The gateway** changes no agent code and no server code. Point the MCP client at it instead of -at the tool server, and tools become actions named `mcp..`, decided by the same -policy: - -```bash -pip install "ctrlrun[gateway]" -ctrlrun gateway --upstream http://localhost:8000/mcp --alias acme --principal support-agent -``` - -A tool call has no decorator to carry its effect and resource templates, so the policy declares -them: - -```yaml runnable file=gateway.yaml -schema: ctrlrun.policy/v2 - -actions: - mcp.acme.delete_document: - effect: "document:{document_id}" - resource: "document:{document_id}" - decision: approve - mcp.acme.search_documents: - decision: allow -``` - -Everything but `tools/call` is relayed untouched, a lost response over the wire blocks the retry -exactly as it does in process, and the gateway prints on the line that starts it every action in -your policy with no `effect:` template — because a write with no effect key is the configuration -this exists to prevent. [`ctrlrun.dev/mcp/overview`](https://ctrlrun.dev/docs/mcp/overview) is the -whole section. - -**An adapter** exists for one reason: to route an `approve` decision through **the framework's -own interrupt** instead of raising `ApprovalRequired` past your graph. A human answers where -they already answer, and one core provider writes the grant through the same calls `ctrlrun -approve` makes. There is never a second place to say yes. - -| | reuses | binding | -|---|---|---| -| [`ctrlrun-langgraph`](https://github.com/CTRLRun/ctrlrun/blob/main/adapters/langgraph/README.md) | `interrupt()` and the checkpointer | **prevention** — the resumption carries the arguments and core re-checks them against the hash | -| [`ctrlrun-openai-agents`](https://github.com/CTRLRun/ctrlrun/blob/main/adapters/openai-agents/README.md) | the SDK's tool-approval interruption | **attribution** — the SDK records *that* a call was approved, not what its arguments were | - -You build the `Control` with your policy, store, identity provider and authority document and -hand it over: an adapter never constructs one and never supplies a principal. Adapters ship on -their own version line, `adapters-langgraph-1.0` and never `0.6.1`, because an adapter breaks -when its framework makes a breaking release, which is not a kernel event. -[`docs/docs/adapters.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/docs/adapters.md) has the three -ways in and how to write one for a framework not listed here. +An adapter exists for one reason: to route an `approve` decision through the framework's own +interrupt, so a human answers where they already answer. There is never a second place to say +yes. [`ctrlrun-langgraph`](https://github.com/CTRLRun/ctrlrun/blob/main/adapters/langgraph/README.md) +gives **prevention**, because the resumption carries the arguments and core re-checks them +against the hash. +[`ctrlrun-openai-agents`](https://github.com/CTRLRun/ctrlrun/blob/main/adapters/openai-agents/README.md) +gives **attribution**, because that SDK records *that* a call was approved and not what its +arguments were. None of the three is only for agents: a worker, a webhook handler and a +scheduled job cannot tell a first attempt from a retry either. ## The same shape in nine domains Nothing in CTRLRun knows what a refund is. An action is a **name**, **canonical arguments**, an **effect key** and a **resource**, and the three questions asked of it are the same whichever domain it came from: how much autonomy does *this action* get, did a human approve *this exact* -action, and has this effect already happened. A payout, a namespace and a change of dose are -the same shape to the kernel. Two things carry the domain, and you write both: - -- **The effect key is the only domain knowledge in the system** — the string that says two calls - are the same real-world consequence. `refund:{payment_id}`, `namespace:{cluster}:{name}`, - `grant:{user_id}:{role}`, `prescription:{patient_id}:{drug}`. Name it well and a retry cannot - act twice; leave it out and there is nothing for *at most once* to be about, which is why the - gateway prints every action in your policy that has no `effect:` template on the line that - starts it. -- **Conditions are arguments, not amounts.** `amount_lte` is not a money feature: the condition - language is `_`, so the same operators read `replicas_lte: 10`, - `host_count_lte: 1`, `role_in: [reader, viewer]` and `to_domain_eq: acme.com`. Any integer - argument can be bounded and any argument can be matched, so a band is available to a domain - that has never issued an invoice. - -The nine files under -[`examples/policies/`](https://github.com/CTRLRun/ctrlrun/tree/main/examples/policies) are that -applied, one per domain. Adapt them; none is a drop-in. +action, and has this effect already happened. Two things carry your domain, and you write both. + +- **The effect key is the only domain knowledge in the system.** It is the string that says two + calls are the same real-world consequence: `refund:{payment_id}`, + `namespace:{cluster}:{name}`, `grant:{user_id}:{role}`, `prescription:{patient_id}:{drug}`. + Name it well and a retry cannot act twice; leave it out and there is nothing for *at most + once* to be about. +- **Conditions are arguments, not amounts.** The language is `_`, so the same + operators read `replicas_lte: 10`, `role_in: [reader, viewer]` and `to_domain_eq: acme.com` + as easily as `amount_lte`. A band is available to a domain that has never issued an invoice. | Domain | Autonomous | A human decides | Never | |---|---|---|---| @@ -397,274 +318,37 @@ applied, one per domain. Adapt them; none is a drop-in. | [Public services](https://github.com/CTRLRun/ctrlrun/blob/main/examples/policies/government.yaml) | `eligibility.precheck` | `benefit.terminate` | `record.delete` | | [Payments](https://github.com/CTRLRun/ctrlrun/blob/main/examples/policies/payments.yaml) | `stripe.refund` under €500 | `stripe.refund` above it | `stripe.delete_customer` | -Read any row left to right and it is one rule wearing different nouns: cheap to undo is -autonomous, anything that hands out power or leaves the building needs a human, and anything -that destroys the evidence is not an agent action at any size. The security row is the one to -read twice — adding a **deny** rule to a firewall is autonomous and adding an **allow** rule is -not, which no amount threshold would have told you. The policy is where your judgement about -your domain gets written down; CTRLRun is what makes it hold. - -## Write down what the agent may do - -One file. The rule is the same in every domain: cheap to undo is autonomous, anything that -leaves the building needs a human, money is by amount with both ends bound, and anything that -destroys the evidence is not an agent action at any size. Unknown actions are denied; there is -no default-allow. - -
-The whole file: CRM, Kubernetes, email, IAM, Stripe, the audit log - -```yaml runnable -schema: ctrlrun.policy/v2 - -actions: - # Cheap to undo: autonomous. - crm.update_record: - effect: "crm:{record_id}:{field}" - decision: allow - k8s.rollout_restart: - effect: "restart:{cluster}:{deployment}" - decision: allow - - # Leaves the building: a human, every time it reaches somebody or something outside. - email.send: - effect: "email:{message_id}" - rules: - - when: { to_domain_eq: "example.com" } - decision: allow - - decision: approve - iam.grant_role: - effect: "grant:{principal}:{role}" - rules: - - when: { role_in: [reader, viewer] } - decision: allow - - decision: approve - k8s.delete_namespace: - effect: "namespace:{cluster}:{name}" - decision: approve - - # Money: by amount, in integer minor units, with both ends bound. An upper bound alone is - # not a range, and a refund of a negative amount is a charge. - stripe.refund: - effect: "refund:{payment_id}" - rules: - - when: { amount_gte: 0, amount_lte: 50000 } # up to €500.00: autonomous - decision: allow - - when: { amount_gte: 0, amount_lte: 500000 } # up to €5,000.00: a human - decision: approve - - decision: deny - - # Destroys the evidence: denied, whoever asks. - stripe.delete_customer: - decision: deny - audit.log.delete: - decision: deny -``` - -
- -Amounts are integer minor units; floats are rejected outright, because `0.1` and `0.10` are the -same money and different hashes. The policy cannot see who is asking — deliberately, since v0.1: -`agent_eq` and every other principal-addressing condition is refused at load. Who may ask is the -second axis, `authority:`, and it is **opt-in, then fail-closed**: a policy without one behaves -exactly as before, and the moment one exists every principal needs a grant and no grant means -denied. The two axes are evaluated separately, authority first, and combine as the **stricter of -the two**. A `delegable` grant can be narrowed at runtime with `ctrlrun delegate` and never -widened — a delegation must be provably a subset of its parent on every dimension, at creation -and again at every evaluation, and omitting a dimension the parent constrains is rejected rather -than inherited — and `ctrlrun revoke` cuts a chain of any depth with one write. -[`docs/docs/authority.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/docs/authority.md) has it in -plain language. - -**Roll it out with `mode: observe` first.** One top-level line runs every real decision against -real traffic and records what *would* have been blocked, without blocking anything, and -`ctrlrun stats` gives you the numbers before you enforce them. It is not a dry run: it executes. - -## Prove it holds in your setup - -Everything above is proven by this repository's tests against this repository's configurations. -What you deploy is *your* policy, *your* grants and *your* store, so `ctrlrun verify` runs the -kernel's own failure scenarios against the configuration in front of it, in a scratch store, -with fake executors, and no network. Your `.ctrlrun/state.db` is byte-identical before and after. - -
-ctrlrun verify against a policy with approvals, effects and grants: 11/11 - -```console -$ ctrlrun verify -CTRLRun verify — ctrlrun 0.6.1, catalogue ctrlrun.guarantees/v2 -policy examples/authority/payments.yaml (ctrlrun.policy/v3, mode: enforce) -authority same document, 3 grants -store sqlite, scratch (created and destroyed for this run) - -G1 mutated approval refused PASS stripe.refund -G2 replayed approval refused PASS stripe.refund -G3 duplicate effect refused PASS stripe.refund -G4 one winner under concurrency PASS stripe.refund (8 processes) -G5 ambiguous blocks a blind retry PASS stripe.refund -G6 unknown action refused PASS -G7 no principal refused PASS stripe.refund -G8 expired authority refused PASS head-of-support -G9 delegation cannot escalate PASS head-of-support (6 of 6 dimensions) -G10 unknown exception is ambiguous PASS stripe.refund -G11 an altered receipt is detected PASS stripe.refund - -11/11 declared guarantees pass. 0 not applicable. -``` - -
- -**Not applicable is not a pass.** A policy with no `approve` rule cannot exercise the -approval-binding guarantees, and one with no `effect:` templates cannot exercise the effect -guarantees. Each is reported `N/A` with the reason, excluded from the denominator and listed -separately — the same command against a `ctrlrun.policy/v1` document with no templates and no -grants ends `6/6 declared guarantees pass. 5 not applicable: G3, G4, G5, G8, G9.`, never -`11/11`. There is no flag that folds one into the count. - -The badge at the top of this page means the **declared guarantees pass**: every guarantee this -configuration can exercise was exercised, and none failed. It does not mean secure, safe, -compliant, certified or audited, and [`docs/docs/verify.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/docs/verify.md#what-the-badge-means) -says on the same screen what verify cannot see: your executors, your `reconcile` hooks, where -you put the decorator, your deployment, and whether your policy is the right policy. There is a -[GitHub Action](https://github.com/CTRLRun/ctrlrun/blob/main/docs/docs/verify.md#in-ci): - -```yaml - - uses: CTRLRun/ctrlrun@v0.6.1 - with: - policy: ctrlrun.yaml -``` - -The ref pins the action's steps and **not** the package they install: `install` defaults to -`ctrlrun`, which is whatever PyPI has that day. Add `install: ctrlrun==0.6.1` to pin the tool -as well as the workflow. - -## What it guarantees, and what it can't - -The six guarantees, and which of the three ways in carries each: - - -| Guarantee | `@protect` | Gateway | Adapter | -|---|---|---|---| -| **Approval binding** — An approval is bound to the exact action; a mutated or replayed one is refused. | yes | yes | prevention or attribution, per adapter | -| **One effect, once** — One logical effect happens at most once, across threads, processes and hosts. | yes | yes | yes | -| **Unknown is not failed** — An unknown outcome is AMBIGUOUS, never FAILED, and blocks a blind retry. | yes | yes | yes | -| **Fail closed** — An unknown action, a missing policy or a missing principal is denied. | yes | yes | yes | -| **Authority and delegation** — With authority on, every principal needs a grant, and delegation cannot widen one. | yes | yes | yes | -| **Receipts** — Every executed action leaves a portable JSON receipt of who, what and outcome. | yes | yes | yes | - - -**It guarantees** what the matrix says, plus the mechanics behind it. Every line has a test in -[`docs/docs/CLAIMS.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/docs/CLAIMS.md), and -[`docs/docs/how-this-is-built.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/docs/how-this-is-built.md) -says how those tests came to exist: - -- **One effect, once, across hosts.** `BEGIN IMMEDIATE` on SQLite; on Postgres a unique index on - the effect key and compare-and-set updates whose row counts are checked. -- **Only `NotExecuted` means `FAILED`.** Every timeout, lost reply and unexpected exception is - `AMBIGUOUS`, and a crashed worker's effect stays that way until a human runs `ctrlrun resolve` - or a `reconcile` hook asks the remote — and then only in the direction that answer points. -- **Identity is consumed, never invented.** `ctrlrun[identity]` verifies a bearer token against a - JWKS or a pinned key and maps the claims onto a principal. CTRLRun issues no credential. -- **Every receipt names the policy that decided it** and carries the hash of the receipt before - it. Where a policy changes between the approval and the execution, the approval is re-checked - against the policy in force at execution. -- **Releases carry PyPI provenance attestations from GitHub Actions**: trusted publishing, no API - token anywhere, an attestation on every distribution naming the workflow that built it. -- **The operator works from the shell** — `approve`, `deny`, `resolve`, `inspect`, `receipts`, - `effects`, `stats` against any store — or over MCP with `ctrlrun mcp-operator`. It records who - answered; it does not check that they were entitled to. - -**It can't**, and does not claim to: - -- CTRLRun cannot guarantee exactly-once execution against external systems it doesn't control. - It refuses to *knowingly* act twice; whether a remote acted is a fact only the remote holds. - It is not a transaction manager: it rolls nothing back. -- The receipt chain detects alteration, and alteration is not authorship. Receipts are not - signed, the chain is no evidence of who wrote one, and it is not tamper-proof: it does not - survive an administrator who can rewrite every row including the chain head, and erasing the - end of the log costs two statements. [`docs/docs/THREAT_MODEL.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/docs/THREAT_MODEL.md) states - what remains open. -- `ctrlrun verify` cannot see your executors. An executor that raises `NotExecuted` after the - remote acted turns the one retryable exception into a licence to act twice, and nothing here - can check that for you. -- **CTRLRun does not detect prompt injection**, and nothing here reads the agent's instructions - to decide whether they were poisoned. The narrower claim is the one that holds: an injected - instruction still has to get past a grant the agent does not hold, an amount that needs a - human, and an approval bound to the recipient the human saw. Containment of the consequence, - not detection of the cause. -- It does not host models, plan, prompt, retrieve, route, remember or orchestrate, and it makes - no claim about any standard: [`docs/docs/OWASP-AGENTIC-TOP10.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/docs/OWASP-AGENTIC-TOP10.md) - is a reading of somebody else's taxonomy against the guarantees, and names the four entries it - does not address. - -If an agent only reads and answers, you don't need CTRLRun. The moment it can **send, pay, -refund, delete, deploy, grant, revoke, approve, submit, purchase or cancel**, you do. - -## Running it in production - -**SQLite is the default and is production-grade on one host.** A file, no server, no ops — and -the concurrency guarantee is held by `BEGIN IMMEDIATE` and a unique constraint, across processes -and not merely across threads. **Postgres is for many hosts**: `pip install "ctrlrun[postgres]"`, -one URL, the same `StateStore` protocol extended by nothing, graded by the suite written for -SQLite. Choose by how many machines write, not by how serious you are. - - -- **Version 0.6.1**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **4,404 tests**, every version specified before it was written and every requirement mutation-tested. -- **11 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. -- **One host: a file.** SQLite, no server, no ops. **Many hosts: Postgres**, the same guarantees, graded by the same suite. -- **Soaked for 20m 0s on postgres**: 889,735 actions, 0 unattributed ambiguous outcomes, positive control fired. Nothing here establishes what only accumulates over days. [What it does not establish](https://ctrlrun.dev/docs/production/soak). -- **Each receipt carries the hash of the one before it**, so an alteration is detected and named. -- **Apache-2.0**, and the enforcement kernel stays open source. Releases carry PyPI provenance attestations from GitHub Actions. - -**Not yet:** - -- No external security audit. (planned for v0.8 or v0.9) -- No third-party review of the kernel. (every review so far was run inside this project) -- No sector packs. (the policy templates are starting points, not a product) - - -### Beyond one process - -`BEGIN IMMEDIATE` is a whole-database write lock on a local file; take the file away and *one -effect, once* has to be re-earned. What 0.6 added to re-earn it: - -- **Postgres**, **migrations** at open and forward-only with no flag that opens a database - un-migrated, **recovery after a crash**, and **a store conformance suite**, so a second backend - is graded rather than described. -- **Policy versioning** — a receipt from six months ago says what the rules were, not what they - are now. -- **Receipt integrity** — each receipt carries the hash of the one before it, so an edit, a - deletion from the middle or a reordering is detected and named by `seq`. It detects - **alteration**, which is not authorship: receipts are not signed. - -[ctrlrun.dev/production/index](https://ctrlrun.dev/docs/production/index) is the whole section: -choosing a store, what reservation does under a lost `COMMIT`, migrations, recovery after a -crash, the receipt chain, the soak, and what to watch once it is running. -[`CHANGELOG.md`](https://github.com/CTRLRun/ctrlrun/blob/main/CHANGELOG.md) has the entry, -including the two subcommands that ride along in this release without being part of the -milestone: `ctrlrun scan` and `ctrlrun mcp-operator`. +Read any row left to right and it is one rule wearing different nouns. The security row is the +one to read twice: adding a **deny** rule to a firewall is autonomous and adding an **allow** +rule is not, which no amount threshold would have told you. The policy is where your judgement +about your domain gets written down; CTRLRun is what makes it hold. ## Documentation -**[ctrlrun.dev](https://ctrlrun.dev)** is the documentation: concepts, guides, a -cookbook, the reference, and a browser demo that runs `ctrlrun demo` with no install. +**[ctrlrun.dev](https://ctrlrun.dev)** is the documentation: concepts, guides, a cookbook, the +full reference, and a browser demo that runs with no install. -| Section | Where | +| | | |---|---| | Start here | [Why](https://ctrlrun.dev/docs/why) · [Protect your first action](https://ctrlrun.dev/docs/get-started/quickstart) · [Try it in your browser](https://ctrlrun.dev/docs/try-it) | -| The ideas | [Concepts](https://ctrlrun.dev/docs/concepts/outcomes-and-ambiguous) | -| Doing something | [Guides](https://ctrlrun.dev/docs/guides/protect-a-function) · [Cookbook](https://ctrlrun.dev/docs/cookbook/index) | -| Running it for real | [Production](https://ctrlrun.dev/docs/production/index) · [Postgres](https://ctrlrun.dev/docs/production/postgres) · [Recovery](https://ctrlrun.dev/docs/production/recovery) · [Operations](https://ctrlrun.dev/docs/production/operations) | +| The ideas, and doing something with them | [Concepts](https://ctrlrun.dev/docs/concepts/outcomes-and-ambiguous) · [Guides](https://ctrlrun.dev/docs/guides/protect-a-function) · [Cookbook](https://ctrlrun.dev/docs/cookbook/index) | | MCP | [Overview](https://ctrlrun.dev/docs/mcp/overview) · [The gateway in five minutes](https://ctrlrun.dev/docs/mcp/gateway-in-5-minutes) | -| Every key, flag and error | [Reference](https://ctrlrun.dev/docs/reference/policy-yaml) | -| Compared with other things | [Compare](https://ctrlrun.dev/docs/compare/idempotency-keys) · [FAQ](https://ctrlrun.dev/docs/faq) | -| Security | [Threat model](https://ctrlrun.dev/docs/THREAT_MODEL) · [What verify guarantees](https://ctrlrun.dev/docs/security/verify-guarantees) · [SECURITY.md](https://github.com/CTRLRun/ctrlrun/blob/main/SECURITY.md) | -| How this is built, and what is not done | [How this is built](https://ctrlrun.dev/docs/how-this-is-built) | -| Every sentence above, mapped to the code and the test that proves it | [`docs/docs/CLAIMS.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/docs/CLAIMS.md) | -| The contract, per version | [`docs/SPEC-v0.1.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/SPEC-v0.1.md) · [v0.2](https://github.com/CTRLRun/ctrlrun/blob/main/docs/SPEC-v0.2.md) · [v0.3](https://github.com/CTRLRun/ctrlrun/blob/main/docs/SPEC-v0.3.md) · [v0.4](https://github.com/CTRLRun/ctrlrun/blob/main/docs/SPEC-v0.4.md) · [v0.5](https://github.com/CTRLRun/ctrlrun/blob/main/docs/SPEC-v0.5.md) · [v0.6](https://github.com/CTRLRun/ctrlrun/blob/main/docs/SPEC-v0.6.md) | -| Contributing | [`CONTRIBUTING.md`](https://github.com/CTRLRun/ctrlrun/blob/main/CONTRIBUTING.md), [`CODE_OF_CONDUCT.md`](https://github.com/CTRLRun/ctrlrun/blob/main/CODE_OF_CONDUCT.md) | -| Changelog and roadmap | [`CHANGELOG.md`](https://github.com/CTRLRun/ctrlrun/blob/main/CHANGELOG.md), [`docs/docs/ROADMAP.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/docs/ROADMAP.md) | +| Running it for real | [Production](https://ctrlrun.dev/docs/production/index) · [Postgres](https://ctrlrun.dev/docs/production/postgres) · [Recovery](https://ctrlrun.dev/docs/production/recovery) · [Operations](https://ctrlrun.dev/docs/production/operations) | +| Every key, flag and error | [Reference](https://ctrlrun.dev/docs/reference/policy-yaml) · [FAQ](https://ctrlrun.dev/docs/faq) | +| What holds, and what does not | [Threat model](https://ctrlrun.dev/docs/THREAT_MODEL) · [What `verify` proves](https://github.com/CTRLRun/ctrlrun/blob/main/docs/docs/verify.md) · [`CLAIMS.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/docs/CLAIMS.md), every sentence mapped to its test · [How this is built](https://github.com/CTRLRun/ctrlrun/blob/main/docs/docs/how-this-is-built.md) | + +## Contributing + +Issues and pull requests are welcome: +[`CONTRIBUTING.md`](https://github.com/CTRLRun/ctrlrun/blob/main/CONTRIBUTING.md) and +[`CODE_OF_CONDUCT.md`](https://github.com/CTRLRun/ctrlrun/blob/main/CODE_OF_CONDUCT.md) have the +working agreement, and +[`SECURITY.md`](https://github.com/CTRLRun/ctrlrun/blob/main/SECURITY.md) is how to report a +vulnerability. Every claim in this file has a test behind it, so a change to the prose usually +means a change to the suite. +[`CHANGELOG.md`](https://github.com/CTRLRun/ctrlrun/blob/main/CHANGELOG.md) and +[`docs/docs/ROADMAP.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/docs/ROADMAP.md) say +where it is going. Releases carry PyPI provenance attestations from GitHub Actions. ## License diff --git a/docs/README.md b/docs/README.md index 0833712..f48f8d9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,7 +2,8 @@ One Mintlify project, deployed from this repository's `/docs` directory. -- `index.mdx` serves `/`: the product homepage, execution boundary, failure example, and scenario explorer. +- `index.mdx` serves `/`: the product homepage, the hallucinated-refund example, the execution boundary and its four rules, and the integration entry point. +- `try.mdx` serves `/try`: the interactive scenario explorer. Controls and a decision come first; the agent/boundary/system context sits in a disclosure below them. - `docs.mdx` serves `/docs`: the preserved technical overview. - `docs/` contains all technical pages, published under `/docs/...`. The three technical navigation tabs retain their full sidebar structure. - `risk-check.mdx` and `protect-my-agent.mdx` are custom-mode product pages. @@ -44,7 +45,7 @@ function accepting a page and an optional preview base URL. It exercises browser downloads, error recovery and all six stages at three viewport widths. The letter demo remains independent. -The homepage explorer contains 48 domains and 238 actions. All rules are illustrative, client-side simulations. The original Python runtime demos remain at `/docs/try-it` and `/docs/demos/medical-affairs`; they load their runtime only when invoked. +The scenario explorer contains 48 domains and 238 actions. All rules are illustrative, client-side simulations. The original Python runtime demos remain at `/docs/try-it` and `/docs/demos/medical-affairs`; they load their runtime only when invoked. The risk check stores no answers and sends no form data until the visitor submits an architecture review. Its result distinguishes indicated patterns from unknown answers and explains the scoring rule. diff --git a/docs/docs.json b/docs/docs.json index 0fa16ed..2850cdb 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -16,6 +16,10 @@ "description": "Execution safety for AI agents. Control consequential actions before they affect real systems.", "navbar": { "links": [ + { + "href": "/#how-it-works", + "label": "How it works" + }, { "href": "/docs", "label": "Documentation" diff --git a/docs/docs/CLAIMS.md b/docs/docs/CLAIMS.md index 824d3ca..ffde802 100644 --- a/docs/docs/CLAIMS.md +++ b/docs/docs/CLAIMS.md @@ -113,7 +113,7 @@ by its quoted claim, and `tests/test_docs_audit.py` fails if a named row is not | "Not applicable is not a pass" | `Report.applicable` is passes plus failures — `verify/report.py`; every N/A reason is a statement about the document — `verify/guarantees.py` | `test_T101_a_policy_with_no_approve_rule_makes_G1_and_G2_not_applicable`, `test_T102_a_policy_with_no_effect_templates_makes_G3_G4_and_G5_not_applicable` | | "`6/6 (5 not applicable)`, never `11/11`" | `Report.summary_line` — the N/A ids are a separate sentence, never a parenthesis inside the fraction | `test_T113_the_summary_is_the_last_line_and_names_the_not_applicable_ids` (asserts `11/11` appears nowhere in an N/A run) | | "There is no flag that folds one into the count" | There is no such parameter on `run()` (§9.1 freezes the signature) and no such option on the CLI | `test_T101b_zero_applicable_guarantees_is_not_a_pass` — `0/0` exits **2** | -| The two quoted reports | Both are real runs; the first is asserted line by line against `run(examples/authority/payments.yaml)` and against `docs/docs/verify.md`'s copy | `test_the_readme_quotes_the_real_verify_output`, `test_the_readme_and_the_verify_page_quote_the_same_report` | +| The quoted report | A real run, asserted line by line against `run(examples/authority/payments.yaml)`. The README carried a second copy until the 2026-09-09 rewrite; `docs/docs/verify.md` is now its only home, and the README keeps the badge sentence | `test_the_verify_page_quotes_the_real_verify_output`, `test_the_readme_says_what_the_badge_does_not_mean` | | "means the **declared guarantees pass**" | `badge_from_document` — `verify/report.py`; the phrase is the first sentence under `docs/docs/verify.md#what-the-badge-means` | `test_T119_the_rendered_badge_text_is_exactly_CTRLRun_verified_N_over_M`, `test_T119_the_link_target_carries_the_exact_phrase` | | "It does not mean secure, safe, compliant, certified or audited" | Those words appear in `docs/docs/verify.md` only inside the sentence that refuses them, and nowhere in the badge, the summary, `action.yml` or the workflow | `test_T119_no_claim_uses_the_forbidden_vocabulary`, `test_T119_the_action_and_the_workflow_make_no_forbidden_claim` | | "There is a GitHub Action" | `action.yml` at the repository root — composite, one verify run, summary and badge rendered from its JSON | `test_T118_the_action_is_a_composite_action_at_the_repository_root`, and CI's own `verify` job against both example configurations | diff --git a/docs/images/demo-poster.jpg b/docs/images/demo-poster.jpg new file mode 100644 index 0000000..84d3842 Binary files /dev/null and b/docs/images/demo-poster.jpg differ diff --git a/docs/images/demo.mp4 b/docs/images/demo.mp4 new file mode 100644 index 0000000..a8edd05 Binary files /dev/null and b/docs/images/demo.mp4 differ diff --git a/docs/index.mdx b/docs/index.mdx index a7a31d2..1810995 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -9,96 +9,85 @@ canonical: "https://ctrlrun.dev/" "og:url": "https://ctrlrun.dev/" --- -import { DeckNav } from "/snippets/deck-nav.jsx"; +import { DemoFilm } from "/snippets/demo-film.jsx"; -
-
-
-
-

CONTROL THE ACTION. KEEP THE AUTONOMY.

-

Execution safety
for AI agents.

-

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.

The agent was certain.
The agent was wrong.

-

CTRLRun checks the action, not the reasoning.
It reads the arguments about to leave your process, and answers whether they may go.

-
-
-
- Without CTRLRun -
    -
  1. The ticket says: refund $500
  2. The agent asks for $5,000
  3. The tool is in its list
  4. The call goes through
  5. $4,500 too much
  6. -
+
+
+
+

CONTROL THE ACTION. KEEP THE AUTONOMY.

+

Execution safety
for AI agents.

+

Control what agents deploy, delete, send, or change. Any domain. Your rules.

+ -
- With CTRLRun -
    -
  1. The ticket says: refund $500
  2. The agent asks for $5,000
  3. Policy denies these arguments
  4. The call never leaves
  5. $0 wrongly paid
  6. -
+ Read the quickstart ↗ +
+
+

ONE EXAMPLE / THE MODEL HALLUCINATES AN AMOUNT

+
+

The model guesses.
CTRLRun does not.

+
+

The ticket saysrefund $500

+

The agent asks for$5,000

+
+
+
+ Without CTRLRun +
    +
  1. Nothing checks the amount
  2. The call goes through
  3. $4,500 too much
  4. +
+
+
+ With CTRLRun +
    +
  1. Your rule checks the amount
  2. The call never leaves
  3. $0 wrongly paid
  4. +
+
+
-
-
-
-
-
-

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.

- +
+

Any domain. Same control.

Explore 48 domains ↗
+ - -
-
-

HOW IT WORKS

Set limits. Check actions.
Know what happened.

-

A library inside your process.
Not a service in front of it.

+
+
YOUR AGENTProposes an action
+ +
YOUR RULESCTRLRun
+ +
✓ Allow◷ Ask a person× Block
-

Models will be wrong sometimes. CTRLRun decides whether being wrong can reach your systems.

-
-
- 01 / SET LIMITS

Your rules define what AI may reach

-
  • Action rulesAllow, require approval or block, before an action runs.
  • Agent permissionsEach agent's limits. Delegating work cannot widen them.
  • Data-use rulesControl actions by the data labels your team supplies.
-
-
- 02 / CHECK ACTIONS

A wrong request need not become a costly action

-
  • Exact approvalsApprove the precise action. Changed details need a new one.
  • Duplicate preventionBlock repeats, even when different agents try.
  • Outcome checksHold retries until the outcome is confirmed.
-
-
- 03 / KNOW WHAT HAPPENED

Trust backed by a record of what AI did

-
  • Action historyTrace requests, decisions and results, including refusals.
  • Control testingPreview what rules would block before enforcing them.
-
-
-

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 →
+
+
EXACT APPROVAL

Exact means exact.

Changed arguments need a new approval.

+
DUPLICATE PREVENTION

Once stays once.

Same effect key. Shared store. No repeat.

+
UNCERTAIN OUTCOME

Unknown means wait.

Confirm the outcome before retrying.

+
A RECORD OF EACH DECISION

Every answer is kept.

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

Start at the function
that takes action.

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 integrating
-
pip install ctrlrun
{"import ctrlrun\n\n@ctrlrun.protect(\n    \"stripe.refund\",\n    effect=\"refund:{payment_id}\"\n)\ndef refund(payment_id, amount):\n    ..."}
  • allow → execute
  • approve → wait for exact approval
  • deny → do not execute
  • unknown outcome → do not retry blindly
-

Python, 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

One policy file, and the refusals it produces.

Or drive it yourself ↗
+ +

Plays muted with captions. Use the controls for sound.

+
-
-

FOR AGENTS MOVING INTO PRODUCTION

Know which actions are safe to automate.

Leave with a concrete execution-control map: what can run automatically, what needs a person, and what must stop until the outcome is known.

Get an execution-safety review Take the free risk check →Or just email me: contact@arpanghoshal.com ↗

Architecture reviews · Integration sprints · Production support
CTRLRun OSS remains Apache-2.0 and free.

+
+

A LIBRARY INSIDE YOUR PROCESS

Wrap the action.
Set the rules.

Protect the function that changes the world.

Protect your first action →
  • PythonThe decorator, around the call that causes the effect.
  • MCP gatewayPoint ctrlrun gateway at an existing MCP server. Five minutes ↗
  • Agent frameworksLangGraph and OpenAI Agents, as separate packages. Adapters ↗
  • Not only agentsWorkers, webhooks and scheduled jobs, same boundary. How ↗
  • HTTP API PLANNEDNot built yet. The boundary is in-process today.
+
+
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 →

+
- -
Give agents autonomy.
Keep control of their actions.
-
- + +
Give agents autonomy.
Keep control of their actions.
diff --git a/docs/snippets/deck-nav.jsx b/docs/snippets/deck-nav.jsx deleted file mode 100644 index abf5b80..0000000 --- a/docs/snippets/deck-nav.jsx +++ /dev/null @@ -1,121 +0,0 @@ -/* Controls for the homepage deck. The slides themselves are plain markup in - index.mdx, so the page still reads and indexes with no JavaScript: without - this component it is a scroll-snap deck, and with it the arrows, the dots - and the keyboard work. */ -export const DeckNav = ({ labels = [] }) => { - const [active, setActive] = useState(0); - const [count, setCount] = useState(labels.length); - const deckRef = useRef(null); - const slidesRef = useRef([]); - const activeRef = useRef(0); - - const goTo = (index) => { - const deck = deckRef.current; - const slides = slidesRef.current; - if (!deck || !slides.length) return; - const target = slides[Math.max(0, Math.min(slides.length - 1, index))]; - if (!target) return; - const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; - deck.scrollTo({ top: target.offsetTop, behavior: reduce ? 'instant' : 'smooth' }); - }; - - useEffect(() => { - const deck = document.querySelector('.cr-deck'); - if (!deck) return undefined; - deckRef.current = deck; - slidesRef.current = Array.from(deck.querySelectorAll('.cr-slide')); - setCount(slidesRef.current.length); - - // The deck sits under whatever chrome the docs theme renders above it, and - // every slide is exactly one viewport. Where a composition is taller than - // the room it has, scale it down rather than let it scroll or clip. - const fit = () => { - const navbar = document.getElementById('navbar'); - const top = navbar ? navbar.getBoundingClientRect().height : 0; - deck.style.setProperty('--cr-chrome', Math.round(top) + 'px'); - // One scale for the whole deck, not one per slide: a slide scaled on its - // own would sit at a different left edge from its neighbours, and the - // misalignment reads as a mistake when you arrow between them. - let scale = 1; - slidesRef.current.forEach((slide) => { - const inner = slide.querySelector('.cr-slide-inner'); - if (!inner) return; - const style = getComputedStyle(slide); - const roomY = slide.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom); - const roomX = slide.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight); - // offsetHeight is layout height, so it does not compound with the scale - // already applied on the previous pass. - scale = Math.min(scale, roomY / Math.max(1, inner.offsetHeight), roomX / Math.max(1, inner.offsetWidth)); - }); - deck.style.setProperty('--cr-slide-scale', String(Math.max(0.5, scale))); - }; - - let frame = 0; - const update = () => { - const slides = slidesRef.current; - if (!slides.length) return; - let current = 0; - slides.forEach((slide, i) => { - const near = Math.abs(slide.offsetTop - deck.scrollTop); - if (near < Math.abs(slides[current].offsetTop - deck.scrollTop)) current = i; - }); - activeRef.current = current; - setActive(current); - }; - const schedule = () => { - cancelAnimationFrame(frame); - frame = requestAnimationFrame(() => { fit(); update(); }); - }; - - fit(); - update(); - deck.addEventListener('scroll', schedule, { passive: true }); - window.addEventListener('resize', schedule); - return () => { - cancelAnimationFrame(frame); - deck.removeEventListener('scroll', schedule); - window.removeEventListener('resize', schedule); - }; - }, []); - - useEffect(() => { - const onKey = (event) => { - // Leave typing, search and control activation alone. - if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; - if (event.target instanceof Element && event.target.closest('input, textarea, select, [contenteditable]')) return; - if (event.key === ' ' && event.target instanceof Element && event.target.closest('button, a')) return; - const steps = { ArrowDown: 1, PageDown: 1, ' ': 1, ArrowUp: -1, PageUp: -1 }; - if (event.key in steps) { - event.preventDefault(); - if (!event.repeat) goTo(activeRef.current + steps[event.key]); - } else if (event.key === 'Home') { - event.preventDefault(); - goTo(0); - } else if (event.key === 'End') { - event.preventDefault(); - goTo(slidesRef.current.length - 1); - } - }; - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, []); - - const names = count === labels.length ? labels : Array.from({ length: count }, (_, i) => 'Slide ' + (i + 1)); - - // Dots only. They are the deck's whole visible chrome, so unlike a decorative - // indicator they carry real labels and take focus. - return ( - - ); -}; diff --git a/docs/snippets/demo-film.jsx b/docs/snippets/demo-film.jsx new file mode 100644 index 0000000..5daeaca --- /dev/null +++ b/docs/snippets/demo-film.jsx @@ -0,0 +1,40 @@ +export const DemoFilm = ({ src, poster, label }) => { + const ref = useRef(null); + const [started, setStarted] = useState(false); + + useEffect(() => { + const video = ref.current; + if (!video || typeof IntersectionObserver === 'undefined') return; + if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; + const observer = new IntersectionObserver(entries => { + entries.forEach(entry => { + if (entry.isIntersecting) { + const play = video.play(); + if (play && play.catch) play.catch(() => {}); + setStarted(true); + } else if (!video.paused) { + video.pause(); + } + }); + }, { threshold: 0.5 }); + observer.observe(video); + return () => observer.disconnect(); + }, []); + + return ( + + ); +}; diff --git a/docs/snippets/scenario-explorer.jsx b/docs/snippets/scenario-explorer.jsx index 07bdf9a..529b8b8 100644 --- a/docs/snippets/scenario-explorer.jsx +++ b/docs/snippets/scenario-explorer.jsx @@ -216,25 +216,26 @@ export const ScenarioExplorer = ({ standalone = false }) => {
YOUR AGENT · THE LLM Decides what to do -

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.

CTRLRUN · THIS TOOL Decides whether it may run -

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.

THE REAL SYSTEM Where it becomes real -

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.

- Choose your domain + 1Choose your domain {pickerOpen &&
{ if (event.key === 'Escape') { setPickerOpen(false); buttonRef.current.focus(); } @@ -251,13 +252,8 @@ export const ScenarioExplorer = ({ standalone = false }) => {
}
- - -
-
- 48 DOMAINS. START WITH ONE - {quick.map(name => )} - + +
@@ -298,6 +294,11 @@ export const ScenarioExplorer = ({ standalone = false }) => {
+
+ 48 DOMAINS. START WITH ONE + {quick.map(name => )} + +
SHARE THIS SCENARIO diff --git a/docs/style.css b/docs/style.css index be7e386..729980b 100644 --- a/docs/style.css +++ b/docs/style.css @@ -29,54 +29,9 @@ .cr-site .cr-caption { font-size:12px; line-height:1.7; color:var(--cr-muted); } .cr-flow-arrow { font-size:26px; color:var(--cr-muted); text-align:center; } .cr-section { padding:64px 0; border-bottom:1px solid var(--cr-line); scroll-margin-top:100px; } -/* Homepage deck: three slides, one viewport each, no resting point between. */ -.cr-deck { --cr-chrome:64px; height:calc(100dvh - var(--cr-chrome)); overflow-y:auto; overflow-x:hidden; scroll-snap-type:y mandatory; scroll-behavior:smooth; overscroll-behavior:contain; scrollbar-width:none; max-width:none; padding:0; } -.cr-deck::-webkit-scrollbar { display:none; } -.cr-slide { height:calc(100dvh - var(--cr-chrome)); overflow:hidden; scroll-snap-align:start; scroll-snap-stop:always; display:flex; align-items:center; padding:34px 32px 44px; } -.cr-slide-inner { width:100%; max-width:1120px; margin:0 auto; display:flex; flex-direction:column; justify-content:center; gap:clamp(26px,3.8vh,48px); transform:scale(var(--cr-slide-scale,1)); transform-origin:center center; } -.cr-deck .cr-hero { padding:0; max-width:830px; } -.cr-deck .cr-how,.cr-deck .cr-integration,.cr-deck .cr-commercial { padding:0; border:0; } -.cr-deck .cr-chain-cta { margin-top:0; padding-top:0; border-top:0; } -.cr-deck .cr-how-close { margin-top:0; } -.cr-deck .cr-pillars { margin-top:0; } -.cr-deck .cr-failure-chain .cr-section-heading { margin-bottom:clamp(22px,3.2vh,34px); } -.cr-deck .cr-integration { display:grid; grid-template-columns:1fr 1fr; gap:clamp(34px,4vw,64px); align-items:center; } -.cr-deck .cr-commercial { display:grid; grid-template-columns:1.3fr 1fr; gap:clamp(34px,4vw,64px); align-items:center; padding-top:clamp(20px,2.6vh,30px); border-top:1px solid var(--cr-line); } -.cr-deck .cr-footer { padding:0; border-top:1px solid var(--cr-line); padding-top:20px; } -.cr-deck .cr-slide-3 .cr-slide-inner { gap:clamp(18px,2.4vh,28px); } -.cr-deck .cr-hero h1 { font-size:clamp(40px,5.4vw,68px); margin-bottom:20px; } -.cr-deck h2+p { margin-top:14px; } -.cr-deck .cr-section-heading h2+p { margin-top:0; } -.cr-deck .cr-code pre { padding:16px 22px; line-height:1.6; } -.cr-deck .cr-code .cr-outcomes { padding:12px 22px; line-height:1.65; } -.cr-deck .cr-install { padding:12px 22px; } - -/* The deck's only visible control: position dots down the right edge. */ -.cr-deck-dots { position:fixed; right:24px; top:50%; transform:translateY(-50%); z-index:40; display:flex; flex-direction:column; gap:12px; } -.cr-dot-btn { width:9px; height:9px; padding:0; border:1px solid var(--cr-muted); border-radius:50%; background:transparent; opacity:.5; transition:opacity .15s,background .15s; } -.cr-dot-btn[aria-current=true] { background:var(--cr-accent); border-color:var(--cr-accent); opacity:1; } -@media (max-width:900px) { - .cr-deck .cr-integration,.cr-deck .cr-commercial { grid-template-columns:1fr; } -} -/* Below the deck's comfortable size, fall back to an ordinary page. */ -@media (max-width:800px),(max-height:620px) { - .cr-deck { height:auto; overflow:visible; scroll-snap-type:none; } - .cr-slide { height:auto; min-height:0; overflow:visible; display:block; padding:38px 24px; } - .cr-slide-inner { transform:none; } - .cr-slide+.cr-slide { border-top:1px solid var(--cr-line); } - .cr-slide-inner { gap:32px; } - .cr-deck-dots { display:none; } -} .cr-section h2+p { margin-top:18px; } -.cr-failure { display:grid; grid-template-columns:1fr 1fr; gap:64px; align-items:center; } -.cr-comparison { display:grid; gap:22px; } -.cr-comparison>div { padding-left:18px; border-left:2px solid var(--cr-line); } -.cr-comparison p { font-size:14px; margin-top:8px; } -.cr-comparison .cr-safe { border-color:var(--cr-accent); } -.cr-safe strong { color:var(--cr-ink); } .cr-section-heading { display:flex; justify-content:space-between; gap:20px; align-items:flex-end; margin-bottom:28px; } .cr-section-heading .cr-eyebrow { margin-bottom:12px; } -.cr-try-actions { display:flex; flex-direction:column; align-items:flex-start; gap:12px; flex-shrink:0; } .cr-explorer { border:1px solid var(--cr-line); border-radius:8px; background:var(--cr-paper); } .cr-demo-toolbar { display:grid; grid-template-columns:1fr 1.1fr 1.1fr; gap:16px; padding:22px; border-bottom:1px solid var(--cr-line); } .cr-field { display:flex; flex-direction:column; gap:8px; font-size:12px; font-weight:550; min-width:0; } @@ -125,12 +80,9 @@ .cr-code pre { padding:22px; background:transparent; border:0; font-size:12px; line-height:1.85; overflow:auto; color:var(--cr-ink); } .cr-site code { font-family:ui-monospace,SFMono-Regular,Consolas,monospace; background:transparent; color:inherit; font-weight:400; padding:0; } .cr-code p { padding:0 22px 18px; font-size:10px; } -.cr-code .cr-outcomes { list-style:none; padding:16px 22px; border-top:1px solid var(--cr-line); font-size:12px; line-height:1.85; } .cr-integration .cr-integration-scope { grid-column:1 / -1; } .cr-integration-scope a { text-decoration:underline; text-underline-offset:3px; } .cr-integration .cr-caption a { text-decoration:underline; text-underline-offset:3px; } -.cr-commercial { display:grid; grid-template-columns:1.3fr 1fr; gap:64px; align-items:center; } -.cr-commercial-links { display:flex; flex-direction:column; align-items:flex-start; gap:16px; justify-self:end; } .cr-footer { display:flex; justify-content:space-between; gap:32px; padding:38px 0 50px; color:var(--cr-muted); font-size:13px; } .cr-footer strong { color:var(--cr-ink); font-weight:500; } .cr-footer>div { display:flex; flex-wrap:wrap; align-items:center; gap:24px; font-size:12px; } @@ -176,18 +128,17 @@ @media (max-width:800px) { .cr-site { padding-left:24px; padding-right:24px; } .cr-hero { padding-top:55px; } - .cr-failure,.cr-integration,.cr-commercial { gap:30px; } + .cr-integration { gap:30px; } .cr-demo-toolbar { grid-template-columns:1fr 1fr; } .cr-demo-toolbar>.cr-field:last-child { grid-column:1/-1; } .cr-section-heading { align-items:flex-start; flex-direction:column; gap:10px; } - .cr-commercial-links { justify-self:start; } } @media (max-width:600px) { .cr-hero { padding:42px 0 35px; } .cr-site .cr-lede { font-size:18px; } .cr-site .cr-intro { font-size:15px; } .cr-flow-arrow { transform:rotate(90deg); line-height:1; } - .cr-failure,.cr-integration,.cr-commercial,.cr-demo-stage { grid-template-columns:1fr; } + .cr-integration,.cr-demo-stage { grid-template-columns:1fr; } .cr-section { padding:40px 0; } .cr-demo-toolbar { grid-template-columns:1fr; padding:18px; gap:14px; } .cr-demo-toolbar>.cr-field:last-child { grid-column:auto; } @@ -344,18 +295,9 @@ body:has(.cr-site) { --topbar-tabs-height:0px; } .cr-share-actions { display:flex; flex-wrap:wrap; align-items:center; gap:16px; } .cr-share-actions .cr-button { min-height:40px; font-size:12px; } .cr-demo-note a { text-decoration:underline; text-underline-offset:3px; } -.cr-how { border-bottom:1px solid var(--cr-line); } -.cr-how-steps { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:26px 44px; margin-top:30px; } -.cr-how-steps>div { padding-top:16px; border-top:1px solid var(--cr-line); } -.cr-how-steps h3 { font-size:17px; margin:8px 0 8px; } -.cr-site .cr-how-steps p { font-size:13.5px; line-height:1.7; } -.cr-how-close { display:flex; flex-wrap:wrap; align-items:center; gap:12px 26px; margin-top:34px; padding-top:22px; border-top:1px solid var(--cr-line); } -.cr-site .cr-how-close p { flex:1 1 420px; font-size:13.5px; } -.cr-how-close a { text-decoration:underline; text-underline-offset:4px; } @media (max-width:900px) { .cr-lanes { grid-template-columns:1fr; } .cr-lanes .cr-flow-arrow { transform:rotate(90deg); line-height:1; } - .cr-how-steps { grid-template-columns:1fr; gap:22px; } } @media (max-width:600px) { .cr-lanes { padding:16px; } @@ -381,20 +323,9 @@ body:has(.cr-site) { --topbar-tabs-height:0px; } .dark .cr-chain-steps .cr-end.cr-bad { color:#f5a198; border-color:#f5a198; } .dark .cr-chain-steps .cr-end.cr-good,.dark .cr-site .cr-chain-label.cr-good { color:#9ec793; } .dark .cr-chain-steps .cr-end.cr-good { border-color:#9ec793; } -.cr-chain-cta { display:flex; justify-content:space-between; align-items:flex-end; gap:44px; margin-top:32px; padding-top:26px; border-top:1px solid var(--cr-line); } -.cr-site .cr-chain-cta p { font-size:13.5px; line-height:1.75; max-width:600px; } -.cr-pillars { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:26px; margin-top:34px; } -.cr-pillar { display:flex; flex-direction:column; gap:12px; padding:24px 22px 26px; border:1px solid var(--cr-line); border-top:2px solid var(--cr-accent); border-radius:6px; background:var(--cr-paper); } -.cr-pillar h3 { font-size:17px; letter-spacing:-.02em; } -.cr-site .cr-pillar ul { list-style:none; display:flex; flex-direction:column; gap:14px; padding:0; margin-top:4px; } -.cr-pillar li { position:relative; padding-left:16px; font-size:13px; line-height:1.65; color:var(--cr-muted); } -.cr-pillar li::before { content:""; position:absolute; left:0; top:.62em; width:6px; height:6px; border-radius:50%; background:var(--cr-accent); } -.cr-pillar li b { display:block; font-size:13.5px; font-weight:600; color:var(--cr-ink); } @media (max-width:900px) { - .cr-pillars { grid-template-columns:1fr; gap:18px; } .cr-chain-row { grid-template-columns:1fr; gap:8px; } .cr-chain { gap:26px; } - .cr-chain-cta { flex-direction:column; align-items:flex-start; gap:22px; } } @media (max-width:600px) { .cr-site .cr-chain-steps { flex-direction:column; gap:16px; } @@ -405,3 +336,197 @@ body:has(.cr-site) { --topbar-tabs-height:0px; } /* The wordmark carries the brand on every page; give it room to read. */ #navbar img.nav-logo { height:40px; } @media (max-width:600px) { #navbar img.nav-logo { height:32px; } } + +/* Product pages keep a natural document flow and readable type at every height. */ +.cr-home,.cr-try-page { max-width:1200px; } +.cr-home .cr-eyebrow,.cr-try-page .cr-eyebrow,.cr-home .cr-step,.cr-try-page .cr-step { font-size:12px; letter-spacing:.08em; } +.cr-home .cr-caption,.cr-try-page .cr-caption { font-size:14px; } +.cr-home .cr-button,.cr-try-page .cr-button,.cr-home .cr-text-link,.cr-try-page .cr-text-link { font-size:14px; } +.cr-home .cr-home-hero { display:grid; grid-template-columns:1fr 1.1fr; gap:72px; align-items:start; padding:64px 0 84px; } +.cr-home-pitch { padding-top:30px; } +.cr-home h1 { font-size:clamp(40px,4.7vw,60px); line-height:1.08; } +.cr-home .cr-lede { font-size:22px; line-height:1.55; max-width:430px; } +.cr-home .cr-actions { gap:18px 24px; margin:36px 0 0; } +.cr-hero-quickstart { display:inline-block; margin-top:24px; } +.cr-home .cr-integration { align-items:start; gap:80px; } +.cr-home .cr-integration .cr-button { margin-top:30px; } +.cr-film { display:block; width:100%; border:1px solid var(--cr-line); border-radius:10px; background:var(--cr-ink); aspect-ratio:16/9; } +.cr-site .cr-demo-film .cr-caption { margin-top:20px; max-width:820px; } +.cr-demo-film code { font-size:13px; } +.cr-code-col { min-width:0; } +.cr-home .cr-integration .cr-code-col { margin-top:8px; } +.cr-code-col .cr-proof-links { margin-top:26px; padding-left:2px; } +.cr-home .cr-code pre { font-size:14px; padding:24px; } +.cr-home .cr-install { font-size:14px; } +.cr-code-context { padding:20px 24px; border-top:1px solid var(--cr-line); } +.cr-code-context strong { font-weight:550; font-size:15px; } +.cr-site .cr-code-context p { font-size:14px; padding:0; margin-top:8px; } +.cr-home .cr-footer,.cr-home .cr-footer>div { font-size:14px; } +.cr-home .cr-section,.cr-try-workspace { scroll-margin-top:100px; } +.cr-home .cr-section { padding:92px 0; } +.cr-home .cr-section-heading { margin-bottom:40px; } +/* The illustration starts with a choice and result, with context available on demand. */ +.cr-try-header { padding:36px 0 18px; display:flex; justify-content:space-between; gap:32px; align-items:center; } +.cr-try-header>div { max-width:770px; } +.cr-try-header .cr-eyebrow { margin-bottom:10px; } +.cr-try-page h1 { font-size:clamp(30px,3.3vw,42px); line-height:1.15; } +.cr-try-header p:not(.cr-eyebrow) { font-size:16px; margin-top:14px; } +.cr-try-header>.cr-text-link { flex-shrink:0; } +.cr-site .cr-try-disclosure { font-size:14px; margin:0 0 20px; } +.cr-try-workspace { padding-bottom:36px; } +.cr-try-page .cr-demo-toolbar { padding:20px 24px; } +.cr-try-page .cr-field,.cr-try-page .cr-select-button,.cr-try-page select { font-size:14px; } +.cr-try-page .cr-request,.cr-try-page .cr-result { padding:24px; } +.cr-try-page .cr-request h3 { margin-top:8px; } +.cr-try-page .cr-request-line { padding:4px 0; } +.cr-try-page .cr-result>p,.cr-try-page .cr-split p { font-size:16px; } +.cr-try-page .cr-result .cr-rule { font-size:14px; } +.cr-try-page .cr-result .cr-check,.cr-try-page .cr-code-label { font-size:12px; } +.cr-try-page .cr-checks strong { font-size:14px; } +.cr-try-page .cr-checks em,.cr-try-page .cr-checks li>span>span { font-size:13px; } +.cr-try-page .cr-demo-stage { grid-template-columns:.85fr 1.15fr; } +.cr-try-page .cr-sees code { font-size:14px; overflow-wrap:anywhere; } +.cr-try-page .cr-chips { padding:20px 24px; border-top:1px solid var(--cr-line); } +.cr-try-page .cr-chip { min-height:44px; font-size:14px; } +.cr-try-page .cr-lanes { border-radius:0; border-bottom:1px solid var(--cr-line); } +.cr-try-page .cr-lane p { font-size:14px; } +.cr-site .cr-lane-note { padding:18px 24px 0; font-size:14px; } +.cr-site .cr-demo-prompt { padding:14px 24px 20px; font-size:15px; color:var(--cr-muted); } +.cr-demo-prompt b { color:var(--cr-ink); font-weight:600; } +/* The three controls are steps, not settings: number them. */ +.cr-field>span,.cr-field #cr-domain-label,.cr-domain-picker>span { display:flex; align-items:center; gap:9px; } +.cr-field i { display:inline-flex; align-items:center; justify-content:center; width:19px; height:19px; flex-shrink:0; border-radius:50%; background:var(--cr-accent); color:var(--cr-paper); font:600 11px/1 ui-monospace,SFMono-Regular,Consolas,monospace; font-style:normal; } +.dark .cr-field i { color:#171b15; } +.cr-try-page .cr-demo-note { font-size:13px; } +/* Keep documentation tools in documentation; product pages use the main links. */ +body:has(.cr-home) #search-bar-entry,body:has(.cr-home) #assistant-entry, +body:has(.cr-try-page) #search-bar-entry,body:has(.cr-try-page) #assistant-entry, +body:has(.cr-home) #search-bar-entry-mobile,body:has(.cr-home) #assistant-entry-mobile, +body:has(.cr-try-page) #search-bar-entry-mobile,body:has(.cr-try-page) #assistant-entry-mobile { display:none; } +@media (max-width:1000px) { + .cr-home .cr-home-hero { gap:32px; } + .cr-try-header { align-items:flex-start; flex-direction:column; gap:14px; } +} +@media (max-width:800px) { + .cr-home .cr-home-hero { grid-template-columns:1fr; padding:44px 0 60px; gap:40px; } + .cr-home .cr-section { padding:72px 0; } + .cr-home h1 { font-size:clamp(38px,7vw,56px); } + .cr-home .cr-section-heading { align-items:flex-start; flex-direction:column; } + .cr-try-page .cr-demo-stage { grid-template-columns:1fr; } + .cr-try-page .cr-result { border-left:0; border-bottom:1px solid var(--cr-line); grid-row:1; } + .cr-try-page .cr-demo-toolbar { grid-template-columns:1fr; } +} +@media (max-width:600px) { + .cr-home,.cr-try-page { padding:0 20px; } + .cr-home .cr-eyebrow { font-size:12px; letter-spacing:.05em; } + .cr-home .cr-lede { margin-top:20px; } + .cr-home .cr-actions { gap:12px; align-items:flex-start; } + .cr-hero-quickstart { padding:8px 0; } + .cr-home .cr-section { padding:56px 0; } + .cr-home .cr-section-heading { margin-bottom:28px; } + .cr-home .cr-failure-chain { padding:20px 16px; } + .cr-home .cr-code pre { font-size:12px; padding:18px; } + .cr-try-header { padding-top:26px; } + .cr-try-page .cr-demo-toolbar,.cr-try-page .cr-request,.cr-try-page .cr-result,.cr-try-page .cr-chips { padding:18px; } +} + +/* Preserve the original hallucination example, aligned for direct comparison. */ +.cr-home .cr-failure-chain { min-width:0; padding:30px 28px; border:1px solid var(--cr-line); border-radius:10px; background:var(--cr-paper); } +.cr-home .cr-failure-chain h2 { font-size:26px; } +/* Both endings share a premise: state it once, then fork. */ +.cr-chain-setup { margin:22px 0 26px; padding:15px 16px; border:1px solid var(--cr-line); border-radius:8px; background:var(--cr-panel); } +.cr-site .cr-chain-setup p { display:flex; justify-content:space-between; align-items:baseline; gap:16px; font-size:14px; font-weight:550; color:var(--cr-ink); } +.cr-site .cr-chain-setup p+p { margin-top:7px; padding-top:7px; border-top:1px solid var(--cr-line); } +.cr-chain-setup span { font-weight:400; color:var(--cr-muted); } +.cr-home .cr-chain { display:grid; grid-template-columns:1fr 1fr; gap:24px; } +.cr-home .cr-chain-row { display:flex; flex-direction:column; gap:14px; } +.cr-home .cr-chain-label { font-size:12px; letter-spacing:.05em; } +.cr-home .cr-chain-steps { display:grid; grid-template-rows:repeat(3,minmax(56px,auto)); gap:16px; } +.cr-home .cr-chain-steps li { font-size:14px; padding:12px 8px; line-height:1.4; color:var(--cr-ink); } +.cr-home .cr-chain-steps li:not(:last-child)::after { content:"↓"; right:auto; left:50%; top:auto; bottom:-15px; transform:translateX(-50%); } +.cr-home .cr-home-how { border-top:1px solid var(--cr-line); } +@media (max-width:600px) { + .cr-home .cr-failure-chain { padding:18px 14px; } + .cr-home .cr-failure-chain h2 { font-size:25px; } + .cr-home .cr-chain { gap:12px; } + .cr-home .cr-chain-label { font-size:12px; letter-spacing:0; } + .cr-home .cr-chain-steps { grid-template-rows:repeat(3,minmax(68px,auto)); } + .cr-home .cr-chain-steps li { padding:10px 6px; } +} +@media (max-width:600px) { + .cr-try-page .cr-demo-toolbar { grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px 10px; } + .cr-try-page .cr-demo-toolbar>.cr-field:last-child { grid-column:1 / -1; } +} + +/* Short visual story: many actions pass through one execution boundary. */ +.cr-example-wrap { min-width:0; } +.cr-site .cr-example-label { margin:0 0 12px; font:600 12px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace; letter-spacing:.07em; color:var(--cr-accent); } +.cr-domain-actions { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:16px; } +.cr-domain-actions a { display:flex; flex-direction:column; gap:13px; padding:22px 18px; background:var(--cr-paper); border:1px solid var(--cr-line); border-radius:6px; min-width:0; } +.cr-domain-actions strong { font-size:16px; font-weight:550; letter-spacing:-.02em; display:flex; align-items:center; justify-content:space-between; gap:8px; } +.cr-domain-actions strong span { color:var(--cr-accent); } +.cr-domain-actions a:hover { border-color:var(--cr-accent); } +.cr-home .cr-domain-actions .cr-step { font-size:12px; letter-spacing:0; } +.cr-boundary-flow { display:grid; grid-template-columns:1fr 40px 1fr 40px 1fr; align-items:center; gap:32px; padding:52px 36px; margin:40px 0 56px; border:1px solid var(--cr-line); border-radius:8px; } +.cr-boundary-source,.cr-boundary-gate { text-align:center; display:flex; flex-direction:column; gap:8px; } +.cr-boundary-source strong { font-weight:550; font-size:20px; } +/* The gate carries the logo itself: the keycap mark, then the wordmark, under the label. */ +.cr-boundary-gate { background:var(--cr-ink); color:#f2f3f5; border-radius:8px; padding:22px 18px 24px; } +.cr-home .cr-boundary-gate .cr-step { color:#f2f3f5; opacity:.6; } +.cr-gate-logo { display:flex; align-items:center; justify-content:center; gap:11px; margin-top:4px; } +.cr-gate-key { flex-shrink:0; } +.cr-gate-word { font-size:30px; font-weight:800; letter-spacing:-.045em; line-height:1.1; } +.cr-gate-word span { font-weight:400; letter-spacing:-.025em; } +.cr-boundary-arrow { font-size:30px; text-align:center; color:var(--cr-accent); } +.cr-boundary-decisions { display:flex; flex-direction:column; gap:10px; font-size:17px; font-weight:550; } +.cr-decision-allow { color:#356339; } +.cr-decision-approve { color:#885508; } +.cr-decision-deny { color:#9c372e; } +.dark .cr-decision-allow { color:#9ec793; } +.dark .cr-decision-approve { color:#edbd70; } +.dark .cr-decision-deny { color:#f5a198; } +.cr-memory-rules { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:24px 36px; } +.cr-memory-rules>div { padding-top:24px; border-top:2px solid var(--cr-accent); } +.cr-memory-rules h3 { font-size:21px; margin:10px 0 8px; } +.cr-memory-rules p { font-size:15px; } +.cr-memory-rules a { text-decoration:underline; text-underline-offset:3px; } +.cr-site .cr-not-llm { margin-top:52px; padding-top:30px; border-top:1px solid var(--cr-line); max-width:820px; font-size:15px; line-height:1.7; color:var(--cr-muted); } +.cr-not-llm strong { color:var(--cr-ink); font-weight:550; } +.cr-not-llm .cr-text-link { display:inline-block; margin-left:6px; } +.cr-site .cr-surfaces { list-style:none; padding:0; margin:34px 0 0; display:flex; flex-direction:column; gap:20px; } +.cr-surfaces li { position:relative; padding-left:16px; font-size:14px; line-height:1.6; color:var(--cr-muted); } +.cr-surfaces li::before { content:""; position:absolute; left:0; top:.6em; width:6px; height:6px; border-radius:50%; background:var(--cr-accent); } +.cr-surfaces b { display:block; font-size:14.5px; font-weight:600; color:var(--cr-ink); } +.cr-surfaces code { font-size:13px; } +.cr-surfaces b span { display:inline-block; margin-left:8px; padding:2px 7px; border:1px solid var(--cr-line); border-radius:3px; font:600 10px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace; letter-spacing:.1em; color:var(--cr-muted); vertical-align:1px; } +.cr-surface-planned::before { opacity:.35; } +.cr-surfaces a { color:var(--cr-ink); font-weight:550; text-decoration:underline; text-underline-offset:3px; } +.cr-proof-links { display:flex; flex-wrap:wrap; gap:12px 22px; font-size:14px; font-weight:550; margin-top:34px; } +.cr-proof-links a:hover,.cr-review-strip a:hover { text-decoration:underline; text-underline-offset:4px; } +.cr-site .cr-code-context p { margin:0; } +.cr-code-context a { text-decoration:underline; text-underline-offset:4px; } +.cr-review-strip { display:flex; flex-wrap:wrap; justify-content:space-between; align-items:center; gap:16px; padding:44px 0; border-bottom:1px solid var(--cr-line); } +.cr-review-strip strong { font-size:18px; font-weight:550; } +.cr-review-strip>div { display:flex; flex-wrap:wrap; gap:16px 24px; font-size:14px; font-weight:550; } +.cr-demo-next h2 { font-size:28px; } +@media (max-width:1000px) { + .cr-domain-actions { grid-template-columns:repeat(3,minmax(0,1fr)); } + .cr-boundary-flow { gap:16px; padding:28px 20px; grid-template-columns:1fr 24px 1fr 24px 1fr; } +} +@media (max-width:800px) { + .cr-home .cr-lede { max-width:520px; } + .cr-memory-rules { grid-template-columns:repeat(2,minmax(0,1fr)); gap:24px; } +} +@media (max-width:600px) { + .cr-home .cr-home-hero { padding:36px 0 52px; gap:34px; } + .cr-home .cr-lede { font-size:19px; } + .cr-domain-actions { grid-template-columns:repeat(2,minmax(0,1fr)); } + .cr-domain-actions a { padding:16px 12px; } + .cr-domain-actions strong { font-size:15px; } + .cr-boundary-flow { grid-template-columns:1fr; gap:12px; padding:24px 18px; margin-bottom:30px; } + .cr-boundary-arrow { transform:rotate(90deg); line-height:1; } + .cr-boundary-gate { width:100%; max-width:230px; justify-self:center; padding:18px; } + .cr-boundary-decisions { flex-direction:row; flex-wrap:wrap; justify-content:center; gap:8px 16px; font-size:15px; } + .cr-memory-rules { grid-template-columns:1fr; gap:22px; } + .cr-memory-rules h3 { font-size:21px; } +} diff --git a/docs/try.mdx b/docs/try.mdx index ba0649b..95c01df 100644 --- a/docs/try.mdx +++ b/docs/try.mdx @@ -10,37 +10,19 @@ mode: "custom" import { ScenarioExplorer } from "/snippets/scenario-explorer.jsx"; -
-
- ← CTRLRun -

TRY THE EXECUTION BOUNDARY

-

Choose an action.
See what CTRLRun stops.

-

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

The model decides what.
CTRLRun decides whether.

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.

+ Run the real Python demo ↗
- -
+

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

Then go and break the real thing.

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.

-
-
HERE

An illustration of the checks, with example rules that are not industry defaults. No code runs, no request is sent.

-
ONE PAGE FURTHER

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

From the demo to your own code.

Apache-2.0 · Runs in your environment

-
-
UNDERSTAND IT

How it works

Where CTRLRun sits, what it checks, and what it deliberately does not do. The plain-English version, then the architecture.

-
RUN IT

Protect your first action

pip install ctrlrun, then wrap the function that causes the effect. The quickstart adds your policy, identity and approval flow.

-
READ THE CASES

Concepts behind each refusal

Approval binding, effect keys, and outcomes and AMBIGUOUS: the three the demo leans on hardest.

-
SEE IT IN A DOMAIN

A worked application

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 →
+
+

Take it into your code.

+
Give agents autonomy.
Keep control of their actions.
diff --git a/tests/test_demo.py b/tests/test_demo.py index 36ad696..6a66259 100644 --- a/tests/test_demo.py +++ b/tests/test_demo.py @@ -698,12 +698,19 @@ def test_a_path_outside_the_run_directory_is_printed_whole(tmp_path): _RUN_VARYING = re.compile(r"(?:apr|dlg)_[0-9a-f]+") +#: The transcript moved into a collapsed block when the README was cut to three questions on +#: 2026-09-09. The guard did not move: every line the demo prints is still quoted there, and a +#: missing block is a failure rather than an empty string that would pass this vacuously. +_DEMO_BLOCK_ANCHOR = "What 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 = "What 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 ,", line) for line in report.to_text().splitlines() } - # The README quotes a path relative to the repository root; the report prints the path it + # The page quotes a path relative to the repository root; the report prints the path it # was given. Compare on the same footing rather than on how the test invoked it. printed = { line.replace(str(AUTHORITY_PAYMENTS), "examples/authority/payments.yaml") @@ -432,30 +432,28 @@ def test_the_readme_quotes_the_real_verify_output(): if re.sub(r"ctrlrun \S+,", "ctrlrun ,", line) not in printed ] - assert not missing, f"the README quotes lines verify does not print: {missing}" + assert not missing, f"the page quotes lines verify does not print: {missing}" -def test_the_readme_and_the_verify_page_quote_the_same_report(): - """Two copies of one output is two things that can drift. They are asserted equal here so - the drift is a test failure rather than a reader's discovery.""" - page = _repository_file(VERIFY_DOC) - quoted = page.split("```console")[1].split("```")[0] - - from_page = [line for line in quoted.splitlines() if line.strip() and not line.startswith("$")] +def test_the_verify_page_says_what_not_applicable_means(): + """The N/A semantics, on the page the badge links to. Asserted with the line wrapping + removed: a sentence that reads correctly and wraps across two lines is still the sentence, + and a test that could not see it would push prose onto one long line.""" + page = " ".join(_repository_file(VERIFY_DOC).split()) - assert from_page == _quoted_report() + assert "Not applicable is not a pass" in page + assert "never `11/11`" in page + assert "no flag that folds an N/A into the count" in page + assert "declared guarantees pass" in page -def test_the_readme_says_what_not_applicable_means(): - """One sentence on N/A semantics, on the same screen as the badge. Asserted with the line - wrapping removed: a sentence that reads correctly and wraps across two lines is still the - sentence, and a test that could not see it would push prose onto one long line.""" - section = " ".join(_readme_verify_section().split()) +def test_the_readme_says_what_the_badge_does_not_mean(): + """What the README keeps of the verify section: the badge, and the sentence that stops a + reader reading it as more than it is. The report itself lives on the page above.""" + readme = " ".join(_repository_file(README).split()) - assert "Not applicable is not a pass" in section - assert "never `11/11`" in section - assert "There is no flag that folds one into the count" in section - assert "declared guarantees pass" in section + assert "declared guarantees pass" in readme + assert "does not mean secure, safe, compliant, certified or audited" in readme # --- publishing the badge: the one place this repository asks for write access ---------------