diff --git a/docs.json b/docs.json index 2a3554c..f15ffe2 100644 --- a/docs.json +++ b/docs.json @@ -25,18 +25,13 @@ "label": "Docs" }, { - "href": "/#pro-and-enterprise", - "label": "Free, Pro, Enterprise" - }, - { - "href": "https://github.com/CTRLRun/ctrlrun", - "label": "GitHub" + "href": "/#verify", + "label": "Verify" } ], "primary": { - "type": "button", - "label": "Protect my agent", - "href": "/protect-my-agent" + "type": "github", + "href": "https://github.com/CTRLRun/ctrlrun" } }, "navigation": { diff --git a/fonts/GeistMono-400.woff2 b/fonts/GeistMono-400.woff2 new file mode 100644 index 0000000..1ee228f Binary files /dev/null and b/fonts/GeistMono-400.woff2 differ diff --git a/fonts/Satoshi-400.woff2 b/fonts/Satoshi-400.woff2 new file mode 100644 index 0000000..81c40ab Binary files /dev/null and b/fonts/Satoshi-400.woff2 differ diff --git a/fonts/Satoshi-500.woff2 b/fonts/Satoshi-500.woff2 new file mode 100644 index 0000000..ffd0ac9 Binary files /dev/null and b/fonts/Satoshi-500.woff2 differ diff --git a/fonts/Satoshi-700.woff2 b/fonts/Satoshi-700.woff2 new file mode 100644 index 0000000..0a8db7a Binary files /dev/null and b/fonts/Satoshi-700.woff2 differ diff --git a/images/demo.gif b/images/demo.gif new file mode 100644 index 0000000..f3e8f9b Binary files /dev/null and b/images/demo.gif differ diff --git a/images/og-home.png b/images/og-home.png new file mode 100644 index 0000000..2f53480 Binary files /dev/null and b/images/og-home.png differ diff --git a/index.mdx b/index.mdx index d6d67e3..11aa0f9 100644 --- a/index.mdx +++ b/index.mdx @@ -9,98 +9,216 @@ canonical: "https://ctrlrun.dev/" "og:url": "https://ctrlrun.dev/" --- -import { HomeSlides } from "/snippets/home-slides.jsx"; import { HowDiagram } from "/snippets/how-diagram.jsx"; -import { CommercialTiers } from "/snippets/commercial-tiers.jsx"; - -
- -
-
-

THE AGENT IS PROBABILISTIC. THE ACTION IS NOT.

-

CTRLRun stops AI agents from taking wrong, restricted, or malicious actions in your workflows.

-

Every action is checked against your rules before it runs. Allowed actions go through. Sensitive ones wait for a person. Forbidden ones are blocked.

-
- Protect my agent - GitHub -
-

Open source · one line, any action{'@ctrlrun.protect("your.action")'}

-
-
-

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. -
-
-
-
+import { LaunchUpdates } from "/snippets/launch-updates.jsx"; + +
+ +
+
+

CTRLRun stops AI agents from taking wrong, restricted, or malicious actions in your workflows.

+

Every action is checked against your rules before it runs. Allowed actions go through. Sensitive ones wait for a person. Forbidden ones are blocked.

+

Execution safety for AI agents. 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.

+ -
-

Works with agents you can and can't modify.

-

WhatsApp, Slack, Teams, Claude Code, Cursor, Codex, ChatGPT, OpenAI Agents. Any AI agent you have. If it acts through your systems, it is checked.

- How it works +
pip install ctrlrun && ctrlrun demo
+
+
+
+ A terminal recording of the three steps below. A policy file gives refunds up to €500 to the agent, sends refunds up to €10,000 to a human, and denies the rest. The agent refunds €500 on its own; a €5,000 refund stops and names the request a human answers. ctrlrun approve grants it against the hash of that exact action. The agent runs the €5,000 refund, then tries €9,000 on the same approval, and that is refused: calls that reached the provider, 1, the €9,000 never left. +
+
+
+ +
+
+
+

Something bigger is coming.

+

CTRLRun is the kernel. What we are building on it is almost ready. Leave your email and you will hear first, once, when it lands.

-
+ +
+
-
-
-

HOW CTRLRUN WORKS

-

Let it run. Ask a human. Or stop it cold.

-

CTRLRun stops an agent from taking an action your rules do not allow. Every action that leaves your agents, tools and workflows is normalized into one action, decided against your policy, held for a person where you require it, reserved so it cannot run twice, executed, resolved and recorded. An action with no rule is blocked, arguments changed after sign-off void the approval, and an outcome nobody knows is never retried on a guess.

+
+
+

What it does

+

The model guesses. CTRLRun does not. The ticket says refund €500. The agent asks for €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.

+
+
Without CTRLRun

Nothing checks the amount. The call goes through. €4,500 too much.

+
With CTRLRun

Your rule checks the amount. The call never leaves. €0 wrongly paid.

+

CTRLRun is that check. It reads the arguments about to leave your process and answers what may happen to them: let it run, ask a human, or stop it cold. Four rules do the work, and each one is a test in this repository before it is a sentence here.

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

Use it in three steps

+

The animation above is this section, recorded against the real library: one policy file, two short programs, four commands, nothing staged.

+ +

1. Install it.

+ +```bash +pip install ctrlrun +``` + +

2. Write down what the agent may do.

+

One file, ctrlrun.yaml. Amounts are integer minor units, so 50000 is €500. Both ends of every band are bound, because an upper bound alone lets a negative amount through, and a refund of a negative amount is a charge. Anything not listed is denied; there is no default-allow.

+ +```yaml +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 +``` + +

3. Wrap the call that has the consequence.

+

The decorator names the action, the effect key names the consequence it has in the world, and the context names who is acting. stripe here is a stand-in that records calls instead of making them.

+ +```python +import ctrlrun + + +@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="support-agent"): + refund(payment_id="txn_1", amount=50_000) # €500 -> succeeded + refund(payment_id="txn_2", amount=500_000) # €5,000 -> ApprovalRequired, a human decides +``` + +

The €500 refund runs on its own. The €5,000 one stops and names the request a human answers.

+

A human answers from the shell. The grant names the hash of the exact action the human read, and when it lapses.

+ +```console +$ ctrlrun approve "$(cat request_id.txt)" +granted apr_63e80076f2cccfee52b17491a4b2e125 for sha256:22ec1c39…6af752 +expires 2026-09-13T20:10:11.367Z +``` + +

The agent presents it, then tries to spend it on something else. The first call is exactly what the human approved, and it runs. The second is the same approval with one digit changed, and it matches nothing.

+ +```console +€5,000 with the approval -> succeeded +€9,000 on that same approval -> refused +calls that reached the provider: 1 (the €9,000 never left) +``` + +

Every attempt, refusals included, left a receipt, and ctrlrun receipts lists them. That is the whole integration: a policy file, a decorator, a context, and with_approval to present a grant. Money is the example, not the scope: the same policy language reads role_in or replicas_lte as easily as amount_lte.

+

Protect your first actionPolicy YAML referenceCookbook

+
+
+ +
+
+

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 that reaches its tools over MCP, in any language.

+ + + + + + + +
You haveUseNeeds
Python in this processthe @protect decorator, shown abovenothing beyond pip install ctrlrun
Tools behind an MCP server, in any languagethe gateway: pip install "ctrlrun[gateway]"one command, no change to agent or server code
A framework with its own approval interruptan adapterthe framework to have a human-in-the-loop primitive
+

It works with agents you can and can't modify. WhatsApp, Slack and Teams bots, ChatGPT, Cursor, Codex, OpenAI Agents: any AI agent you have. CTRLRun checks the action, not the agent, so if the agent acts through a tool server or an API you run, the action is checked, and the agent is not rebuilt, redeployed or told. Agents you can't modify says where the boundary goes for each kind.

+
+
+ +
+
+

How it works

+

Every protected call, whichever way it arrives, goes through the same seven steps. Only then does it reach your systems.

+
+
- -
-
-
-

FREE, PRO, ENTERPRISE

-

Free and open source: the boundary. Pro: the boundary, run for you. Enterprise: the boundary, shaped to you.

-
-
-
- FREE · OPEN SOURCE -

ctrlrun Open Source

-

Free. Run it yourself.

-

The boundary itself, at no cost. No account, no card, no call with us. Every rule that decides is code you can read, and every action leaves a receipt you keep. Free to use and free to change, under the Apache-2.0 licence.

-

Who does the work Your team, on your machines.

- Install it -
-
- IN DEVELOPMENT -

ctrlrun Pro

-

Integrate, analyze, protect. One dashboard.

-

Connect your agents and workflows and see what each connection covers. Search every action, decision and outcome across your agents. Manage policies and approvals from one place, test a rule in observe mode, then turn enforcement on. Built on the open-source foundation, run by us.

-

Who does the work Your team, on our dashboard.

- -
-
- ENGAGEMENTS OPEN -

ctrlrun Enterprise

-

The same product, shaped to your company.

-

Everything in Pro, with CTRLRun engineers who design, integrate and maintain the controls your business needs: custom policies and approval chains, connectors to your internal systems, your deployment options, the reports you ask for. Scoped to your requirements, delivered with your team.

-

Who does the work Our engineers, with your team.

- -
-
-
-
Let agents act.
Keep the consequences yours to decide.
-
+
+
+

The seven steps, in fullWhy unknown is not failed

+
+
+ +
+
+

Prove it in your own setup

+

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: 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. It reaches no network.

+ +```console +$ ctrlrun verify +CTRLRun verify — ctrlrun 0.9.0, catalogue ctrlrun.guarantees/v6 +policy ctrlrun.yaml (ctrlrun.policy/v2, mode: enforce) +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 +G10 unknown exception is ambiguous PASS stripe.refund +G11 an altered receipt is detected PASS stripe.refund +… 24 guarantees graded in the full run +``` + +

What verify provesThreat modelEvery sentence mapped to its test

+
+
+ +
+
+

Documentation

+

docs.ctrlrun.dev is the documentation: concepts, guides, a cookbook, the full reference, and a browser demo that runs with no install.

+
+ +
The ideas, and doing something with them
Concepts Guides Cookbook
+ + +
Every key, flag and error
Reference FAQ
+ + +
+
+
+ +
+
+

Built in the open

+

Issues and pull requests are welcome: CONTRIBUTING.md and CODE_OF_CONDUCT.md have the working agreement, and SECURITY.md is how to report a vulnerability. Every claim on this page has a test behind it, so a change to the prose usually means a change to the suite. CHANGELOG.md and the roadmap say where it is going. Releases carry PyPI provenance attestations from GitHub Actions.

+

+ PyPI version + CI status + OpenSSF Scorecard + OpenSSF Best Practices + Apache-2.0 license +

+
+
+ +
diff --git a/scripts/render-how-diagram.py b/scripts/render-how-diagram.py index 250fccc..2f40497 100644 --- a/scripts/render-how-diagram.py +++ b/scripts/render-how-diagram.py @@ -42,13 +42,13 @@ # The two ends name what the reader already has, in the vocabulary /protect-my-agent uses. # No worked example: the process is the point, and a refund makes it look like a payments tool. TOP = ( - "YOUR AGENTS, TOOLS AND WORKFLOWS", + "Your agents, tools and workflows", "An action is about to run.", "Any model, any framework. Existing code stays as it is.", ) -BOT = ("YOUR SYSTEMS", "The action arrives already checked.") +BOT = ("Your systems", "The action arrives already checked.") BOT_NOTE = "Allowed by your rules, approved where you require it, and never run twice." -BOX_LABEL = "CTRLRun · THE EXECUTION BOUNDARY" +BOX_LABEL = "CTRLRun, the execution boundary" # The commercial band that used to sit on the boundary is gone. The tiers have their own # section directly below the diagram, and the drawing repeated it a screen early. diff --git a/snippets/how-diagram.jsx b/snippets/how-diagram.jsx index 98049b3..1d2b239 100644 --- a/snippets/how-diagram.jsx +++ b/snippets/how-diagram.jsx @@ -10,13 +10,13 @@ export const HowDiagram = () => ( How CTRLRun works An action leaves the agents, tools and workflows you already run. Inside CTRLRun it is normalized into one action, decided against your rules, held for approval, reserved so it cannot run twice, executed, resolved and recorded. Only then does it reach your systems. An action with no rule is blocked, changed arguments void the approval, and an unknown outcome is never retried. - YOUR AGENTS, TOOLS AND WORKFLOWS + Your agents, tools and workflows An action is about to run. Any model, any framework. Existing code stays as it is. - CTRLRun · THE EXECUTION BOUNDARY + CTRLRun, the execution boundary 1 Normalize @@ -67,7 +67,7 @@ export const HowDiagram = () => ( - YOUR SYSTEMS + Your systems The action arrives already checked. Allowed by your rules, approved where you require it, and never run twice. @@ -76,13 +76,13 @@ export const HowDiagram = () => ( How CTRLRun works An action leaves the agents, tools and workflows you already run. Inside CTRLRun it is normalized into one action, decided against your rules, held for approval, reserved so it cannot run twice, executed, resolved and recorded. Only then does it reach your systems. An action with no rule is blocked, changed arguments void the approval, and an unknown outcome is never retried. - YOUR AGENTS, TOOLS AND WORKFLOWS + Your agents, tools and workflows An action is about to run. Any model, any framework. Existing code stays as it is. - CTRLRun · THE EXECUTION BOUNDARY + CTRLRun, the execution boundary 1 @@ -125,7 +125,7 @@ export const HowDiagram = () => ( - YOUR SYSTEMS + Your systems The action arrives already checked. Allowed by your rules, approved where you require it. diff --git a/snippets/launch-updates.jsx b/snippets/launch-updates.jsx new file mode 100644 index 0000000..b611060 --- /dev/null +++ b/snippets/launch-updates.jsx @@ -0,0 +1,53 @@ +// The email form under "Something bigger is coming." on the homepage. One field, one button. +// Posts to /api/interest with the launch-updates intent; the server decides the subject line +// and that no company is required for this intent, so nothing the browser sends can change that. +// Same idempotency and honeypot pattern as the tier forms: one requestId per filled-in form, a +// hidden "website" field that a person never sees. +// +// ENDPOINT lives inside the component because a snippet exports one binding and module-level +// constants beside it are not in scope when MDX evaluates it. +export const LaunchUpdates = () => { + const ENDPOINT = 'https://ctrlrun-review-form.vercel.app/api/interest'; + const [email, setEmail] = useState(''); + const [website, setWebsite] = useState(''); + const [sending, setSending] = useState(false); + const [sent, setSent] = useState(false); + const [error, setError] = useState(''); + const requestId = useRef(null); + const sendingRef = useRef(false); + const track = name => window.dispatchEvent(new CustomEvent('ctrlrun:conversion', { detail: { name } })); + + const send = async event => { + event.preventDefault(); + if (sendingRef.current || sent) return; + if (!requestId.current) requestId.current = crypto.randomUUID(); + sendingRef.current = true; setSending(true); setError(''); + try { + const response = await fetch(ENDPOINT, { + method: 'POST', credentials: 'omit', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ intent: 'launch-updates', email, website, requestId: requestId.current }), + signal: AbortSignal.timeout(15000) + }); + const data = await response.json(); + if (!response.ok || data.ok !== true || typeof data.id !== 'string') throw new Error(data.error || 'We could not confirm this. Retry, or email contact@arpanghoshal.com.'); + setSent(true); track('launch_updates_submitted'); + } catch (failure) { + setError(failure.name === 'TimeoutError' || failure.name === 'TypeError' ? 'We could not confirm this. Retrying is safe, or email contact@arpanghoshal.com.' : failure.message); + } finally { sendingRef.current = false; setSending(false); } + }; + + if (sent) return

Thanks. One email when it lands, nothing else.

; + return ( +
+
+ +
+ { setEmail(event.target.value); setError(''); }} /> + +
+ +
+ {error &&

{error}

} +
+ ); +}; diff --git a/style.css b/style.css index 63266fb..3956ff9 100644 --- a/style.css +++ b/style.css @@ -779,3 +779,151 @@ body:has(.cr-subpage) #search-bar-entry-mobile,body:has(.cr-subpage) #assistant- .cr-explorer { gap:44px; } .cr-dg-scroll { padding:18px 0 4px; } } + +/* --------------------------------------------------------------------------------------------- + ct-home: the ctrlrun.dev project page, rebuilt 2026-09-15 from internal/design/DESIGNS.md. + New namespace on purpose: the cr-* rules above still style the other custom pages until they + are redone. Tokens, type scale, spacing, radius and motion are the design file's. Monochrome. + --------------------------------------------------------------------------------------------- */ +@font-face { font-family: "Satoshi"; font-weight: 400; font-style: normal; font-display: swap; src: url("/fonts/Satoshi-400.woff2") format("woff2"), url("https://cdn.fontshare.com/wf/TTX2Z3BF3P6Y5BQT3IV2VNOK6FL22KUT/7QYRJOI3JIMYHGY6CH7SOIFRQLZOLNJ6/KFIAZD4RUMEZIYV6FQ3T3GP5PDBDB6JY.woff2") format("woff2"); } +@font-face { font-family: "Satoshi"; font-weight: 500; font-style: normal; font-display: swap; src: url("/fonts/Satoshi-500.woff2") format("woff2"); } +@font-face { font-family: "Satoshi"; font-weight: 700; font-style: normal; font-display: swap; src: url("/fonts/Satoshi-700.woff2") format("woff2"); } +@font-face { font-family: "Geist Mono"; font-weight: 400; font-style: normal; font-display: swap; src: url("/fonts/GeistMono-400.woff2") format("woff2"); } + +.ct-home { + --ct-bg:#F8F8F7; --ct-surface:#FFFFFF; --ct-surface-2:#F1F1EF; --ct-border:#E4E4E0; --ct-border-strong:#C9C9C3; + --ct-text:#14161A; --ct-text-2:#5C6068; --ct-text-3:#8B8F96; + --ct-accent:#B8730A; --ct-accent-hover:#8A5606; --ct-accent-soft:#FBF1DE; --ct-on-accent:#FFFFFF; --ct-button:#14161A; --ct-button-hover:#2A2D33; --ct-on-button:#FFFFFF; + --ct-danger:#D63A3A; --ct-success:#1F8A4C; + --ct-sans:"Satoshi",-apple-system,"Segoe UI",sans-serif; --ct-mono:"Geist Mono",ui-monospace,SFMono-Regular,Menlo,monospace; + --ct-ease-in:cubic-bezier(0.2,0,0,1); --ct-ease-out:cubic-bezier(0.4,0,1,1); + background:var(--ct-bg); color:var(--ct-text); font-family:var(--ct-sans); font-size:15px; line-height:22px; + -webkit-font-smoothing:antialiased; margin:0 calc(50% - 50vw); padding:0; width:100vw; overflow-x:clip; +} +.dark .ct-home { + --ct-bg:#111214; --ct-surface:#18191C; --ct-surface-2:#1F2024; --ct-border:#2A2B30; --ct-border-strong:#3A3B42; + --ct-text:#ECECEA; --ct-text-2:#A2A5AD; --ct-text-3:#6F737B; + --ct-accent:#F5A623; --ct-accent-hover:#FFC163; --ct-accent-soft:#3B3020; --ct-on-accent:#14161A; --ct-button:#ECECEA; --ct-button-hover:#FFFFFF; --ct-on-button:#14161A; +} +.ct-home *, .ct-home *::before, .ct-home *::after { box-sizing:border-box; } +.ct-wrap { max-width:1120px; margin:0 auto; padding:0 24px; } +@media (min-width:900px) { .ct-wrap { padding:0 48px; } } +.ct-home h1, .ct-home h2, .ct-home h3 { font-family:var(--ct-sans); font-weight:700; color:var(--ct-text); margin:0; text-wrap:balance; } +.ct-home h1 { font-size:36px; line-height:40px; letter-spacing:-0.02em; } +@media (min-width:720px) { .ct-home h1 { font-size:52px; line-height:54px; } } +.ct-home h2 { font-size:26px; line-height:32px; letter-spacing:-0.01em; } +@media (min-width:720px) { .ct-home h2 { font-size:36px; line-height:40px; letter-spacing:-0.02em; } } +.ct-home h3 { font-size:20px; line-height:28px; margin-top:32px; } +.ct-home p { margin:0; color:var(--ct-text-2); max-width:64ch; } +.ct-home p strong { color:var(--ct-text); font-weight:700; } +.ct-home a { color:var(--ct-text); text-decoration:underline; text-underline-offset:3px; text-decoration-thickness:1px; text-decoration-color:var(--ct-accent); } +.ct-home a:hover { color:var(--ct-accent-hover); text-decoration-color:var(--ct-accent-hover); } +.ct-home :is(a,button,input):focus-visible { outline:2px solid var(--ct-accent); outline-offset:2px; border-radius:6px; } +.ct-home code { font-family:var(--ct-mono); font-size:13px; background:var(--ct-surface-2); border:1px solid var(--ct-border); border-radius:6px; padding:1px 5px; color:var(--ct-text); } +.ct-home pre, .ct-home pre code { font-family:var(--ct-mono); font-size:13px; line-height:20px; } +.ct-home pre { background:var(--ct-surface); border:1px solid var(--ct-border); border-radius:10px; padding:16px 20px; overflow-x:auto; margin:16px 0 0; } +.ct-home pre code { background:transparent; border:0; padding:0; } +.ct-home em { font-style:italic; } + +/* hero */ +.ct-hero { padding:64px 0 0; } +@media (min-width:900px) { .ct-hero { padding-top:96px; } } +.ct-hero-inner { text-align:center; display:flex; flex-direction:column; align-items:center; gap:0; } +.ct-hero-inner h1 { max-width:22ch; } +.ct-hero-inner .cr-lede { font-size:17px; line-height:24px; max-width:56ch; margin-top:20px; color:var(--ct-text-2); } +.ct-sub { max-width:62ch; margin-top:16px !important; } +.ct-meta { font-size:13px; line-height:18px; color:var(--ct-text-3) !important; margin-top:12px !important; } +.ct-actions { display:flex; flex-wrap:wrap; justify-content:center; align-items:center; gap:20px; margin-top:28px; } +.ct-button { display:inline-flex; align-items:center; justify-content:center; height:36px; padding:0 14px; border-radius:6px; background:var(--ct-button); color:var(--ct-on-button) !important; font-weight:500; font-size:15px; text-decoration:none !important; border:1px solid transparent; cursor:pointer; white-space:nowrap; transition:background-color 150ms var(--ct-ease-in), transform 150ms var(--ct-ease-in); } +.ct-button:hover { background:var(--ct-button-hover); } +.ct-button:active { transform:translateY(1px); } +.ct-button:disabled, .ct-home fieldset:disabled .ct-button { opacity:.6; cursor:default; } +.ct-link { font-weight:500; } +.ct-install { margin-top:32px; } +.ct-install code { font-size:14px; padding:10px 16px; border-radius:6px; background:var(--ct-surface); } +.ct-recording { margin:48px 0 0; padding:0; } +.ct-recording img { display:block; width:100%; height:auto; max-width:960px; margin:0 auto; border:1px solid var(--ct-border); border-radius:10px; background:var(--ct-surface); } +@media (min-width:900px) { .ct-recording { margin-top:64px; } } +@keyframes ct-rise { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:none; } } +.ct-hero-inner > * { animation:ct-rise 250ms var(--ct-ease-in) both; } +.ct-hero-inner > *:nth-child(2) { animation-delay:40ms; } .ct-hero-inner > *:nth-child(3) { animation-delay:80ms; } .ct-hero-inner > *:nth-child(n+4) { animation-delay:120ms; } +@media (prefers-reduced-motion:reduce) { .ct-hero-inner > * { animation:none; } .ct-button { transition:none; } } + +/* the notify band */ +.ct-notify { padding:64px 0 0; } +@media (min-width:900px) { .ct-notify { padding-top:96px; } } +.ct-notify-inner { background:var(--ct-surface); border:1px solid var(--ct-border); border-radius:10px; padding:32px 24px; display:grid; gap:24px; } +@media (min-width:900px) { .ct-notify-inner { grid-template-columns:1fr 1fr; gap:48px; padding:40px 48px; align-items:center; } } +.ct-notify-copy h2 { font-size:26px; line-height:32px; letter-spacing:-0.01em; } +.ct-notify-copy p { margin-top:12px; max-width:44ch; } +.ct-notify-form { display:block; } +.ct-notify-fields { border:0; margin:0; padding:0; min-width:0; } +.ct-notify-label { display:block; font-size:13px; line-height:18px; color:var(--ct-text-2); margin-bottom:6px; } +.ct-notify-row { display:flex; gap:8px; flex-wrap:wrap; } +.ct-input { flex:1 1 220px; height:36px; padding:0 12px; border:1px solid var(--ct-border-strong); border-radius:6px; background:var(--ct-surface); color:var(--ct-text); font:inherit; font-size:15px; min-width:0; } +.ct-input::placeholder { color:var(--ct-text-3); } +.ct-input:focus { outline:2px solid var(--ct-accent); outline-offset:2px; border-color:var(--ct-border-strong); } +.ct-home fieldset:disabled .ct-input { opacity:.7; } +.ct-honeypot { position:absolute; left:-10000px; width:1px; height:1px; overflow:hidden; } +.ct-notify-error { margin-top:8px !important; font-size:13px; line-height:18px; color:var(--ct-danger) !important; } +.ct-notify-done { font-size:15px; color:var(--ct-text) !important; } + +/* sections */ +.ct-section { padding:64px 0 0; } +@media (min-width:900px) { .ct-section { padding-top:96px; } } +.ct-prose > p, .ct-prose > dl, .ct-prose > .ct-compare, .ct-prose > table { margin-top:16px; } +.ct-prose > h2 + p { margin-top:16px; } +.ct-lead { font-size:17px; line-height:24px; max-width:66ch; } +.ct-compare { display:grid; border:1px solid var(--ct-border); border-radius:10px; overflow:hidden; background:var(--ct-surface); max-width:880px; } +@media (min-width:720px) { .ct-compare { grid-template-columns:1fr 1fr; } } +.ct-compare > div { padding:16px 20px; } +.ct-compare > div + div { border-top:1px solid var(--ct-border); } +@media (min-width:720px) { .ct-compare > div + div { border-top:0; border-left:1px solid var(--ct-border); } } +.ct-compare-label { display:block; font-size:13px; line-height:18px; color:var(--ct-text-3); margin-bottom:6px; } +.ct-compare p { font-size:14px; line-height:20px; } +.ct-rules { display:grid; gap:12px 32px; margin:24px 0 0; max-width:880px; } +@media (min-width:720px) { .ct-rules { grid-template-columns:1fr 1fr; } } +.ct-rules div { padding:12px 0; border-top:1px solid var(--ct-border); } +.ct-rules dt { font-weight:700; color:var(--ct-text); } +.ct-rules dd { margin:2px 0 0; font-size:14px; line-height:20px; color:var(--ct-text-2); } +.ct-links { display:flex; flex-wrap:wrap; gap:8px 24px; margin-top:24px !important; } +.ct-table { width:100%; border-collapse:collapse; font-size:14px; line-height:20px; max-width:880px; } +.ct-table th { text-align:left; font-weight:500; color:var(--ct-text-3); font-size:13px; padding:8px 12px 8px 0; border-bottom:1px solid var(--ct-border); } +.ct-table td { padding:10px 12px 10px 0; border-bottom:1px solid var(--ct-border); vertical-align:top; color:var(--ct-text-2); } +.ct-table td:first-child { color:var(--ct-text); } +.ct-diagram { margin-top:32px; } +.ct-diagram svg { width:100%; height:auto; display:block; } +.ct-doclist { margin:24px 0 0; display:grid; gap:0; max-width:880px; } +.ct-doclist div { display:grid; gap:4px; padding:12px 0; border-top:1px solid var(--ct-border); } +@media (min-width:720px) { .ct-doclist div { grid-template-columns:260px 1fr; gap:24px; } } +.ct-doclist dt { color:var(--ct-text); font-weight:500; } +.ct-doclist dd { margin:0; display:flex; flex-wrap:wrap; gap:6px 20px; } +.ct-badges { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-top:24px !important; } +.ct-badges img { height:20px; width:auto; display:block; } +.ct-open { padding-bottom:64px; } +.ct-footer { border-top:1px solid var(--ct-border); padding:40px 0 48px; margin-top:32px; } +.ct-footer-inner { display:flex; flex-direction:column; gap:16px; } +@media (min-width:720px) { .ct-footer-inner { flex-direction:row; justify-content:space-between; align-items:baseline; } } +.ct-footer p { color:var(--ct-text-2); } +.ct-footer nav { display:flex; gap:24px; flex-wrap:wrap; } + + +/* one diagram at a time: the snippet ships a wide and a narrow drawing */ +.ct-home .cr-dia-narrow { display:none; } +@media (max-width:719px) { .ct-home .cr-dia-wide { display:none; } .ct-home .cr-dia-narrow { display:block; } } +/* the diagram, restyled monochrome inside this page */ +.ct-home .cr-diagram text { font-family:var(--ct-sans); } +.ct-home .cr-dia-label, .ct-home .cr-dia-num { fill:var(--ct-accent); font-family:var(--ct-sans); font-size:13px; font-weight:500; letter-spacing:0; } +.ct-home .cr-dia-name { fill:var(--ct-text); font-weight:700; } +.ct-home .cr-dia-gloss, .ct-home .cr-dia-note { fill:var(--ct-text-2); } +.ct-home .cr-dia-claim { fill:var(--ct-text); font-weight:500; } +.ct-home .cr-dia-strip, .ct-home .cr-dia-box { fill:var(--ct-surface); stroke:var(--ct-border); } +.ct-home .cr-dia-node { fill:var(--ct-surface-2); stroke:var(--ct-border); } +.ct-home .cr-dia-arrow { stroke:var(--ct-border-strong); } .ct-home .cr-dia-head { fill:var(--ct-border-strong); } +.ct-home .cr-dia-tier-label { fill:var(--ct-accent); font-family:var(--ct-sans); letter-spacing:0; } + +/* the project's own accent: amber and black, kept on purpose (2026-09-15) */ +.ct-home .cr-dot { color:var(--ct-accent); } +.ct-home .cr-mark { color:inherit; font-weight:inherit; } +.ct-wordmark img { height:28px; width:auto; display:block; } +.ct-input:focus { outline-color:var(--ct-accent); } diff --git a/tests/test_home_and_readme_agree.py b/tests/test_home_and_readme_agree.py index 0f59280..d7d7663 100644 --- a/tests/test_home_and_readme_agree.py +++ b/tests/test_home_and_readme_agree.py @@ -20,6 +20,7 @@ REPO_ROOT = Path(__file__).resolve().parents[1] HOME = REPO_ROOT / "index.mdx" +WWW = REPO_ROOT / "www" / "index.html" DIAGRAM = REPO_ROOT / "snippets" / "how-diagram.jsx" README = CORE_ROOT / "README.md" @@ -79,3 +80,17 @@ def test_the_readme_walks_the_homepage_seven_steps_in_order(): positions = [section.find(f"**{step}:") for step in steps] assert all(p >= 0 for p in positions), dict(zip(steps, positions, strict=True)) assert positions == sorted(positions), "the README walks the steps in the diagram's order" + + +def test_the_static_site_opens_with_the_same_h1_and_lede(): + """ctrlrun.dev is served from `www/` (Vercel) and docs.ctrlrun.dev from Mintlify; both + carry the homepage. The static page must open with the same H1 and lede the README does, + or the README sync above is only half true.""" + h1 = _homepage(r'

