marcinmarzeta/obstat
GitHub: marcinmarzeta/obstat
obstat 为 AI Agent 工具调用提供事前持久化的可审计授权决策记录,确保每次调用在被允许执行之前都有不可篡改的书面授权证据。
Stars: 0 | Forks: 0
# obstat
[](https://pypi.org/project/obstat/)
[](https://pypi.org/project/obstat/)
[](https://github.com/marcinmarzeta/obstat/actions/workflows/ci.yml)
[](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安全, 工具调用, 操作审计, 无后门, 时序数据库, 权限控制, 策略引擎, 网络安全挑战, 逆向工具