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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ recursive-include docs *.md
# The README's header assets, and the sources they are rendered from. These stayed: the README
# is this repository's, and so is what it renders on PyPI.
recursive-include docs/assets *.svg *.png *.gif *.tape *.sh *.txt
# The three files the animation records, byte for byte the README's own runnable blocks;
# `tests/test_readme_assets.py` runs them and compares.
recursive-include docs/assets/recording *.yaml *.py
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
Expand Down
284 changes: 189 additions & 95 deletions README.md

Large diffs are not rendered by default.

20 changes: 8 additions & 12 deletions docs/assets/demo.expected.txt
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
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
2. Approval mutation
agent proposes refund €2,000 → human approves apr_15b1568d362999c10f7fd49c320619fb (bound to the action hash)
agent executes refund €5,000 →
✗ BLOCKED — approved action ≠ requested action (mismatch)
€500 -> succeeded
€5,000 -> a human decides: apr_63e80076f2cccfee52b17491a4b2e125
calls that reached the provider: 1
granted apr_63e80076f2cccfee52b17491a4b2e125 for sha256:22ec1c398e4b93d080b6cba61e5e11b0e21879552ac5dbf63c192d2b2e6af752
expires 2026-09-13T20:10:11.367Z
€5,000 with the approval -> succeeded
€9,000 on that same approval -> refused
calls that reached the provider: 1 (the €9,000 never left)
Binary file modified docs/assets/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
68 changes: 47 additions & 21 deletions docs/assets/demo.tape
Original file line number Diff line number Diff line change
@@ -1,37 +1,63 @@
# The README's animation. Regenerate from the repository root whenever the demo's output
# changes:
# The README's animation: the "Use it in three steps" section of README.md, recorded against
# the real library. Regenerate from the repository root whenever that section or the library's
# output changes:
#
# PATH=.venv/bin:$PATH vhs docs/assets/demo.tape
#
# What it records is `ctrlrun demo`'s first two scenarios, byte for byte: the refund that
# commits behind a lost response and the retry that is refused, then the approval that is bound
# to one action and refused for another. The demo prints all five scenarios at once, so the
# command is piped twice, and both pipes are typed in the open, because a recording that hid
# how it was made would be the wrong kind of demo for this project: `sed` ends the output on
# scenario 2's BLOCKED line, and `slow` — the only thing this file sets up off screen, a
# `while read` loop with a sleep — releases one line every 0.7 s so a reader can follow. Nothing
# is added, reordered or removed by either. `docs/assets/demo.expected.txt` holds the lines the
# recording ends on, and `tests/test_readme_assets.py` asserts they are lines the demo prints
# and the README quotes.
# What it records is the section itself. `docs/assets/recording/` holds the policy and the two
# programs, byte for byte the README's own runnable blocks, and `tests/test_readme_assets.py`
# asserts that they are. Nothing is staged: the provider is a fake that records calls instead
# of making them, so the recording opens no socket, and both programs exit non-zero on the
# path where the refusal did not happen, so a recording of a broken build fails instead of
# looking fine. The only thing that runs off screen is the copy of those three files into an
# empty directory. `docs/assets/demo.expected.txt` holds the lines the recording ends on, and
# the test runs the same three files and asserts every one of them is printed.

Output docs/assets/demo.gif

Set Shell bash
Set FontSize 17
Set Width 1340
Set Height 470
Set FontSize 20
Set Width 940
Set Height 420
Set Padding 28
Set Framerate 24
Set Theme { "name": "ctrlrun", "background": "#14161b", "foreground": "#e6e8ec", "cursor": "#F5A623", "selection": "#2c3038", "black": "#14161b", "red": "#f28b82", "green": "#8fd694", "yellow": "#F5A623", "blue": "#7aa2f7", "magenta": "#c8a1f0", "cyan": "#7fd6d6", "white": "#e6e8ec", "brightBlack": "#6f747d", "brightRed": "#f28b82", "brightGreen": "#8fd694", "brightYellow": "#F5A623", "brightBlue": "#7aa2f7", "brightMagenta": "#c8a1f0", "brightCyan": "#7fd6d6", "brightWhite": "#ffffff" }
Set TypingSpeed 45ms
Set TypingSpeed 40ms
Set PlaybackSpeed 1.0