(.*?)

') + lede = _homepage(r'

(.*?)

') + page = WWW.read_text(encoding="utf-8") + assert h1 in _prose(page), "www/index.html does not carry the homepage H1" + assert lede in _prose(page), "www/index.html does not carry the homepage lede" + assert "ctrlaiagents" not in page and "ctrlpayments" not in page.lower(), ( + "the project site names a commercial site; it is not supposed to" + ) diff --git a/website-form/api/interest.mjs b/website-form/api/interest.mjs index 46aa613..5dc2382 100644 --- a/website-form/api/interest.mjs +++ b/website-form/api/interest.mjs @@ -6,8 +6,10 @@ const RECIPIENT = 'contact@arpanghoshal.com'; // Pro takes a waiting-list place and needs nothing else; Enterprise is a conversation, so it // asks what the deployment needs. The intent decides which, and nothing else in the request does. const INTENTS = { - 'pro-waitlist': { subject: 'CTRLRun Pro waiting list', heading: 'CTRLRun Pro waiting list request', requiresMessage: false }, - 'enterprise-contact': { subject: 'CTRLRun Enterprise enquiry', heading: 'CTRLRun Enterprise enquiry', requiresMessage: true } + 'pro-waitlist': { subject: 'CTRLRun Pro waiting list', heading: 'CTRLRun Pro waiting list request', requiresMessage: false, requiresCompany: true }, + 'enterprise-contact': { subject: 'CTRLRun Enterprise enquiry', heading: 'CTRLRun Enterprise enquiry', requiresMessage: true, requiresCompany: true }, + // The homepage's "Something bigger is coming" form: an email and nothing else. + 'launch-updates': { subject: 'CTRLRun launch updates', heading: 'CTRLRun launch updates: someone asked to hear first', requiresMessage: false, requiresCompany: false } }; const requests = new Map(); const WINDOW_MS = 600_000; @@ -26,7 +28,7 @@ export function validate(input) { if (!shape) throw new Error('Unknown request type.'); const email = field('email', 254); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || /[\r\n]/.test(email)) throw new Error('Enter a valid work email.'); - const company = field('company', 100); + const company = field('company', 100, shape.requiresCompany); const message = field('message', 600, shape.requiresMessage); const agents = field('agents', 200, false); const requestId = field('requestId', 36); @@ -64,12 +66,12 @@ export function createHandler({ env = process.env, fetcher = fetch, now = Date.n const entry = rateStore.get(rateKey) || { start: time, count: 0 }; if (entry.count >= MAX_REQUESTS || rateStore.size >= 10_000) { res.setHeader('Retry-After', '600'); return res.status(429).json({ error: 'Too many requests. Please try again later or email us directly.' }); } rateStore.set(rateKey, { start: entry.start, count: entry.count + 1 }); - const text = [data.shape.heading, '', 'Company: ' + data.company, 'Reply email: ' + data.email, data.agents && 'Agents and actions: ' + data.agents, data.message && 'What they need: ' + data.message].filter(Boolean).join('\n'); + const text = [data.shape.heading, '', data.company && 'Company: ' + data.company, 'Reply email: ' + data.email, data.agents && 'Agents and actions: ' + data.agents, data.message && 'What they need: ' + data.message].filter(Boolean).join('\n'); try { const response = await fetcher('https://api.resend.com/emails', { method: 'POST', headers: { Authorization: 'Bearer ' + env.RESEND_API_KEY, 'Content-Type': 'application/json', 'Idempotency-Key': data.intent + '/' + data.requestId }, - body: JSON.stringify({ from: 'CTRLRun ', to: [RECIPIENT], reply_to: data.email, subject: data.shape.subject + ' — ' + data.company.replace(/[\r\n]/g, ' '), text }), + body: JSON.stringify({ from: 'CTRLRun ', to: [RECIPIENT], reply_to: data.email, subject: data.shape.subject + ' — ' + (data.company || data.email).replace(/[\r\n]/g, ' '), text }), signal: AbortSignal.timeout(10_000) }); if (!response.ok) return res.status(502).json({ error: 'We could not confirm submission. Retry this request or email contact@arpanghoshal.com.' }); diff --git a/website-form/interest.test.mjs b/website-form/interest.test.mjs index 121ffb7..11e6462 100644 --- a/website-form/interest.test.mjs +++ b/website-form/interest.test.mjs @@ -4,12 +4,13 @@ import { createHandler } from './api/interest.mjs'; const pro = { intent: 'pro-waitlist', email: 'engineer@example.com', company: 'Example', agents: 'Support agent', requestId: '11111111-1111-4111-8111-111111111111' }; const enterprise = { intent: 'enterprise-contact', email: 'cto@example.com', company: 'Example', message: 'Approvals in Slack, on-prem deployment.', requestId: '22222222-2222-4222-8222-222222222222' }; +const updates = { intent: 'launch-updates', email: 'reader@example.com', requestId: '33333333-3333-4333-8333-333333333333' }; const request = (body, headers = {}) => ({ method: 'POST', headers: { origin: 'https://ctrlrun.dev', 'content-type': 'application/json', 'x-vercel-forwarded-for': '192.0.2.1', ...headers }, body }); const response = () => ({ code: 200, headers: {}, body: null, setHeader(key, value) { this.headers[key] = value; }, status(code) { this.code = code; return this; }, json(body) { this.body = body; return this; }, end() { return this; } }); const handler = (overrides = {}) => createHandler({ env: { RESEND_API_KEY: 'test-only' }, rateStore: new Map(), fetcher: async () => ({ ok: true, json: async () => ({ id: 'test-email' }) }), ...overrides }); test('each intent sends to the fixed recipient with its own subject and idempotency key', async () => { - for (const [input, subject] of [[pro, 'CTRLRun Pro waiting list — Example'], [enterprise, 'CTRLRun Enterprise enquiry — Example']]) { + for (const [input, subject] of [[pro, 'CTRLRun Pro waiting list — Example'], [enterprise, 'CTRLRun Enterprise enquiry — Example'], [updates, 'CTRLRun launch updates — reader@example.com']]) { let sent; const fn = handler({ fetcher: async (url, options) => { sent = { url, ...options }; return { ok: true, json: async () => ({ id: 'test-email' }) }; } }); const res = response(); await fn(request({ ...input, to: 'attacker@example.com', from: 'spoof@example.com' }), res); @@ -88,3 +89,13 @@ test('preflight is allowed for site origins and refused for everything else', as assert.equal(res.code, origin.includes('unrelated') ? 403 : 204); } }); + +test('launch updates needs an email and nothing else, and the other intents still need a company', async () => { + let sent; + const fn = handler({ fetcher: async (url, options) => { sent = { url, ...options }; return { ok: true, json: async () => ({ id: 'test-email' }) }; } }); + const ok = response(); await fn(request(updates), ok); + assert.equal(ok.code, 200); + assert.doesNotMatch(JSON.parse(sent.body).text, /Company:/); + const missing = response(); await fn(request({ ...pro, company: undefined }), missing); + assert.equal(missing.code, 400); +}); diff --git a/www/404.html b/www/404.html new file mode 100644 index 0000000..584d35b --- /dev/null +++ b/www/404.html @@ -0,0 +1,23 @@ + + + + + +Page not found, CTRLRun + + + + + + + + +Skip to content +
+
+
+

This page does not exist.

The address may be wrong, or the page has moved to the documentation.

+
+
+ + diff --git a/www/README.md b/www/README.md new file mode 100644 index 0000000..0a7d4f5 --- /dev/null +++ b/www/README.md @@ -0,0 +1,7 @@ +# ctrlrun.dev + +The project site for the open-source kernel: one static page, a 404, the fonts, the recording. Nothing to buy on it. Deployed by Vercel with this folder as the project root; `vercel.json` carries the security headers the OpenSSF Best Practices hardened-site criterion probes for, and the permanent redirects that send `/docs/*` and the old Mintlify pages to docs.ctrlrun.dev. + +- `index.html` opens with the README's H1 and lede, verbatim; `tests/test_home_and_readme_agree.py` pins them here as well as in `index.mdx`. +- The "Something bigger is coming" form posts to the website form's `/api/interest` with the `launch-updates` intent: an email and nothing else. +- Copy is the README's. The seven-step diagram is `snippets/how-diagram.jsx`, inlined; regenerate it with `scripts/render-how-diagram.py` and re-run `scripts/build-www.py` (to be added) or paste. diff --git a/www/fonts/GeistMono-400.woff2 b/www/fonts/GeistMono-400.woff2 new file mode 100644 index 0000000..1ee228f Binary files /dev/null and b/www/fonts/GeistMono-400.woff2 differ diff --git a/www/fonts/Satoshi-400.woff2 b/www/fonts/Satoshi-400.woff2 new file mode 100644 index 0000000..81c40ab Binary files /dev/null and b/www/fonts/Satoshi-400.woff2 differ diff --git a/www/fonts/Satoshi-500.woff2 b/www/fonts/Satoshi-500.woff2 new file mode 100644 index 0000000..ffd0ac9 Binary files /dev/null and b/www/fonts/Satoshi-500.woff2 differ diff --git a/www/fonts/Satoshi-700.woff2 b/www/fonts/Satoshi-700.woff2 new file mode 100644 index 0000000..0a8db7a Binary files /dev/null and b/www/fonts/Satoshi-700.woff2 differ diff --git a/www/images/demo.gif b/www/images/demo.gif new file mode 100644 index 0000000..f3e8f9b Binary files /dev/null and b/www/images/demo.gif differ diff --git a/www/images/favicon.svg b/www/images/favicon.svg new file mode 100644 index 0000000..7dedf1a --- /dev/null +++ b/www/images/favicon.svg @@ -0,0 +1 @@ + diff --git a/www/images/og-home.png b/www/images/og-home.png new file mode 100644 index 0000000..2f53480 Binary files /dev/null and b/www/images/og-home.png differ diff --git a/www/images/wordmark-dark.svg b/www/images/wordmark-dark.svg new file mode 100644 index 0000000..fd0b506 --- /dev/null +++ b/www/images/wordmark-dark.svg @@ -0,0 +1,6 @@ + + + + + CTRLRun + diff --git a/www/images/wordmark-light.svg b/www/images/wordmark-light.svg new file mode 100644 index 0000000..5cd37c3 --- /dev/null +++ b/www/images/wordmark-light.svg @@ -0,0 +1,6 @@ + + + + + CTRLRun + diff --git a/www/index.html b/www/index.html new file mode 100644 index 0000000..74593bd --- /dev/null +++ b/www/index.html @@ -0,0 +1,371 @@ + + + + + +CTRLRun: stop wrong, restricted, or malicious agent actions + + + + + + + + + + + + + + + + + +Skip to content +
+
+
+ + + + + + + +
+
+

CTRLRun stops AI agents from taking wrong, restricted, or malicious actions in your workflows.

+

Every action is checked against your rules before it runs. Allowed actions go through. Sensitive ones wait for a person. Forbidden ones are blocked.

+

Execution safety for AI agents. 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.

+ +
pip install ctrlrun && ctrlrun demo
+
+
+
+ A terminal recording of the three steps below. A policy file gives refunds up to €500 to the agent, sends refunds up to €10,000 to a human, and denies the rest. The agent refunds €500 on its own; a €5,000 refund stops and names the request a human answers. ctrlrun approve grants it against the hash of that exact action. The agent runs the €5,000 refund, then tries €9,000 on the same approval, and that is refused: calls that reached the provider, 1, the €9,000 never left. +
+
+
+ +
+
+
+

Something bigger is coming.

+

CTRLRun is the kernel. What we are building on it is almost ready. Leave your email and you will hear first, once, when it lands.

+
+
+
+ +
+ + +
+ +
+ + +
+
+
+ +
+
+

What it does

+

The model guesses. CTRLRun does not. The ticket says refund €500. The agent asks for €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.

+
+
Without CTRLRun

Nothing checks the amount. The call goes through. €4,500 too much.

+
With CTRLRun

Your rule checks the amount. The call never leaves. €0 wrongly paid.

+
+

CTRLRun is that check. It reads the arguments about to leave your process and answers what may happen to them: let it run, ask a human, or stop it cold. Four rules do the work, and each one is a test in this repository before it is a sentence here.

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

Use it in three steps

+

The animation above is this section, recorded against the real library: one policy file, two short programs, four commands, nothing staged.

+ +

1. Install it.

+ +
pip install ctrlrun
+ +

2. Write down what the agent may do.

+

One file, ctrlrun.yaml. Amounts are integer minor units, so 50000 is €500. Both ends of every band are bound, because an upper bound alone lets a negative amount through, and a refund of a negative amount is a charge. Anything not listed is denied; there is no default-allow.

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

3. Wrap the call that has the consequence.

+

The decorator names the action, the effect key names the consequence it has in the world, and the context names who is acting. stripe here is a stand-in that records calls instead of making them.

+ +

+
+
+@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="support-agent"):
+    refund(payment_id="txn_1", amount=50_000)      # €500   -> succeeded
+    refund(payment_id="txn_2", amount=500_000)     # €5,000 -> ApprovalRequired, a human decides
+ +

The €500 refund runs on its own. The €5,000 one stops and names the request a human answers.

+

A human answers from the shell. The grant names the hash of the exact action the human read, and when it lapses.

+ +
$ ctrlrun approve "$(cat request_id.txt)"
+granted apr_63e80076f2cccfee52b17491a4b2e125 for sha256:22ec1c39…6af752
+expires 2026-09-13T20:10:11.367Z
+ +

The agent presents it, then tries to spend it on something else. The first call is exactly what the human approved, and it runs. The second is the same approval with one digit changed, and it matches nothing.

+ +
€5,000 with the approval -> succeeded
+€9,000 on that same approval -> refused
+calls that reached the provider: 1 (the €9,000 never left)
+ +

Every attempt, refusals included, left a receipt, and ctrlrun receipts lists them. That is the whole integration: a policy file, a decorator, a context, and with_approval to present a grant. Money is the example, not the scope: the same policy language reads role_in or replicas_lte as easily as amount_lte.

+ +
+
+ +
+
+

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 that reaches its tools over MCP, in any language.

+ + + + + + + +
You haveUseNeeds
Python in this processthe @protect decorator, shown abovenothing beyond pip install ctrlrun
Tools behind an MCP server, in any languagethe gateway: pip install "ctrlrun[gateway]"one command, no change to agent or server code
A framework with its own approval interruptan adapterthe framework to have a human-in-the-loop primitive
+

It works with agents you can and can't modify. WhatsApp, Slack and Teams bots, ChatGPT, Cursor, Codex, OpenAI Agents: any AI agent you have. CTRLRun checks the action, not the agent, so if the agent acts through a tool server or an API you run, the action is checked, and the agent is not rebuilt, redeployed or told. Agents you can't modify says where the boundary goes for each kind.

+
+
+ +
+
+

How it works

+

Every protected call, whichever way it arrives, goes through the same seven steps. Only then does it reach your systems.

+
+
+ + + How CTRLRun works + An action leaves the agents, tools and workflows you already run. Inside CTRLRun it is normalized into one action, decided against your rules, held for approval, reserved so it cannot run twice, executed, resolved and recorded. Only then does it reach your systems. An action with no rule is blocked, changed arguments void the approval, and an unknown outcome is never retried. + + Your agents, tools and workflows + An action is about to run. + Any model, any framework. Existing code stays as it is. + + + + CTRLRun, the execution boundary + + 1 + Normalize + one action, one id + + + + 2 + Decide + allow / ask / block + + + + 3 + Approve + bound to this action + + + + 4 + Reserve + claimed once + + + + 5 + Execute + your code runs + + + + 6 + Resolve + unknown is not failed + + + + 7 + Record + a receipt either way + + No rule for it + The action is blocked. Silence is never permission. + Arguments changed after sign-off + The old approval is void. A person signs again. + Outcome unknown + No retry until a person resolves it. Nothing runs twice on a guess. + + + + Your systems + The action arrives already checked. + Allowed by your rules, approved where you require it, and never run twice. + + + How CTRLRun works + An action leaves the agents, tools and workflows you already run. Inside CTRLRun it is normalized into one action, decided against your rules, held for approval, reserved so it cannot run twice, executed, resolved and recorded. Only then does it reach your systems. An action with no rule is blocked, changed arguments void the approval, and an unknown outcome is never retried. + + Your agents, tools and workflows + An action is about to run. + Any model, any framework. Existing code stays as it is. + + + + CTRLRun, the execution boundary + + + 1 + Normalize + one action, one id + + 2 + Decide + allow / ask / block + + 3 + Approve + bound to this action + + 4 + Reserve + claimed once + + 5 + Execute + your code runs + + 6 + Resolve + unknown is not failed + + 7 + Record + a receipt either way + + No rule for it + The action is blocked. + Silence is never permission. + Arguments changed after sign-off + The old approval is void. + A person signs again. + Outcome unknown + No retry until a person resolves it. + Nothing runs twice on a guess. + + + + Your systems + The action arrives already checked. + Allowed by your rules, approved where you require it. + + +
+ +
+ +
+
+

Prove it in your own setup

+

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: 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. It reaches no network.

+ +
$ ctrlrun verify
+CTRLRun verify — ctrlrun 0.9.0, catalogue ctrlrun.guarantees/v6
+policy     ctrlrun.yaml (ctrlrun.policy/v2, mode: enforce)
+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
+G10  unknown exception is ambiguous   PASS  stripe.refund
+G11  an altered receipt is detected   PASS  stripe.refund
+…    24 guarantees graded in the full run
+ + +
+
+ +
+
+

Documentation

+

docs.ctrlrun.dev is the documentation: concepts, guides, a cookbook, the full reference, and a browser demo that runs with no install.

+
+ +
The ideas, and doing something with them
Concepts Guides Cookbook
+ + +
Every key, flag and error
Reference FAQ
+ + +
+
+
+ +
+
+

Built in the open

+

Issues and pull requests are welcome: CONTRIBUTING.md and CODE_OF_CONDUCT.md have the working agreement, and SECURITY.md is how to report a vulnerability. Every claim on this page has a test behind it, so a change to the prose usually means a change to the suite. CHANGELOG.md and the roadmap say where it is going. Releases carry PyPI provenance attestations from GitHub Actions.

+

+ PyPI version + CI status + OpenSSF Scorecard + OpenSSF Best Practices + Apache-2.0 license +

+
+
+ + + + +
+
+ + + diff --git a/www/robots.txt b/www/robots.txt new file mode 100644 index 0000000..e1987c6 --- /dev/null +++ b/www/robots.txt @@ -0,0 +1,3 @@ +User-agent: * +Allow: / +Sitemap: https://ctrlrun.dev/sitemap.xml diff --git a/www/site.js b/www/site.js new file mode 100644 index 0000000..3f7783b --- /dev/null +++ b/www/site.js @@ -0,0 +1,31 @@ +(function () { + var form = document.getElementById('ct-notify'); + if (!form) return; + var ENDPOINT = 'https://ctrlrun-review-form.vercel.app/api/interest'; + var button = document.getElementById('ct-notify-button'); + var errorBox = document.getElementById('ct-notify-error'); + var done = document.getElementById('ct-notify-done'); + var fields = form.querySelector('fieldset'); + var requestId = null, sending = false, sent = false; + function uuid() { return (crypto.randomUUID ? crypto.randomUUID() : 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { var r = Math.random() * 16 | 0; return (c === 'x' ? r : (r & 3 | 8)).toString(16); })); } + function fail(message) { errorBox.textContent = message; errorBox.hidden = false; button.textContent = 'Retry'; } + form.addEventListener('submit', function (event) { + event.preventDefault(); + if (sending || sent) return; + var email = form.elements.email.value.trim(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { fail('Enter a valid email address.'); form.elements.email.focus(); return; } + if (!requestId) requestId = uuid(); + sending = true; fields.disabled = true; errorBox.hidden = true; button.textContent = 'Sending…'; + var controller = new AbortController(); var timer = setTimeout(function () { controller.abort(); }, 15000); + fetch(ENDPOINT, { method: 'POST', credentials: 'omit', headers: { 'Content-Type': 'application/json' }, signal: controller.signal, + body: JSON.stringify({ intent: 'launch-updates', email: email, website: form.elements.website.value, requestId: requestId }) }) + .then(function (response) { return response.json().then(function (data) { return { ok: response.ok, data: data }; }); }) + .then(function (result) { + if (!result.ok || result.data.ok !== true || typeof result.data.id !== 'string') throw new Error(result.data.error || 'We could not confirm this. Retry, or email contact@arpanghoshal.com.'); + sent = true; form.querySelector('fieldset').hidden = true; done.hidden = false; + if (window.dispatchEvent) window.dispatchEvent(new CustomEvent('ctrlrun:conversion', { detail: { name: 'launch_updates_submitted' } })); + }) + .catch(function (failure) { fail(failure.name === 'AbortError' || failure.name === 'TypeError' ? 'We could not confirm this. Retrying is safe, or email contact@arpanghoshal.com.' : failure.message); }) + .then(function () { clearTimeout(timer); sending = false; fields.disabled = false; }); + }); +})(); diff --git a/www/sitemap.xml b/www/sitemap.xml new file mode 100644 index 0000000..c438338 --- /dev/null +++ b/www/sitemap.xml @@ -0,0 +1,2 @@ + +https://ctrlrun.dev/ diff --git a/www/styles.css b/www/styles.css new file mode 100644 index 0000000..f622a3e --- /dev/null +++ b/www/styles.css @@ -0,0 +1,173 @@ +/* --------------------------------------------------------------------------------------------- + ct-home: the ctrlrun.dev project page, rebuilt 2026-09-15 from internal/design/DESIGNS.md. + New namespace on purpose: the cr-* rules above still style the other custom pages until they + are redone. Tokens, type scale, spacing, radius and motion are the design file's. Monochrome. + --------------------------------------------------------------------------------------------- */ +@font-face { font-family: "Satoshi"; font-weight: 400; font-style: normal; font-display: swap; src: url("/fonts/Satoshi-400.woff2") format("woff2"); } +@font-face { font-family: "Satoshi"; font-weight: 500; font-style: normal; font-display: swap; src: url("/fonts/Satoshi-500.woff2") format("woff2"); } +@font-face { font-family: "Satoshi"; font-weight: 700; font-style: normal; font-display: swap; src: url("/fonts/Satoshi-700.woff2") format("woff2"); } +@font-face { font-family: "Geist Mono"; font-weight: 400; font-style: normal; font-display: swap; src: url("/fonts/GeistMono-400.woff2") format("woff2"); } + +html{color-scheme:light dark;background:#F8F8F7} +.dark html, html.dark{background:#111214} +body{margin:0} +.ct-home { + --ct-bg:#F8F8F7; --ct-surface:#FFFFFF; --ct-surface-2:#F1F1EF; --ct-border:#E4E4E0; --ct-border-strong:#C9C9C3; + --ct-text:#14161A; --ct-text-2:#5C6068; --ct-text-3:#8B8F96; + --ct-accent:#B8730A; --ct-accent-hover:#8A5606; --ct-accent-soft:#FBF1DE; --ct-on-accent:#FFFFFF; --ct-button:#14161A; --ct-button-hover:#2A2D33; --ct-on-button:#FFFFFF; + --ct-danger:#D63A3A; --ct-success:#1F8A4C; + --ct-sans:"Satoshi",-apple-system,"Segoe UI",sans-serif; --ct-mono:"Geist Mono",ui-monospace,SFMono-Regular,Menlo,monospace; + --ct-ease-in:cubic-bezier(0.2,0,0,1); --ct-ease-out:cubic-bezier(0.4,0,1,1); + background:var(--ct-bg); color:var(--ct-text); font-family:var(--ct-sans); font-size:15px; line-height:22px; + -webkit-font-smoothing:antialiased; margin:0; padding:0; width:auto; overflow-x:clip; +} +.dark .ct-home { + --ct-bg:#111214; --ct-surface:#18191C; --ct-surface-2:#1F2024; --ct-border:#2A2B30; --ct-border-strong:#3A3B42; + --ct-text:#ECECEA; --ct-text-2:#A2A5AD; --ct-text-3:#6F737B; + --ct-accent:#F5A623; --ct-accent-hover:#FFC163; --ct-accent-soft:#3B3020; --ct-on-accent:#14161A; --ct-button:#ECECEA; --ct-button-hover:#FFFFFF; --ct-on-button:#14161A; +} +.ct-home *, .ct-home *::before, .ct-home *::after { box-sizing:border-box; } +.ct-wrap { max-width:1120px; margin:0 auto; padding:0 24px; } +@media (min-width:900px) { .ct-wrap { padding:0 48px; } } +.ct-home h1, .ct-home h2, .ct-home h3 { font-family:var(--ct-sans); font-weight:700; color:var(--ct-text); margin:0; text-wrap:balance; } +.ct-home h1 { font-size:36px; line-height:40px; letter-spacing:-0.02em; } +@media (min-width:720px) { .ct-home h1 { font-size:52px; line-height:54px; } } +.ct-home h2 { font-size:26px; line-height:32px; letter-spacing:-0.01em; } +@media (min-width:720px) { .ct-home h2 { font-size:36px; line-height:40px; letter-spacing:-0.02em; } } +.ct-home h3 { font-size:20px; line-height:28px; margin-top:32px; } +.ct-home p { margin:0; color:var(--ct-text-2); max-width:64ch; } +.ct-home p strong { color:var(--ct-text); font-weight:700; } +.ct-home a { color:var(--ct-text); text-decoration:underline; text-underline-offset:3px; text-decoration-thickness:1px; text-decoration-color:var(--ct-accent); } +.ct-home a:hover { color:var(--ct-accent-hover); text-decoration-color:var(--ct-accent-hover); } +.ct-home :is(a,button,input):focus-visible { outline:2px solid var(--ct-accent); outline-offset:2px; border-radius:6px; } +.ct-home code { font-family:var(--ct-mono); font-size:13px; background:var(--ct-surface-2); border:1px solid var(--ct-border); border-radius:6px; padding:1px 5px; color:var(--ct-text); } +.ct-home pre, .ct-home pre code { font-family:var(--ct-mono); font-size:13px; line-height:20px; } +.ct-home pre { background:var(--ct-surface); border:1px solid var(--ct-border); border-radius:10px; padding:16px 20px; overflow-x:auto; margin:16px 0 0; } +.ct-home pre code { background:transparent; border:0; padding:0; } +.ct-home em { font-style:italic; } + +/* hero */ +.ct-hero { padding:64px 0 0; } +@media (min-width:900px) { .ct-hero { padding-top:96px; } } +.ct-hero-inner { text-align:center; display:flex; flex-direction:column; align-items:center; gap:0; } +.ct-hero-inner h1 { max-width:22ch; } +.ct-hero-inner .cr-lede { font-size:17px; line-height:24px; max-width:56ch; margin-top:20px; color:var(--ct-text-2); } +.ct-sub { max-width:62ch; margin-top:16px !important; } +.ct-meta { font-size:13px; line-height:18px; color:var(--ct-text-3) !important; margin-top:12px !important; } +.ct-actions { display:flex; flex-wrap:wrap; justify-content:center; align-items:center; gap:20px; margin-top:28px; } +.ct-button { display:inline-flex; align-items:center; justify-content:center; height:36px; padding:0 14px; border-radius:6px; background:var(--ct-button); color:var(--ct-on-button) !important; font-weight:500; font-size:15px; text-decoration:none !important; border:1px solid transparent; cursor:pointer; white-space:nowrap; transition:background-color 150ms var(--ct-ease-in), transform 150ms var(--ct-ease-in); } +.ct-button:hover { background:var(--ct-button-hover); } +.ct-button:active { transform:translateY(1px); } +.ct-button:disabled, .ct-home fieldset:disabled .ct-button { opacity:.6; cursor:default; } +.ct-link { font-weight:500; } +.ct-install { margin-top:32px; } +.ct-install code { font-size:14px; padding:10px 16px; border-radius:6px; background:var(--ct-surface); } +.ct-recording { margin:48px 0 0; padding:0; } +.ct-recording img { display:block; width:100%; height:auto; max-width:960px; margin:0 auto; border:1px solid var(--ct-border); border-radius:10px; background:var(--ct-surface); } +@media (min-width:900px) { .ct-recording { margin-top:64px; } } +@keyframes ct-rise { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:none; } } +.ct-hero-inner > * { animation:ct-rise 250ms var(--ct-ease-in) both; } +.ct-hero-inner > *:nth-child(2) { animation-delay:40ms; } .ct-hero-inner > *:nth-child(3) { animation-delay:80ms; } .ct-hero-inner > *:nth-child(n+4) { animation-delay:120ms; } +@media (prefers-reduced-motion:reduce) { .ct-hero-inner > * { animation:none; } .ct-button { transition:none; } } + +/* the notify band */ +.ct-notify { padding:64px 0 0; } +@media (min-width:900px) { .ct-notify { padding-top:96px; } } +.ct-notify-inner { background:var(--ct-surface); border:1px solid var(--ct-border); border-radius:10px; padding:32px 24px; display:grid; gap:24px; } +@media (min-width:900px) { .ct-notify-inner { grid-template-columns:1fr 1fr; gap:48px; padding:40px 48px; align-items:center; } } +.ct-notify-copy h2 { font-size:26px; line-height:32px; letter-spacing:-0.01em; } +.ct-notify-copy p { margin-top:12px; max-width:44ch; } +.ct-notify-form { display:block; } +.ct-notify-fields { border:0; margin:0; padding:0; min-width:0; } +.ct-notify-label { display:block; font-size:13px; line-height:18px; color:var(--ct-text-2); margin-bottom:6px; } +.ct-notify-row { display:flex; gap:8px; flex-wrap:wrap; } +.ct-input { flex:1 1 220px; height:36px; padding:0 12px; border:1px solid var(--ct-border-strong); border-radius:6px; background:var(--ct-surface); color:var(--ct-text); font:inherit; font-size:15px; min-width:0; } +.ct-input::placeholder { color:var(--ct-text-3); } +.ct-input:focus { outline:2px solid var(--ct-accent); outline-offset:2px; border-color:var(--ct-border-strong); } +.ct-home fieldset:disabled .ct-input { opacity:.7; } +.ct-honeypot { position:absolute; left:-10000px; width:1px; height:1px; overflow:hidden; } +.ct-notify-error { margin-top:8px !important; font-size:13px; line-height:18px; color:var(--ct-danger) !important; } +.ct-notify-done { font-size:15px; color:var(--ct-text) !important; } + +/* sections */ +.ct-section { padding:64px 0 0; } +@media (min-width:900px) { .ct-section { padding-top:96px; } } +.ct-prose > p, .ct-prose > dl, .ct-prose > .ct-compare, .ct-prose > table { margin-top:16px; } +.ct-prose > h2 + p { margin-top:16px; } +.ct-lead { font-size:17px; line-height:24px; max-width:66ch; } +.ct-compare { display:grid; border:1px solid var(--ct-border); border-radius:10px; overflow:hidden; background:var(--ct-surface); max-width:880px; } +@media (min-width:720px) { .ct-compare { grid-template-columns:1fr 1fr; } } +.ct-compare > div { padding:16px 20px; } +.ct-compare > div + div { border-top:1px solid var(--ct-border); } +@media (min-width:720px) { .ct-compare > div + div { border-top:0; border-left:1px solid var(--ct-border); } } +.ct-compare-label { display:block; font-size:13px; line-height:18px; color:var(--ct-text-3); margin-bottom:6px; } +.ct-compare p { font-size:14px; line-height:20px; } +.ct-rules { display:grid; gap:12px 32px; margin:24px 0 0; max-width:880px; } +@media (min-width:720px) { .ct-rules { grid-template-columns:1fr 1fr; } } +.ct-rules div { padding:12px 0; border-top:1px solid var(--ct-border); } +.ct-rules dt { font-weight:700; color:var(--ct-text); } +.ct-rules dd { margin:2px 0 0; font-size:14px; line-height:20px; color:var(--ct-text-2); } +.ct-links { display:flex; flex-wrap:wrap; gap:8px 24px; margin-top:24px !important; } +.ct-table { width:100%; border-collapse:collapse; font-size:14px; line-height:20px; max-width:880px; } +.ct-table th { text-align:left; font-weight:500; color:var(--ct-text-3); font-size:13px; padding:8px 12px 8px 0; border-bottom:1px solid var(--ct-border); } +.ct-table td { padding:10px 12px 10px 0; border-bottom:1px solid var(--ct-border); vertical-align:top; color:var(--ct-text-2); } +.ct-table td:first-child { color:var(--ct-text); } +.ct-diagram { margin-top:32px; } +.ct-diagram svg { width:100%; height:auto; display:block; } +.ct-doclist { margin:24px 0 0; display:grid; gap:0; max-width:880px; } +.ct-doclist div { display:grid; gap:4px; padding:12px 0; border-top:1px solid var(--ct-border); } +@media (min-width:720px) { .ct-doclist div { grid-template-columns:260px 1fr; gap:24px; } } +.ct-doclist dt { color:var(--ct-text); font-weight:500; } +.ct-doclist dd { margin:0; display:flex; flex-wrap:wrap; gap:6px 20px; } +.ct-badges { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-top:24px !important; } +.ct-badges img { height:20px; width:auto; display:block; } +.ct-open { padding-bottom:64px; } +.ct-footer { border-top:1px solid var(--ct-border); padding:40px 0 48px; margin-top:32px; } +.ct-footer-inner { display:flex; flex-direction:column; gap:16px; } +@media (min-width:720px) { .ct-footer-inner { flex-direction:row; justify-content:space-between; align-items:baseline; } } +.ct-footer p { color:var(--ct-text-2); } +.ct-footer nav { display:flex; gap:24px; flex-wrap:wrap; } + + +/* one diagram at a time: the snippet ships a wide and a narrow drawing */ +.ct-home .cr-dia-narrow { display:none; } +@media (max-width:719px) { .ct-home .cr-dia-wide { display:none; } .ct-home .cr-dia-narrow { display:block; } } +/* the diagram, restyled monochrome inside this page */ +.ct-home .cr-diagram text { font-family:var(--ct-sans); } +.ct-home .cr-dia-label, .ct-home .cr-dia-num { fill:var(--ct-accent); font-family:var(--ct-sans); font-size:13px; font-weight:500; letter-spacing:0; } +.ct-home .cr-dia-name { fill:var(--ct-text); font-weight:700; } +.ct-home .cr-dia-gloss, .ct-home .cr-dia-note { fill:var(--ct-text-2); } +.ct-home .cr-dia-claim { fill:var(--ct-text); font-weight:500; } +.ct-home .cr-dia-strip, .ct-home .cr-dia-box { fill:var(--ct-surface); stroke:var(--ct-border); } +.ct-home .cr-dia-node { fill:var(--ct-surface-2); stroke:var(--ct-border); } +.ct-home .cr-dia-arrow { stroke:var(--ct-border-strong); } .ct-home .cr-dia-head { fill:var(--ct-border-strong); } +.ct-home .cr-dia-tier-label { fill:var(--ct-accent); font-family:var(--ct-sans); letter-spacing:0; } + +/* system dark mode, and the explicit toggle */ +@media (prefers-color-scheme: dark) { html:not(.light) .ct-home { + --ct-bg:#111214; --ct-surface:#18191C; --ct-surface-2:#1F2024; --ct-border:#2A2B30; --ct-border-strong:#3A3B42; + --ct-text:#ECECEA; --ct-text-2:#A2A5AD; --ct-text-3:#6F737B; + --ct-accent:#F5A623; --ct-accent-hover:#FFC163; --ct-accent-soft:#3B3020; --ct-on-accent:#14161A; --ct-button:#ECECEA; --ct-button-hover:#FFFFFF; --ct-on-button:#14161A; } html:not(.light){background:#111214} } +/* top bar */ +.ct-topbar { position:sticky; top:0; z-index:5; background:color-mix(in srgb, var(--ct-bg) 88%, transparent); backdrop-filter:blur(8px); border-bottom:1px solid var(--ct-border); } +.ct-topbar-inner { height:56px; display:flex; align-items:center; justify-content:space-between; gap:16px; } +.ct-wordmark { font-weight:700; font-size:20px; letter-spacing:-0.03em; color:var(--ct-text); text-decoration:none !important; } +.ct-topnav { display:none; gap:24px; } +.ct-topnav a { text-decoration:none !important; font-weight:500; color:var(--ct-text); } +.ct-topnav a:hover { color:var(--ct-text-2); } +@media (min-width:720px) { .ct-topnav { display:flex; } } +.ct-topbar .ct-button-secondary { display:inline-flex; align-items:center; height:32px; padding:0 12px; border:1px solid var(--ct-border-strong); border-radius:6px; font-size:13px; font-weight:500; color:var(--ct-text); text-decoration:none !important; background:var(--ct-surface); } +.ct-topbar .ct-button-secondary:hover { background:var(--ct-surface-2); } +.ct-skip { position:absolute; left:-10000px; top:8px; background:var(--ct-surface); padding:8px 12px; border-radius:6px; } +.ct-skip:focus { left:8px; z-index:10; } +/* 404 */ +.ct-404 { padding:96px 0; } +.ct-404 h1 { font-size:36px; line-height:40px; } +.ct-404 p { margin-top:12px; } +.ct-404 .ct-links { margin-top:24px !important; } + +/* the project's own accent: amber and black, kept on purpose (2026-09-15) */ +.ct-home .cr-dot { color:var(--ct-accent); } +.ct-home .cr-mark { color:inherit; font-weight:inherit; } +.ct-wordmark img { height:28px; width:auto; display:block; } +.ct-input:focus { outline-color:var(--ct-accent); } diff --git a/www/vercel.json b/www/vercel.json new file mode 100644 index 0000000..c12e273 --- /dev/null +++ b/www/vercel.json @@ -0,0 +1,89 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "cleanUrls": true, + "trailingSlash": false, + "redirects": [ + { + "source": "/docs", + "destination": "https://docs.ctrlrun.dev/", + "permanent": true + }, + { + "source": "/docs/:path*", + "destination": "https://docs.ctrlrun.dev/:path*", + "permanent": true + }, + { + "source": "/execution-boundary", + "destination": "https://docs.ctrlrun.dev/execution-boundary", + "permanent": true + }, + { + "source": "/risk-check", + "destination": "https://docs.ctrlrun.dev/risk-check", + "permanent": true + }, + { + "source": "/protect-my-agent", + "destination": "https://docs.ctrlrun.dev/get-started/quickstart", + "permanent": true + } + ], + "headers": [ + { + "source": "/(.*)", + "headers": [ + { + "key": "Content-Security-Policy", + "value": "default-src 'self'; script-src 'self'; style-src 'self'; font-src 'self'; img-src 'self' data: https://img.shields.io https://github.com https://api.scorecard.dev https://www.bestpractices.dev; connect-src 'self' https://ctrlrun-review-form.vercel.app; frame-ancestors 'none'; base-uri 'self'; form-action 'self' https://ctrlrun-review-form.vercel.app; object-src 'none'; upgrade-insecure-requests" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=63072000; includeSubDomains; preload" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Referrer-Policy", + "value": "strict-origin-when-cross-origin" + }, + { + "key": "Permissions-Policy", + "value": "camera=(), microphone=(), geolocation=(), payment=(), usb=()" + }, + { + "key": "Cross-Origin-Opener-Policy", + "value": "same-origin" + }, + { + "key": "Cross-Origin-Resource-Policy", + "value": "same-origin" + } + ] + }, + { + "source": "/fonts/(.*)", + "headers": [ + { + "key": "Cache-Control", + "value": "public, max-age=31536000, immutable" + } + ] + }, + { + "source": "/images/(.*)", + "headers": [ + { + "key": "Cache-Control", + "value": "public, max-age=604800" + } + ] + } + ] +}