Skip to content

Latest commit

 

History

History
291 lines (246 loc) · 13.6 KB

File metadata and controls

291 lines (246 loc) · 13.6 KB
title The execution safety layer for AI agents
sidebarTitle Overview
description The last check before an AI agent does something it can't undo. Autonomy belongs to the action, not the agent.
mode wide
og:title CTRLRun: the execution safety layer for AI agents
twitter:title CTRLRun: the execution safety layer for AI agents
canonical https://ctrlrun.dev/docs

CTRLRun is a Python library that sits between an agent's decision to act and the call that acts. 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.

pip install ctrlrun && ctrlrun demo
No install. The path an action takes, the five checks on it, and the refusal each one raises. One policy file, one decorator, one approval from the shell, three receipts.

Runs in production on a single file, or on Postgres across hosts. SQLite is the default and is production-grade on one host; Postgres is for many. Apache-2.0.

Protect one function

CTRLRun wraps the call that has the consequence, and a YAML file says how much autonomy that call gets. This is the whole integration for a function in your own process:

schema: ctrlrun.policy/v2

actions:
  stripe.refund:
    effect: "refund:{payment_id}"
    rules:
      - when: { amount_gte: 0, amount_lte: 50000 }     # up to €500: autonomous
        decision: allow
      - when: { amount_gte: 0, amount_lte: 500000 }    # up to €5,000: a human decides
        decision: approve
      - decision: deny                                  # above that: never
import ctrlrun


class Stripe:  # stands in for the real client so this block runs offline
    def refund(self, payment_id: str, amount: int) -> dict:
        return {"status": "succeeded"}


stripe = Stripe()


@ctrlrun.protect("stripe.refund", effect="refund:{payment_id}")
def refund(payment_id: str, amount: int) -> dict:
    return stripe.refund(payment_id, amount)


with ctrlrun.context(agent="refund-agent"):
    refund(payment_id="txn_1", amount=10000)  # €100: runs, and leaves a receipt
    try:
        refund(payment_id="txn_2", amount=200000)  # €2,000: waits for a human
    except ctrlrun.ApprovalRequired as pending:
        print("a human decides:", pending.request_id)
    else:
        raise SystemExit("the €2,000 refund ran without a human; the policy is not in force")

What the same function does next, and what stops it:

The agent CTRLRun
refunds €100 runs it; one receipt
refunds €2,000 raises ApprovalRequired; ctrlrun approve <id> from the shell lets it through
has €2,000 approved, executes €5,000 ApprovalMismatch: the approval is bound to the action a human saw
refunds €20,000 ActionDenied; no request is created
retries a refund whose reply was lost AmbiguousEffect: the remote may have committed; a human or a reconcile hook decides
runs the same refund from two workers one reserves refund:txn_1, the other gets DuplicateEffect

The refund is the first example because everyone understands it; the same file protects a kubectl delete, an IAM grant, a record deletion or an outbound email, and the cookbook has each of those as a runnable recipe.

What the demo shows

Five ways an agent action goes wrong, and what stops each one, in under a second with no network. The first scenario is the one that explains the product: a refund commits at the remote, the reply is lost, the agent retries, and the retry is refused. The customer was refunded once.

$ ctrlrun demo
CTRLRun demo — five ways an agent action goes wrong, and what stops it.
Policy: refunds up to €1,000 are autonomous, up to €10,000 need a human, above that are denied.

1. Duplicate effect after a lost response

   refund €500  →  remote commits  →  response lost  →  effect: AMBIGUOUS
   agent retries the same refund
   ✗ BLOCKED — effect may already have committed; blind retry refused
   remote refund calls: 1
   only a human moves it on:  ctrlrun resolve refund:txn_1 --committed|--failed

The other four are approval mutation, two agents racing for one effect, approval replay, and an agent trying to act outside what was delegated to it. The execution boundary draws the same decisions without an install, or read the full transcript in the repository README.

What it does

