关于 CTRLRun Operator
CTRLRun Operator 是通过 MCP 客户端处理 CTRLRun 待审批操作的服务,供需要人工审查 Agent 操作的团队使用。它可列出待处理项、查看详情并批准或拒绝。
主要功能
- 01列出待审批操作
- 02查看操作详情
- 03批准或拒绝请求
适用场景
- 01查看 Agent 暂停等待的操作
- 02审查操作内容后授予批准
- 03拒绝不允许执行的请求
MCP 相关
通过 MCP 客户端提供 CTRLRun 审批操作,包括列出待处理项、检查请求以及批准或拒绝。
配置
本地服务的配置在各客户端中通用,复制后粘贴到客户端的 MCP 配置文件即可。
工具
本地服务需要在你的电脑上运行后才能读取工具,维护者暂未提供工具列表。
详细介绍
ctrlrun 阻止 AI Agent 在你的工作流中采取错误、受限或恶意的操作。
每个操作在执行前都会根据你的规则进行检查。允许的操作直接通过。
敏感操作等待人工确认。禁止的操作会被拦截。
为 AI Agent 提供执行安全保障。一个 Python 库,位于做出行动决策与执行调用之间。
一个重大操作最多执行一次,完全按照批准的方式进行,并留下回执。
当结果未知时,ctrlrun 会明确说明,而不是猜测。
可在生产环境中运行于单个文件,或跨主机运行于 Postgres。Apache-2.0。
pip install ctrlrun && ctrlrun demo
它做什么
模型靠猜。ctrlrun 不猜。 工单上写的是退款 €500。Agent 却申请 €5,000,多了一个零。工具就在它的清单里,参数格式也无懈可击,而模型对此完全自信。调用之上的任何环节都不会提出异议,因为调用之上的任何环节都不是检查:一个工具可被调用,并不等于允许用这些参数调用它。
| 没有 ctrlrun | 有 ctrlrun |
|---|---|
| 没有任何环节检查金额。调用照常执行。多付了 €4,500。 | 你的规则检查金额。调用根本不会发出。错误支付 €0。 |
ctrlrun 就是那道检查。它读取即将离开你进程的参数,并回答这些参数可能带来什么后果:放行、交给人来确认,还是直接拦下。四条规则承担了这项工作,而每一条都先是本仓库中的一个测试,然后才是这里的一句话。
| 精确就是精确 | 参数一旦改变,就需要新的审批。 |
| 一次就只算一次 | 相同的效果键,共享存储,绝不重复。 |
| 未知就是等待 | 在重试之前先确认结果。 |
| 每个答案都被保留 | 请求、决策和结果,包括拒绝。 |
第三条是人们最容易忘记的那一半。一笔正确的 €500 退款在服务商处已提交成功,但返回的应答丢失了,于是 Agent 重试。重试库、Agent 框架和工具循环会把这次失败了和我不知道发生了什么混为一谈。ctrlrun 将两者区分开:丢失的应答是 AMBIGUOUS,绝不是 FAILED,而对一个 AMBIGUOUS 效果的重试会被拒绝,直到有人类或 reconcile 钩子说明发生了什么。
ctrlrun demo 展示的内容:五次失败和五次拒绝,逐字节呈现
$ 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
2. Approval mutation
agent proposes refund €2,000 → human approves apr_0aa78e0380ba55d77a601dc782f57095 (bound to the action hash)
agent executes refund €5,000 →
✗ BLOCKED — approved action ≠ requested action (mismatch)
3. Concurrent agents, same effect
Agent A reserve refund:txn_123 → ACQUIRED → executes
Agent B reserve refund:txn_123 →
✗ BLOCKED — already reserved (in_progress)
4. Approval replay
approval apr_dbc8bc6f06690cdf2e2c55a4e591ef3b used once → consumed
same approval presented again →
✗ BLOCKED — single-use approval already consumed
5. Authority escalation
human €100,000 delegable → finance agent €25,000 → support agent €2,000
support agent's grant: dlg_5f8d41938a3f29972d5489d676cd9edb
support agent requests €50,000 →
✗ BLOCKED — outside the delegated grant (authority_constraint)
remote refund calls: 0
finance agent tries to delegate €50,000 under its own €25,000 → refused (containment: constraints)
support agent requests €1,500 → authority permits it, and the policy asks a human (apr_f86eca24dd80206ab5189ccb1b62aa55)
two axes, and an action needs both: the stricter of the pair wins
Receipts (8): .ctrlrun/demo/receipts.jsonl
Events: .ctrlrun/demo/events.jsonl
Read them: CTRLRUN_STATE=.ctrlrun/demo/state.db ctrlrun receipts
审批和委托 id 每次运行都会生成;除此之外的一切都与 demo 打印的完全一致,如果两者出现偏差,测试就会失败。无网络,无外部服务,一秒以内。pip install ctrlrun && ctrlrun demo 在本地运行它,耗时大致相同。
它止步于何处。 它不检测提示词注入:它约束的是后果,而不是去读取原因。对于它无法控制的远程服务,它无法承诺精确一次,它拒绝在知情的情况下执行两次,而且它不会回滚任何东西。回执是链式连接的,因此篡改会被检测到;但末尾的截断和伪造的追加则不会,因为能捕捉它们的链头本身就是同一数据库中的一行,而解决这个问题正是 ctrlrun anchor 的用途。回执没有签名:篡改不等于署名。上方的徽章意味着在它所运行的配置中,声明的保证均通过,并不意味着安全、可靠、合规、已认证或已审计:
徽章的含义
· OWASP-AGENTIC-TOP10.md 列出了它未涉及的四个条目。
如果 Agent 只是读取并回答,你不需要 ctrlrun。一旦它能发送、支付、退款、删除、部署、授权、撤销、批准、提交、购买或取消,你就需要它。
三步即可使用
上方的动画就是本节内容,录制自真实的库:一个策略文件,两个 短程序,四条命令,没有任何预先安排。
1. 安装它。
pip install ctrlrun
2. 写下 Agent 可以做什么。 一个文件,ctrlrun.yaml。金额为整数最小
单位,因此 50000 就是 €500。每个区间的两端都必填,因为仅有上限会让
负数金额通过,而对负数金额的退款就是一筆扣款。任何未列出的内容都
会被拒绝;不存在默认允许。
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. 包装那个会产生后果的调用。 装饰器指明动作,effect key
指明它在现实世界中产生的后果,context 指明是谁在行动。这里的 stripe
是一个替身,它记录调用而不实际发起调用。
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))
€500 的退款自行运行。€5,000 的那笔会停下,并指明需要人类答复的请求:
€500 -> succeeded
€5,000 -> a human decides: apr_63e80076f2cccfee52b17491a4b2e125
calls that reached the provider: 1
人类从 shell 中答复。 授权指明人类所阅读的确切动作的哈希,以及 它何时失效。Id、哈希和日期每次运行都会生成;你得到的会有所不同。
ctrlrun approve "$(cat request_id.txt)"
granted apr_63e80076f2cccfee52b17491a4b2e125 for sha256:22ec1c398e4b93d080b6cba61e5e11b0e21879552ac5dbf63c192d2b2e6af752
expires 2026-09-13T20:10:11.367Z
Agent 出示它,然后试图把它花在别的东西上。 第一次调用正是 人类批准的内容,它会运行。第二次是同一个授权,只改动了一位数字, 它不匹配任何东西:
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)")
€5,000 with the approval -> succeeded
€9,000 on that same approval -> refused
calls that reached the provider: 1 (the €9,000 never left)
每一次尝试,包括被拒绝的,都留下了回执,ctrlrun receipts 会列出它们。这就是
整个集成:一个策略文件、一个装饰器、一个 context,以及用 with_approval 出示授权。
金额只是示例,并非范围。条件的形式是 <argument>_<op>,因此同一套策略
语言可以像读取 amount_lte 一样轻松地读取 role_in: [reader, viewer] 或 replicas_lte: 10,
下面九个领域各有一个策略。
保护你的第一个动作走的是同一条路径,
并对每个输出都作了说明 ·
策略 YAML 参考 ·
Cookbook:退款、部署、IAM、删除、电子邮件、MCP。
三种使用方式
你大概率不需要适配器。 @protect 覆盖在此进程中运行的任何内容:原始模型调用、LangChain 工具、手写循环、定时任务。网关覆盖以任何语言通过 MCP 访问其工具的任何内容。
| 你的情况 | 使用方式 | 需要 |
|---|---|---|
| 此进程中的 Python | 上文展示的 @protect 装饰器 | 除 pip install ctrlrun 外无需其他 |
| 任意语言中位于 MCP 服务背后的工具 | 网关:pip install "ctrlrun[gateway]" | 一条命令,无需更改 Agent 或服务代码 |
| 自带审批中断的框架 | 适配器 | 框架需具备人在回路原语 |
它适用于你能修改和不能修改的 Agent。 WhatsApp、Slack 和 Teams 机器人、ChatGPT、Cursor、Codex、OpenAI Agents:任何你拥有的 AI Agent。ctrlrun 检查的是动作,而不是 Agent,因此如果 Agent 通过你运行的工具服务或 API 执行动作,该动作就会被检查,而 Agent 无需重建、重新部署或被通知。你无法修改的 Agent 说明了每种类型的边界所在。
适配器存在的唯一原因:将 approve 决策路由到框架自己的中断中,让人在他们已经应答的地方应答。绝不会有第二个表示同意的地方。ctrlrun-langgraph 提供预防,因为恢复过程会携带参数,核心会对照哈希重新检查这些参数。ctrlrun-openai-agents 提供归因,因为该 SDK 记录的是某次调用被批准,而不是其参数是什么。这三者都不只适用于 Agent:工作进程、Webhook 处理器和定时任务同样无法区分首次尝试和重试。
它是官方 LangChain 中间件集成。 ctrlrun-langchain 已列在 LangChain 的中间件集成中。它的 CTRLRunMiddleware 放入 create_agent(middleware=[...]),并通过 LangChain 自己的 wrap_tool_call 对每次工具调用进行管控,包括不是你编写的工具:被拒绝的调用永远不会到达工具,而模型会被告知是哪条规则拒绝了它。使用 LangChain 中间件提供了相关设置。
工作原理
每次受保护的调用,无论以何种方式到达,都会经过同样的七个步骤。只有之后,它才会到达你的系统。
normalize → decide → approve → reserve → execute → resolve → record
- 规范化:一个动作,一个 id。 该调用变成一个
Action:一个名称、规范化参数(键已排序,无浮点数)、一个资源、主体。其 SHA-256 就是动作哈希。 - 决策:允许、询问或阻止。 先看权限(这个主体究竟能否提出该动作,以及在什么范围内?),再看策略(这个动作获得多少自主权?)。未知动作、缺失策略或缺失主体都是
deny。沉默绝不是许可。 - 批准:绑定到该动作。 由人针对动作哈希作出答复。批准是一次性的,会过期,并且除了那个确切的动作之外不匹配任何东西,因此答复之后被改动的参数会使它失效,需要由人重新答复。指定一个
preconditions=提供方,批准还会绑定到它被授予时所依据的资源状态,并在预留之前严格重新检查:这收窄了答复与执行之间的窗口,从数分钟的斟酌缩短到数毫秒。它并未关闭这个窗口,因为重新检查是一次网络调用,无法在原子写入内部运行。 - 预留:只被认领一次。 效果键,
refund:txn_1或namespace:prod-eu:checkout,在一次原子写入中被占用。第二个调用者,无论在另一个进程中还是在另一台主机上,都会被拒绝。 - 执行:你的代码运行。 只有由你抛出的
NotExecuted才意味着FAILED;其他所有异常和所有超时都意味着AMBIGUOUS。判断你正面对的是哪一种才是困难的部分,所以ctrlrun.transport替你完成:来自标准库urllib和http.client的urlopen、HTTPConnection和HTTPSConnection,仅当它们打开的连接未被交付任何请求字节时才抛出NotExecuted。在一个字节之后,每种失败都保持它原本的异常,结果为AMBIGUOUS。没有任何设置能放宽这一点。 - 解决:未知不等于失败。 一个
AMBIGUOUS效果会保留其键并拒绝重试,直到ctrlrun resolve,或一个询问了远端的reconcile钩子,说明发生了什么。任何东西都不会凭猜测运行两次。 - 记录:无论哪种结果都留一张凭据。 一份可移植的 JSON 凭据:谁、什么、决策、批准、效果键、结果,以及作出决策的策略的哈希,链接到它之前的那张凭据。拒绝也会得到一张。
谁可以提出请求,以及可以提出多少。 策略决定动作,且看不到是谁在提出请求。谁究竟可以提出请求是第二个维度:权限。每个主体都需要一项授权,委托不能扩大授权,而一个动作需要两个维度同时满足,取两者中更严格的那个。自 0.9 起,授权还可以携带一个预算,一个在滚动窗口内设有上限的指标,在同一写入内部于预留时消耗,因此一千笔各自通过 amount_lte 的退款,其总额不能超过授权所允许的。一个 AMBIGUOUS 效果会占用其预算直到它被解决,否则一个能够制造歧义的 Agent 就能制造权限。预算限定下一次预留可以做什么;它无法召回一个已经在执行中的动作。权限包含完整的模型。
状态默认存放在 SQLite 中,一个无需服务器、无需运维的文件,并且预留会跨进程保持,而不仅仅是跨线程。当有不止一台主机写入时,将它指向 Postgres:pip install "ctrlrun[postgres]",一个 URL,同样的保证由同一套测试套件评级。用 ctrlrun verify 在你自己的环境中证明它,它会针对你的策略在一个暂存存储中运行内核自身的失败场景。它不访问网络:它打开的唯一套接字是连向你指定的存储,以及它自己绑定的回环监听器,它正是以此对传输分类器进行评级。
| 保证 | @protect | Gateway | Adapter |
|---|---|---|---|
| 审批绑定 —— 审批与确切的动作绑定;被篡改或重放的审批会被拒绝。 | 是 | 是 | 依 adapter 而定,为预防或归因 |
| 一次效果,仅一次 —— 一个逻辑效果在跨线程、跨进程和跨主机的情况下至多发生一次。 | 是 | 是 | 是 |
| 未知不等于失败 —— 未知的结果是 AMBIGUOUS,而非 FAILED,并会阻止盲目重试。 | 是 | 是 | 是 |
| 失败即拒绝 —— 未知的动作、缺失的策略或缺失的主体都会被拒绝。 | 是 | 是 | 是 |
| 权限与委派 —— 每个主体都需要授权,委派不能扩大授权,且授权限定总量。 | 是 | 是 | 是 |
| 回执 —— 每个已执行的动作都会留下一个可移植的 JSON 回执,记录由谁、做了什么以及结果。 | 是 | 是 | 是 |
九个领域中的同一形态
ctrlrun 中没有任何东西知道什么是退款。一个动作是一个 name、canonical arguments、一个 effect key 和一个 resource,而无论它来自哪个领域,对它提出的三个问题都相同:这个动作 获得多少自主权、是否有一个人批准了这个确切的动作,以及这个效果是否已经发生过。承载你领域信息 的有两样东西,而这两样都由你来编写。
- effect key 是系统中唯一的领域知识。 它是这样一个字符串,表明两次调用是同一个现实世界后
果:
refund:{payment_id}、namespace:{cluster}:{name}、grant:{user_id}:{role}、prescription:{patient_id}:{drug}。把它命名好,重试就无法执行两次;省略它,至多一次就没 有任何可依托的对象。 - 条件是参数,而非金额。 该语言是
<argument>_<op>,因此同样的运算符可以像读取amount_lte一样轻松地读取replicas_lte: 10、role_in: [reader, viewer]和to_domain_eq: acme.com。一个从未开具过发票的领域也可以使用区间。
| 领域 | 自主执行 | 由人决定 | 绝不 |
|---|---|---|---|
| DevOps | k8s.scale_deployment 至 10 个副本 | terraform.apply | k8s.delete_namespace |
| 安全运营 | firewall.add_deny_rule | firewall.add_allow_rule | edr.disable_protection |
| 医疗 | appointment.reschedule | patient.export_record | prescription.change_dose |
| 法律 | document.draft_internal | document.file_with_court | contract.execute |
| 人力资源 | 在区间内 pto.approve | payroll.run | employee.delete_record |
| 保险 | claim.request_documents | 超出区间时 claim.approve_payout | policyholder.delete |
| 电子商务 | 在区间内 inventory.adjust | price.update | customer.delete |
| 公共服务 | eligibility.precheck | benefit.terminate | record.delete |
| 支付 | €500 以下的 stripe.refund | 超过该金额的 stripe.refund | stripe.delete_customer |
从左到右读取任何一行,它就是同一条规则换上了不同的名词。安全这一行值得读两遍:向防火墙添加 一条 deny 规则是自主执行的,而添加一条 allow 规则则不是,这是任何金额阈值都无法告诉你 的。策略是你写下关于自己领域判断的地方;ctrlrun 则是让它得以成立的东西。
文档
docs.ctrlrun.dev 是文档站点:包含概念、指南、实用手册以及 完整参考。
| 从这里开始 | 为什么 · 保护你的第一个操作 |
| 核心理念,以及如何运用它们 | 概念 · 指南 · 实用手册 |
| Agent 与 MCP | 你无法修改的 Agent · MCP 概览 · 五分钟上手网关 · 从你的助手中审批 |
| 真正投入运行 | 生产环境 · Postgres · 恢复 · 运维 |
| 每一个键、标志和错误 | 参考 · 常见问题 |
| 对比 | 幂等键 · 框架的 human-in-the-loop · 护栏库 · 持久化工作流 · 治理工具包 |
| 什么成立,什么不成立 | 威胁模型 · verify 证明了什么 · CLAIMS.md,每一句话都对应到它的测试 · 这是如何构建的 |
贡献
欢迎提交 issue 和 pull request:
CONTRIBUTING.md 和
CODE_OF_CONDUCT.md 包含
协作约定,
SECURITY.md 说明了如何报告
漏洞。本文件中的每一项声明背后都有测试支撑,因此对文字的修改通常
意味着对测试套件的修改。
CHANGELOG.md 和
https://docs.ctrlrun.dev/ROADMAP 说明了
它的发展方向。每个版本都带有来自 GitHub Actions 的 PyPI 来源证明。
许可证
Apache-2.0。执行内核现在是并且将始终保持完全开源。
评论
一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。