# Off screen: the three files, copied into an empty directory, so every run starts from
# nothing and the store the recording writes is thrown away with it.
Hide
Type `cd $(mktemp -d) && slow() { while IFS= read -r line; do printf '%s\n' "$line"; sleep 0.7; done; } && PS1=$'\e[33m$\e[0m ' && clear`
Type `W=$(mktemp -d) && cp -R docs/assets/recording/. "$W" && cd "$W" && PS1=$'\e[33m$\e[0m ' && clear`
Enter
Sleep 500ms
Sleep 600ms
Show

Type "ctrlrun demo | sed '/approved action/q' | slow"
Sleep 700ms
# 1. One file says how much autonomy each action gets.
Type "cat ctrlrun.yaml"
Sleep 400ms
Enter
Sleep 16500ms
Sleep 5s

Type "clear"
Sleep 300ms
Enter
Sleep 300ms

# 2. The agent runs. €500 is autonomous. €5,000 stops and names the request a human answers.
Type "python agent.py"
Sleep 400ms
Enter
Sleep 4s

# 3. A human answers from the shell. The grant names the exact action hash it authorizes.
Type `ctrlrun approve "$(cat request_id.txt)"`
Sleep 400ms
Enter
Sleep 3500ms

# 4. The agent presents that approval, and then tries to spend it on €9,000.
Type "python approved.py"
Sleep 400ms
Enter
Sleep 6500ms
36 changes: 36 additions & 0 deletions docs/assets/recording/agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import sys

import ctrlrun


class FakeStripe:
"""Stands in for the provider: it records calls instead of making them."""

def __init__(self) -> None:
self.calls: list[tuple[str, int]] = []

def refund(self, payment_id: str, amount: int) -> dict:
self.calls.append((payment_id, amount))
return {"id": f"re_{payment_id}", "amount": amount, "status": "succeeded"}


stripe = FakeStripe()


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


if __name__ == "__main__":
with ctrlrun.context(agent="support-agent"):
print("€500 ->", refund(payment_id="txn_1", amount=50_000)["status"])
try:
refund(payment_id="txn_2", amount=500_000)
except ctrlrun.ApprovalRequired as pending:
print("€5,000 -> a human decides:", pending.request_id)
with open("request_id.txt", "w") as handle:
handle.write(pending.request_id)
else:
sys.exit("the €5,000 refund ran without a human; the policy is not in force")
print("calls that reached the provider:", len(stripe.calls))
22 changes: 22 additions & 0 deletions docs/assets/recording/approved.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import sys

import ctrlrun

from agent import refund, stripe

with open("request_id.txt") as handle:
request_id = handle.read().strip()

with ctrlrun.context(agent="support-agent"), ctrlrun.with_approval(request_id):
# Exactly what the human read: €5,000 on txn_2.
print("€5,000 with the approval ->", refund(payment_id="txn_2", amount=500_000)["status"])

# The same approval, one digit changed.
try:
refund(payment_id="txn_2", amount=900_000)
except ctrlrun.ApprovalMismatch:
print("€9,000 on that same approval -> refused")
else:
sys.exit("a mutated action ran on a human's approval; that is the bug this exists to stop")

print("calls that reached the provider:", len(stripe.calls), "(the €9,000 never left)")
11 changes: 11 additions & 0 deletions docs/assets/recording/ctrlrun.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
schema: ctrlrun.policy/v2

actions:
stripe.refund:
effect: "refund:{payment_id}"
rules:
- when: { amount_gte: 0, amount_lte: 50000 }
decision: allow # up to €500: the agent acts alone
- when: { amount_gte: 0, amount_lte: 1000000 }
decision: approve # up to €10,000: a human decides
- decision: deny # above that: never
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,11 @@ select = ["E", "F", "W", "I", "N", "UP", "B", "ANN", "SIM", "RUF"]
# Error class names are frozen by SPEC-v0.1 §8; no "Error" suffix.
"src/ctrlrun/errors.py" = ["N818"]
"scripts/release_provenance.py" = ["N818"]
# The README's recorded example (`docs/assets/demo.tape`). `approved.py` imports the `agent.py`
# it was recorded beside, which isort cannot classify from `src` and so sorts above `ctrlrun`;
# the order a reader expects, stdlib, then the library, then the file next to it, is the one it
# flags. Everything else ruff checks still applies to both files.
"docs/assets/recording/*.py" = ["I001"]
# An adapter's own refusals follow this codebase's convention and not the suffix rule, for the
# same reason `errors.py` does: they sit beside `ActionDenied`, `ApprovalRequired` and
# `DuplicateEffect` in an operator's `except` clause, and `ApprovalNotAskedError` next to those
Expand Down
Loading
Loading