{/* generated from capabilities.yaml (mdx) — edit the YAML, never this grid /} An approval is bound to the exact action; a mutated or replayed one is refused. Since v0.1. One logical effect happens at most once, across threads, processes and hosts. Since v0.1. An unknown outcome is AMBIGUOUS, never FAILED, and blocks a blind retry. Since v0.1. An unknown action, a missing policy or a missing principal is denied. Since v0.1. Every principal needs a grant, delegation cannot widen one, and a grant bounds the total. Since v0.3. Every executed action leaves a portable JSON receipt of who, what and outcome. Since v0.1. One YAML file decides allow, approve or deny per action and argument. Since v0.1. Approve, deny, resolve, inspect and count from the shell, against any store. Since v0.1. Every guarantee in front of an MCP tool server, with no agent changes. Since v0.2. A reconcile hook asks the remote what happened and resolves an AMBIGUOUS effect. Since v0.2. Approval requests go to a webhook, such as Slack, and the answer comes back. Since v0.2. One span per action, one span event per step; argument values are opt-in. Since v0.2. A principal comes from a verified header or JWT; CTRLRun issues nothing. Since v0.3. A principal narrows its own grant at runtime; one revocation cuts the chain. Since v0.3. Records what enforcement would have blocked, blocks nothing, and counts it. Since v0.3. Runs the guarantee catalogue against your policy and store; N/A is not a pass. Since v0.4. A GitHub Action and a badge that means the declared guarantees pass. Since v0.4. An approval routed through the framework's own interrupt; never a second path. Since v0.5. SQLite on one host, Postgres across hosts, the same guarantees either way. Since v0.6. The same store on Postgres, graded by the suite written for SQLite. Since v0.6. Migrations run at open, forward only, and an unknown schema is refused. Since v0.6. A dead worker's effect stays AMBIGUOUS until a human or a hook resolves it. Since v0.6. Each receipt carries the hash of the one before; alteration is detected and named. Since v0.6. Every receipt names the policy hash and version that decided it. Since v0.6. Name the house controls an action satisfies, and receipts cite them. Since v0.6. Label arguments by data class and condition a rule on the labels present. Since v0.6. {/ end generated */}

Three ways in

You have Use Needs
Python in this process: a raw model call, a LangChain tool, a hand-rolled loop, a cron job the @protect decorator nothing beyond pip install ctrlrun
Tools behind an MCP server, in any language the gateway, ctrlrun gateway pip install "ctrlrun[gateway]"
A framework with its own approval interrupt, and a place where humans already answer an adapter the framework to have a human-in-the-loop primitive

Most readers need the decorator. An adapter buys exactly one thing, routing an approval through the framework's own interrupt, and a framework with no such primitive does not need one. Choosing between them has the decision table.

Where it stands

{/* generated from the suite, pyproject and the soak (mdx) — run the generator */}

  • Version 0.9.0, on PyPI, Python 3.11 and later.
  • 5,975 tests, every version specified before it was written and every requirement mutation-tested.
  • 24 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.
  • 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. (optional, and no release waits for one)
  • 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) {/* end generated */}

Start here

Protect one function end to end and read the receipt. Ten minutes. One action, five ways to stop it, drawn for any of twelve domains. Refunds, deploys, IAM, deletions, email, MCP, LangGraph: each a recipe that runs. Decorator, gateway, adapter: what each covers and what each needs. The gateway in front of any MCP server, and this site as an MCP server. Which store, what a lost `COMMIT` does, what survives a crash, and what to watch. The five principles, in 700 words. The page people link to. The idea that explains the product: a timeout is not a failure.

Ask your coding tool

This site is an MCP server. Add it to Cursor or any MCP client that takes an mcpServers entry, and the assistant answers from these pages rather than from memory:

{
  "mcpServers": {
    "ctrlrun-docs": { "type": "http", "url": "https://ctrlrun.dev/mcp" }
  }
}

The server exposes one tool, a search across this documentation. When the site moves to its own domain the URL moves with it; the current one is always in this block.

Next

  • Why: what CTRLRun believes and why.
  • Install: what pip install ctrlrun puts on your machine, and what it does not.
  • How this is built: the discipline behind the guarantees.