marcinmarzeta/obstat

GitHub: marcinmarzeta/obstat

obstat 为 AI Agent 工具调用提供事前持久化的可审计授权决策记录,确保每次调用在被允许执行之前都有不可篡改的书面授权证据。

Stars: 0 | Forks: 0

# obstat [![PyPI](https://img.shields.io/pypi/v/obstat)](https://pypi.org/project/obstat/) [![Python](https://img.shields.io/pypi/pyversions/obstat)](https://pypi.org/project/obstat/) [![CI](https://static.pigsec.cn/wp-content/uploads/repos/cas/99/993938d8ce5e902ccfb9d6747725c320d855dea3235ed9a304cedf0d94c9321f.svg)](https://github.com/marcinmarzeta/obstat/actions/workflows/ci.yml) [![License](https://img.shields.io/pypi/l/obstat)](LICENSE) **用于 agent 工具调用的可审计决策记录。** 许可会在调用运行**之前**写下——而不是事后从日志中重构。 *Nihil obstat*:毫无阻碍。这是审查员在**发布之前,以书面形式**授予的正式许可。这就是这里的核心理念。Agent 请求执行某项操作,由规则做出决策,并且该决策会在工具主体执行*之前*写入磁盘。如果进程在调用期间意外终止,记录依然会说明被授权的内容及其原因。 ``` pip install obstat ``` 零依赖。不依赖 AWS,不依赖身份提供商,也不依赖策略服务——只需 decorator、`tomllib`、`sqlite3` 和一个文件。 [`docs/obstat-spec.md`](docs/obstat-spec.md) 是规范性文档,其第 8 节列出了目前仍然薄弱的环节。 ## 60 秒简介 ``` $ obstat init wrote obstat.toml — everything is denied until you uncomment a rule ``` `obstat.toml`: ``` [[rule]] tool = "read_*" effect = "allow" [[rule]] tool = "delete_*" effect = "approve" ``` 你的工具: ``` from obstat import guard @guard(resource="doc:{doc_id}") def delete_document(doc_id: str) -> str: ... # obstat has already decided this may happen ``` 首次调用将直接返回,而不会执行: ``` >>> delete_document("q3-report") {'obstat': 'approval_required', 'approval_id': '4f1c2a9b8e07', 'expires_in_seconds': 900, 'retry': "A human must approve this call. Once approved, call the same tool again with identical arguments plus obstat_approval_id='4f1c2a9b8e07'."} ``` 由人类做出决策: ``` $ obstat pending 4f1c2a9b8e07 delete_document anonymous doc:q3-report 871s left $ obstat approve 4f1c2a9b8e07 4f1c2a9b8e07 approved by ana ``` Agent 使用该 id 进行重试,调用开始执行,并且 `.obstat/decisions.jsonl` 会记录整个过程。 ## 它不是什么 市面上已经有很多优秀的库可以拦截 MCP 工具调用。而本项目围绕一个更狭义的主张构建:**记录本身就是产物。** 由此引出以下四个要点,这也是选择本项目而非普通权限包装层的原因。 **决策在主体运行之前就是持久化的。** 不会在事后刷入,不会写在 `finally` 块中,也不会进行批处理。`record.decision()` 只有在 `fsync` 之后才会返回。事后写入的日志只是对已发生事情的陈述;而事前写入的记录才是授权的证据。我们有一个测试会在工具主体内部运行,从磁盘读取日志,如果它发现自身的决策记录还未存在,测试就会失败。 **授权是针对 resource 的,而不是针对工具的。** 一个分级标签——READ、WRITE、DESTRUCTIVE——无法说明“可以编辑他们自己的工单,但不能编辑你的”。obstat 会从调用参数中解析出 resource,并以此为依据匹配规则: ``` @guard(resource="jira_issue:{issue_key}") ``` ``` [[rule]] subject = "human:ana" resource = "jira_issue:ACME-*" effect = "allow" ``` **一次批准绑定到一次调用。** 它携带了 tool、subject、resource 以及参数的 digest,并且只能使用一次。批准“删除 q3-report”不能被用于删除其他内容,也不能被使用两次。这是在一个 `BEGIN IMMEDIATE` 事务中强制执行的,因此两个并发的重试不可能同时成功。 **记录能显示出它是否被篡改过。** 每条记录都包含前一条记录的哈希值,并且 `obstat verify` 会重新计算这条链: ``` $ obstat verify line 3: record cd53f9db… follows a record that is no longer in the log ``` 被篡改的行和被删除的行都会暴露无遗。但截断尾部记录是无法被检测到的,并且任何能写入该文件的人都可以重新计算整条链——这是 tamper-evidence(防篡改证据),而非 non-repudiation(不可否认性),[第 8 节](docs/obstat-spec.md#8-still-open) 已经明确使用了这些词汇进行了说明。 ## 身份验证是可选的 如今大多数 MCP 服务器根本没有 token:stdio(标准输入输出)、单一本地用户,或者是已经终止身份验证的 gateway。在尝试使用治理库之前就强制要求提供身份提供商,正是治理库迟迟得不到试用的原因。在这里,匿名调用也是一种合法的调用——它会被记录为 `anonymous`,并由策略决定 `anonymous` 可以执行哪些操作。 当你*确实*拥有身份信息时,将其传递过来即可: ``` from obstat import Subject, set_subject_resolver set_subject_resolver(lambda: Subject(id=current_user(), kind="human", verified=True)) ``` 如果身份信息来源于调用者可能影响的地方(例如 header 或参数),`verified=False` 就是如实反映该情况的标记。该标记会被记录下来,因此阅读者就能分辨出“这是 Ana 做的”和“某个自称是 Ana 的主体做的”之间的区别。 ## 策略 首条匹配的规则生效。如果没有匹配项,则拒绝——缺失规则不等于授予权限,并且缺失策略文件会被视为错误,而不是隐式允许。 | 键 | 匹配内容 | 默认值 | |---|---|---| | `tool` | 函数名,或 decorator 上的 `tool=` | `*` | | `subject` | `human:ana`、`agent:planner`、`service:etl`、`anonymous` | `*` | | `resource` | resource 模板生成的任何内容 | `*` | | `effect` | `allow`、`deny`、`approve` | 必填 | 模式匹配使用 glob。文件在发生更改时会被重新读取,因此编辑策略不需要重启服务。 它们是**区分大小写**匹配的,匹配对象是由调用者发送的参数构建的 id。如果你的 namespace 不区分大小写——比如 Jira 键、大多数文件系统——那么为 `jira_issue:SEC-*` 编写的规则将无法覆盖 `sec-1`,而且系统不会有任何提示。在构建 id 的地方对其进行规范化处理: ``` @guard(resource=lambda a: f"jira_issue:{a['issue_key'].upper()}") ``` 这就是 callable 形式的用途;[§3.3](docs/obstat-spec.md#33-resource-resolution) 包含了完整的说明,解释了为什么 obstat 不会替你处理大小写转换。 ## 执行顺序 ``` 1 reject a caller-supplied subject 2 stop file 3 resolve the resource from the arguments 4 policy 5 approval, if policy asked for one 6 write the decision record — durable <-- before, not after 7 run the body 8 write the outcome — best effort ``` 步骤 1 的存在是因为 `subject` 已从工具对外公开的 signature 中剥离。如果客户端仍然发送该参数,说明它在试图自我命名,这在任何代码读取该值之前就会被直接拒绝。 步骤 8 刻意设计为非持久化。如果进程在步骤 7 和 8 之间意外终止,记录将显示为“已授权,结果未知”,这是最诚实的状态;为了输出仅仅具有参考价值的信息而进行第二次 `fsync` 付出的代价是不划算的。 ## 运维命令 ``` obstat init # a starter policy; refuses to overwrite one obstat check [res] # what the policy would decide, without a call obstat pending # approvals waiting on a human obstat approve [--by] # decide obstat deny [--by] obstat log -n 50 # the decision record obstat verify # recompute the chain; exit 1 if anything was edited obstat stop # deny every guarded call obstat resume ``` `obstat stop` 的检查优先于策略,因此停止操作永远不会依赖于策略文件是否依然可解析。 `obstat check` 对于 allow(允许)操作会返回退出码 0,对于其他任何情况则返回 1,因此可以在 CI 中对策略进行测试——并且解析失败的策略可以在 CI 阶段就被发现并报告,而不是等到下一次真实的工具调用时才暴露。 ## 参数 默认情况下仅计算指纹,绝不存储:工具参数会携带凭证和个人数据,如果治理日志泄露了这些信息,它就会成为负担而非管控手段。标记出那些需要人类审查的参数,这些值也会被记录下来—— ``` @guard(resource="tool:send_email", record_args=("to",)) def send_email(to: str, body: str) -> str: ... ``` ``` $ obstat pending d41b88f29a43 send_email human:ana tool:send_email 871s left to = 'board@example.com' ``` ——因为在审批者眼里,对着 `sha256:ae32e6…` 做决策,等同于对未知内容做决策。 digest 依然涵盖了每一个参数;`body` 被包含在其中,且不会出现在其他任何地方。请标明标识符,而不是 payload。 ## 尚未实现的功能 特意省略的内容:日志的保留与轮转、Slack 和 webhook 审批渠道、关于结果可能被*发送*到何处的策略,以及任何与云端通信的功能。这些功能应该处于系统边缘,并且它们应该作为适配器存在,而不是成为依赖项。 ## 配置 | 变量 | 默认值 | |---|---| | `OBSTAT_POLICY` | `obstat.toml` | | `OBSTAT_LOG` | `.obstat/decisions.jsonl` | | `OBSTAT_DB` | `.obstat/approvals.db` | | `OBSTAT_HALT` | `.obstat/halt` | | `OBSTAT_APPROVAL_TTL` | `900`(秒) | 在调用时读取,绝不在导入时读取。如果某个库在导入阶段就会抛出异常,那它就是一个你根本无法尝试使用的库。 ## 许可证 Apache-2.0。版权所有 2026 Marcin Marzęta。
标签:AI智能体, Python, Python安全, 工具调用, 操作审计, 无后门, 时序数据库, 权限控制, 策略引擎, 网络安全挑战, 逆向工具