如果你已经把 AI Agent 接到工单、知识库、代码仓库、CRM 或内部运维系统,迟早会遇到一个尴尬问题:
Agent 的这一次工具调用,到底为什么被允许?
很多团队第一反应是做 RBAC:用户有 admin 角色,所以 Agent 可以调用 update_ticket;用户只有 viewer,所以只能调用 search_docs。这当然必要,但它远远不够。RBAC 只能解释"理论上能不能调用",解释不了生产事故里真正需要的东西:这一次调用发生时,Agent 看到了什么上下文?拿的是哪一个授权快照?策略版本是多少?有没有人工审批?输入输出有没有被改写?失败后补偿动作是什么?三天后还能不能回放?
这篇文章想讲一个更工程化的做法:给 Agent 的每一次工具调用建立"权限账本"(permission ledger)。它不是普通日志,也不是把所有请求体粗暴塞进 Elasticsearch。它是一条可审计、可回放、可关联 trace、可用于事后追责和策略迭代的调用证据。
先说结论:
- RBAC 是准入表,权限账本是证据链。 前者告诉你"规则怎么写",后者告诉你"这次为什么发生"。
- 不要只记录 tool name 和 status。 至少要记录授权快照、策略版本、风险等级、输入输出摘要、审批证据、trace/span id 和补偿状态。
- 账本不是明文 dump。 生产系统里应使用字段级脱敏、摘要哈希、保留周期和不可篡改写入,避免把审计系统变成新的数据泄露源。
- MCP、OAuth、OpenTelemetry 不是替代品。 它们分别解决协议授权、令牌流转、可观测性结构;权限账本负责把"授权决策"和"工具副作用"串成业务可理解的证据。
1. 一个很常见的事故:权限是合法的,调用是不该发生的
假设你做了一个客服 Agent。它可以读取用户资料、查询订单、更新工单状态。某天用户说:
"把这个客户的退款工单标成已处理,并顺手把备注改成我说的这段话。"
Agent 调用了:
json
{
"tool": "ticket.update",
"args": {
"ticket_id": "T-92817",
"status": "resolved",
"note": "客户已同意退款完成"
}
}
调用成功。第二天业务同学发现:客户并没有同意,Agent 是从一段旧聊天记录里误读出来的。
你去查权限系统,RBAC 显示:客服主管确实有 ticket:update 权限;Agent 是代表客服主管执行;工具调用也没有越权。看起来一切"合法"。但安全、合规和业务负责人真正想问的是:
- 这一次调用时,Agent 基于哪些上下文判断"客户已同意"?
- 这个写操作是否属于高风险动作?
- 当时策略版本是否要求人工审批?为什么没有触发?
- Agent 使用的授权是即时授权、长期令牌,还是继承用户会话?
- 工具输入是否经过二次校验?输出是否产生了可恢复的副作用?
- 如果要复盘,能否只重放策略判定,而不重新执行写操作?
如果系统只能回答"用户有权限,所以允许",那就是审计断层。
这就是权限账本要解决的问题。
2. RBAC 为什么不够:Agent 的权限不是一次性判断
传统后台系统里,权限判断通常发生在 API 边界:请求进来,检查用户、角色、资源、动作,允许或拒绝。这个模型默认"调用者知道自己在做什么"。
Agent 系统不一样。Agent 的一次工具调用中间多了几层不确定性:
- 用户意图可能含糊;
- 模型可能误读上下文;
- 检索结果可能过期;
- 工具描述可能被错误理解;
- 多步任务中,第 5 步的决策依赖第 1 步的中间结果;
- 工具结果会反过来进入上下文,影响下一次调用;
- 写操作可能产生外部副作用,比如发消息、改状态、扣额度、创建订单。
所以 Agent 的权限判断至少包含三层:
| 层级 | 传统问题 | Agent 场景的新问题 |
|---|---|---|
| 身份层 | 谁在调用? | Agent 是代表谁调用?用户本人、系统账号还是租户机器人? |
| 策略层 | 这个角色能否执行动作? | 当前上下文、风险等级、数据范围、时间窗口是否允许? |
| 证据层 | API 是否返回成功? | 这次决策依据是什么?能否复盘、回放、追责、补偿? |
RBAC 主要覆盖前两层的一部分。权限账本覆盖第三层,并把前两层的关键快照记录下来。
注意这里的"快照"很重要。生产事故经常不是当下规则错,而是当时规则是什么已经查不清了。今天你修了策略,昨天的调用用的是旧策略;今天用户角色被降级,昨天调用时还是管理员;今天工具 manifest 里标成高风险,昨天还是中风险。如果没有账本,你只能靠猜。
3. 权限账本应该记录什么
我建议把每一次 tool_call 拆成 8 类字段。
3.1 调用身份:who on behalf of whom
至少记录:
json
{
"tenant_id": "acme",
"actor_type": "agent",
"agent_id": "support-agent-v3",
"on_behalf_of": "user_123",
"session_id": "sess_789"
}
关键不是"Agent 叫什么",而是 Agent 代表谁行动。读操作可以允许系统身份执行,写操作最好绑定真实用户或明确的服务账号。否则出了问题,会变成"机器人干的",没有责任边界。
3.2 工具定义:what was callable
记录工具 manifest 的版本和摘要:
json
{
"tool": "ticket.update",
"tool_version": "2026-07-20.3",
"tool_manifest_hash": "sha256:...",
"risk_level": "high",
"side_effect": "write_external_state"
}
不要只记 ticket.update。工具描述、参数 schema、风险等级都会变。manifest hash 可以让你三个月后确认:当时 Agent 看到的工具定义到底是哪一版。
3.3 授权快照:grant snapshot
记录当时授权状态,而不是只记录最终 allow/deny:
json
{
"grant": {
"scope": ["ticket:read", "ticket:update"],
"resource_filter": { "region": "cn-east", "team": "refund" },
"expires_at": "2026-07-27T09:00:00+08:00",
"grant_source": "user_delegated_oauth"
}
}
MCP 的 HTTP 授权规范里,受保护 MCP Server 可以作为 resource server,Client 代表资源所有者请求受保护资源,授权链路会涉及资源元数据、授权服务器发现和访问令牌。工程上这很好,但落到事故复盘时,仅知道"有 token"还不够,你要知道 token 当时代表的 scope、资源边界和过期时间。
3.4 策略判定:why allowed
记录策略引擎给出的结构化解释:
json
{
"policy": {
"policy_version": "perm-policy-42",
"decision": "approval_required",
"matched_rules": ["write_requires_approval", "refund_team_only"],
"reason": "high-risk write requires human approval"
}
}
很多团队的策略判断只有一个 boolean。boolean 对机器够用,对审计不够用。你至少要知道命中了哪些规则,哪个规则把结果从 allow 改成 approval_required。
3.5 输入摘要:what was requested
不要把完整 prompt、客户资料、聊天记录塞进审计日志。建议记录:
- schema 校验后的参数;
- 高敏字段的脱敏值;
- 原始输入 hash;
- 关键上下文引用 id;
- 模型生成的调用理由摘要。
例如:
json
{
"input": {
"args_redacted": { "ticket_id": "T-92817", "status": "resolved", "note": "[REDACTED:78chars]" },
"args_hash": "sha256:...",
"context_refs": ["msg_173", "kb_42"],
"agent_rationale": "用户要求关闭退款工单;Agent 判断客户已同意"
}
}
这里最容易犯错的是"为了审计,全部保存"。这会让审计系统变成最大的敏感数据仓库。权限账本要保存足够复盘的信息,而不是保存所有明文。
3.6 输出与副作用:what changed
写操作必须记录副作用摘要:
json
{
"output": {
"status": "success",
"external_id": "T-92817",
"changed_fields": ["status", "note"],
"before_hash": "sha256:...",
"after_hash": "sha256:...",
"compensation": {
"available": true,
"tool": "ticket.rollback",
"deadline": "2026-07-30T00:00:00+08:00"
}
}
}
如果是不可逆动作,比如发出通知、提交付款、删除资源,就应该在策略层升级为 approval_required 或 deny,而不是事后才说"日志里有记录"。
3.7 Trace 关联:how to find it in observability
把权限账本和 trace/span 关联起来:
json
{
"trace": {
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span_id": "00f067aa0ba902b7",
"parent_span_id": "..."
}
}
OpenTelemetry 的 Trace API 把一次 operation 建模为 span。Agent 系统里可以把 agent.run、policy.check、tool.call、approval.wait、tool.compensate 都作为 span 或 event。这样你在可观测性平台里看到延迟、错误、重试时,可以跳到权限账本;在审计账本里看到一次高风险 allow 时,也可以跳回完整 trace。
3.8 完整性:can it be tampered with
最后是完整性字段:
json
{
"ledger": {
"entry_id": "led_01j...",
"prev_hash": "sha256:previous",
"entry_hash": "sha256:current",
"written_at": "2026-07-27T08:10:00+08:00"
}
}
最低配可以用 append-only JSONL + hash chain。更严格的场景可以写入不可变对象存储、审计数据库或外部合规系统。重点是:不要让业务服务可以随便改旧账。
4. 一个最小可行架构
可以从 5 个组件开始:
text
User / Agent Session
|
v
Tool Call Planner
|
v
Permission Gateway
| | |
| | +--> Tool Manifest Registry
| +-----------> Grant Snapshot Provider
+--------------------> Policy Engine
|
v
Tool Executor -----> External System
|
v
Permission Ledger Writer -----> append-only ledger
|
v
Replay Verifier / Audit UI
几个工程边界要明确:
- Agent 不直接调工具。 它只能提交 tool intent,由 Permission Gateway 代理执行。
- 策略判断发生在执行前。 不要先执行再补日志。
- 账本写入要覆盖 denied 调用。 被拒绝的调用同样重要,尤其能发现 prompt 注入和越权尝试。
- 高风险写操作先写 pending,再等审批。 审批通过后追加 approved/executed 记录,不要覆盖原记录。
- 回放只回放判定,不默认重放副作用。 Replay Verifier 应该默认 dry-run。
5. 可运行 Demo:一个 150 行以内的权限账本
下面是一个简化版 Node.js demo。它没有依赖外部服务,直接写 JSONL。你可以复制到 ledger-demo.mjs 运行。
js
import { appendFileSync, readFileSync, existsSync } from 'node:fs';
import crypto from 'node:crypto';
const LEDGER = './permission-ledger.jsonl';
const tools = {
'ticket.search': {
version: '2026-07-27.1',
risk: 'low',
sideEffect: 'read',
scopes: ['ticket:read']
},
'ticket.update': {
version: '2026-07-27.1',
risk: 'high',
sideEffect: 'write_external_state',
scopes: ['ticket:update']
},
'payment.refund': {
version: '2026-07-27.1',
risk: 'critical',
sideEffect: 'money_movement',
scopes: ['payment:refund']
}
};
function sha256(x) {
return crypto.createHash('sha256').update(typeof x === 'string' ? x : JSON.stringify(x)).digest('hex');
}
function lastHash() {
if (!existsSync(LEDGER)) return 'GENESIS';
const lines = readFileSync(LEDGER, 'utf8').trim().split('\n').filter(Boolean);
if (!lines.length) return 'GENESIS';
return JSON.parse(lines.at(-1)).ledger.entry_hash;
}
function redactArgs(args) {
const out = {};
for (const [k, v] of Object.entries(args)) {
if (/note|comment|message|content|phone|email/i.test(k)) {
out[k] = `[REDACTED:${String(v).length}chars]`;
} else {
out[k] = v;
}
}
return out;
}
function decide({ tool, grant, hasApproval }) {
const manifest = tools[tool];
if (!manifest) return { decision: 'deny', rules: ['unknown_tool'], reason: 'tool is not registered' };
const missing = manifest.scopes.filter(s => !grant.scopes.includes(s));
if (missing.length) {
return { decision: 'deny', rules: ['missing_scope'], reason: `missing scope: ${missing.join(',')}` };
}
if (manifest.risk === 'critical') {
return { decision: 'deny', rules: ['critical_action_blocked'], reason: 'critical money movement is disabled for agents' };
}
if (manifest.risk === 'high' && !hasApproval) {
return { decision: 'approval_required', rules: ['high_risk_requires_approval'], reason: 'high-risk write requires human approval' };
}
return { decision: 'allow', rules: ['scope_match', 'risk_accepted'], reason: 'scope and risk policy passed' };
}
function fakeExecute(tool, args) {
if (tool === 'ticket.search') return { status: 'success', rows: 3, outputHash: sha256({ rows: 3 }) };
if (tool === 'ticket.update') {
return {
status: 'success',
externalId: args.ticket_id,
changedFields: Object.keys(args),
beforeHash: sha256('before-state'),
afterHash: sha256(args),
compensation: { available: true, tool: 'ticket.rollback' }
};
}
return { status: 'skipped' };
}
function callTool({ tenant, agentId, userId, sessionId, tool, args, grant, hasApproval = false }) {
const manifest = tools[tool];
const traceId = crypto.randomBytes(16).toString('hex');
const spanId = crypto.randomBytes(8).toString('hex');
const policy = decide({ tool, grant, hasApproval });
const base = {
identity: { tenant, actor_type: 'agent', agent_id: agentId, on_behalf_of: userId, session_id: sessionId },
tool: manifest ? {
name: tool,
version: manifest.version,
manifest_hash: sha256(manifest),
risk_level: manifest.risk,
side_effect: manifest.sideEffect
} : { name: tool },
grant_snapshot: {
scopes: grant.scopes,
resource_filter: grant.resourceFilter || {},
expires_at: grant.expiresAt,
grant_source: grant.source || 'user_delegated'
},
policy: { version: 'perm-policy-42', ...policy },
input: {
args_redacted: redactArgs(args),
args_hash: sha256(args),
context_refs: ['msg:last-user', 'retrieval:last-3'],
agent_rationale: 'tool selected by planner; gateway performs final decision'
},
trace: { trace_id: traceId, span_id: spanId }
};
let output = { status: 'not_executed' };
if (policy.decision === 'allow') output = fakeExecute(tool, args);
const prev = lastHash();
const entryWithoutHash = { ...base, output, ledger: { prev_hash: prev, written_at: new Date().toISOString() } };
const entryHash = sha256(entryWithoutHash);
const entry = { ...entryWithoutHash, ledger: { ...entryWithoutHash.ledger, entry_hash: entryHash } };
appendFileSync(LEDGER, JSON.stringify(entry) + '\n');
return entry;
}
function verifyLedger() {
if (!existsSync(LEDGER)) return true;
let prev = 'GENESIS';
for (const line of readFileSync(LEDGER, 'utf8').trim().split('\n').filter(Boolean)) {
const entry = JSON.parse(line);
const expectedPrev = entry.ledger.prev_hash;
const actualHash = entry.ledger.entry_hash;
const clone = structuredClone(entry);
delete clone.ledger.entry_hash;
if (expectedPrev !== prev) return false;
if (sha256(clone) !== actualHash) return false;
prev = actualHash;
}
return true;
}
const grant = {
scopes: ['ticket:read', 'ticket:update'],
resourceFilter: { team: 'refund' },
expiresAt: '2026-07-27T09:00:00+08:00',
source: 'oauth_user_delegated'
};
console.log('read:', callTool({ tenant: 'acme', agentId: 'support-v3', userId: 'u1', sessionId: 's1', tool: 'ticket.search', args: { q: 'refund' }, grant }).policy.decision);
console.log('write no approval:', callTool({ tenant: 'acme', agentId: 'support-v3', userId: 'u1', sessionId: 's1', tool: 'ticket.update', args: { ticket_id: 'T-92817', status: 'resolved', note: '客户已同意退款完成' }, grant }).policy.decision);
console.log('write approved:', callTool({ tenant: 'acme', agentId: 'support-v3', userId: 'u1', sessionId: 's1', tool: 'ticket.update', args: { ticket_id: 'T-92817', status: 'resolved', note: '客户已同意退款完成' }, grant, hasApproval: true }).policy.decision);
console.log('refund:', callTool({ tenant: 'acme', agentId: 'support-v3', userId: 'u1', sessionId: 's1', tool: 'payment.refund', args: { order_id: 'O-1', amount: 199 }, grant: { ...grant, scopes: [...grant.scopes, 'payment:refund'] }, hasApproval: true }).policy.decision);
console.log('ledger ok:', verifyLedger());
运行结果大概是:
text
read: allow
write no approval: approval_required
write approved: allow
refund: deny
ledger ok: true
这个 demo 很小,但已经覆盖了生产系统里最关键的 4 个分支:低风险读取直接允许;高风险写入没有审批则进入审批;审批后执行并记录补偿信息;资金类关键动作即使有 scope 也被策略拒绝。
6. 四个实验结果:账本比日志多解决了什么
我用上面的 demo 跑了 4 组场景,观察点不是性能,而是事故复盘能力。
| 场景 | 普通日志能看到 | 权限账本能额外回答 |
|---|---|---|
| 低风险读取 | tool=search, status=success | 当时 scope、manifest hash、trace id、输入 hash |
| 高风险写入未审批 | status=not_executed | 命中 high_risk_requires_approval,为什么没有执行 |
| 高风险写入已审批 | status=success | 审批状态、changed fields、before/after hash、补偿工具 |
| 关键资金动作 | status=deny | 即使 scope 存在,策略仍按 critical_action_blocked 拒绝 |
最有价值的是第二和第四个场景。很多事故不是"系统没拦住",而是"系统拦住了但没人知道为什么";或者"用户有权限,但这类动作不应该交给 Agent 自动执行"。权限账本把这些边界变成可查询的数据。
如果你把账本接入审计 UI,可以做出很实用的查询:
sql
-- 最近 24 小时,所有高风险 allow
SELECT * FROM permission_ledger
WHERE tool_risk_level IN ('high', 'critical')
AND policy_decision = 'allow'
AND written_at > now() - interval '24 hours';
-- 某个策略版本上线后,approval_required 是否异常升高
SELECT policy_version, policy_decision, count(*)
FROM permission_ledger
WHERE written_at > '2026-07-27'
GROUP BY policy_version, policy_decision;
-- 某个用户被 Agent 代理执行过哪些写操作
SELECT tool_name, changed_fields, trace_id, written_at
FROM permission_ledger
WHERE on_behalf_of = 'user_123'
AND side_effect != 'read';
这类查询比"翻日志找关键字"稳定得多。
7. 权限账本和 MCP/OAuth/OpenTelemetry 的关系
容易混淆的点是:既然 MCP 有授权规范,OAuth 有 scope,OpenTelemetry 有 trace,那为什么还要权限账本?
我的理解是:
- MCP 授权解决"客户端如何代表资源所有者访问受保护 MCP Server"。它是协议层能力。
- OAuth/scope解决"令牌是否拥有某些资源权限"。它是授权凭据能力。
- OpenTelemetry解决"运行时调用链如何观测"。它是可观测性结构。
- 权限账本解决"这一次工具调用的授权、策略、上下文、输入输出、副作用、审批和补偿如何形成证据"。它是审计与治理能力。
它们应该组合,而不是互相替代。
一个比较好的落地方式是:
- MCP Client 通过授权流程拿到用户委托令牌;
- Agent 计划调用工具,但不直接执行;
- Permission Gateway 读取 tool manifest 和 grant snapshot;
- Policy Engine 给出 allow/deny/approval_required;
- Tool Executor 执行或拒绝;
- Ledger Writer 写入账本;
- Trace span 记录延迟、错误、重试和 ledger entry id。
这样,协议、权限、执行和审计就闭环了。
8. 生产落地的 9 条建议
8.1 不要让 Agent 直接持有长期高权限 token
Agent 最好拿短期、可撤销、绑定用户或任务的授权。长期 admin token 一旦进入 Agent runtime,后面所有策略都会变得脆弱。即使你有账本,也只是记录了事故如何发生。
8.2 把工具按副作用分级
至少分四类:
- read:读取,不改变外部状态;
- write_internal:改内部草稿、缓存、临时状态;
- write_external:改业务系统状态;
- irreversible:不可逆或高成本动作,比如付款、删除、外发通知。
不同等级对应不同默认策略。不要只靠 tool name 猜风险。
8.3 denied 也要入账
被拒绝的调用是安全信号。比如某次 prompt 注入诱导 Agent 调 user.export_all,策略拒绝了。如果你不记录 denied,就错过了攻击样本和策略改进依据。
8.4 账本字段要可索引,不要只存 blob
policy_decision、risk_level、tool_name、on_behalf_of、trace_id、approval_id 应该是独立字段。否则审计查询会退化成全文搜索。
8.5 明文 payload 要克制
输入输出建议分三层:
| 数据 | 建议 |
|---|---|
| 低敏参数 | 可明文记录 |
| 中高敏字段 | 脱敏后记录 + 原文 hash |
| 极高敏内容 | 只记录引用 id、分类、hash,不进账本 |
不要因为"审计需要"就把用户隐私复制一份。
8.6 策略版本必须可回放
每条账本记录策略版本和 tool manifest hash。策略仓库要保留历史版本。回放时用当时版本判断一次,看看今天的策略会不会给出不同结果。这个能力对策略迭代很有用。
8.7 审批是状态机,不是一个 boolean
高风险调用常见状态:
text
planned -> approval_required -> approved -> executed -> compensated
\-> rejected
\-> expired
每个状态都应追加账本事件,而不是覆盖同一行。覆盖会破坏证据链。
8.8 和 trace 打通
账本适合回答"为什么允许";trace 适合回答"在哪里慢、哪里错、重试了几次"。两边通过 trace_id、span_id、ledger_entry_id 互跳,排障效率会高很多。
8.9 先从高风险工具做起
不用一开始覆盖所有工具。优先接入:
- 外发消息;
- 修改业务状态;
- 资金/额度相关操作;
- 删除/归档;
- 读取高敏数据;
- 跨租户或跨团队数据访问。
从这些工具开始,收益最大。
9. 常见反对意见
"我们已经有 API access log 了,还需要吗?"
需要。API log 通常记录 HTTP 层事实:谁请求了哪个接口、状态码多少、耗时多少。权限账本记录 Agent 语义层事实:为什么选择这个工具、当时策略如何判定、授权快照是什么、审批证据在哪里、输出副作用如何补偿。两者不是一个层级。
"这会不会太重?"
如果你给每个 read tool 都写完整账本,确实重。更实际的做法是分级:低风险读取记录轻量字段,高风险写入记录完整证据,不可逆动作要求审批和补偿计划。账本也可以异步写,但执行前的策略判定不能异步。
"会不会拖慢 Agent?"
策略判断和账本写入会增加延迟,但通常比模型调用小得多。真正需要注意的是外部审计存储不可用时怎么办。我的建议是:
- 对高风险写操作,账本写入失败则拒绝执行;
- 对低风险读取,账本写入失败可以降级为本地缓冲;
- 对不可逆动作,审计系统不可用时直接熔断。
"能不能只靠 prompt 约束 Agent 不要乱调工具?"
不能。Prompt 是行为建议,不是权限边界。工具权限必须在模型外部的 deterministic gateway 中执行。Agent 可以提出 intent,但最终 allow/deny 应由可测试、可审计的策略引擎决定。
10. 最后:生产 Agent 的安全感来自证据,不来自信任
Agent 从 demo 到生产,最大的变化不是多接几个工具,而是它开始产生真实副作用。它会改状态、发通知、创建记录、触发流程。到了这个阶段,"我们相信模型会正确调用工具"是不够的。
你需要的是一条能回答这些问题的证据链:
- 谁让 Agent 做的?
- Agent 代表谁做的?
- 当时有哪些权限?
- 策略为什么允许或拒绝?
- 工具输入输出是什么摘要?
- 产生了什么副作用?
- 有没有审批?
- 能不能回放?
- 出错后能不能补偿?
这就是权限账本的价值。
我的建议很简单:如果你的 Agent 还只读知识库,可以先用普通 trace 和 access log;如果它已经开始写业务系统、发消息、改工单、触发财务或运维动作,就不要只做 RBAC 了。给每一次工具调用记一笔账。未来真出事故时,你会感谢今天多写的这几行证据。
参考资料:
- MCP Authorization specification(2025-06-18):HTTP transport 授权、OAuth 2.1 子集、资源服务器元数据发现。
- MCP runtime build/buy discussion(2026):agent authorization、OAuth token rotation、audit logging、policy enforcement 是生产 runtime 的关键边界。
- Production-grade Agent framework documentation:durability、restartability、observability、governance、human-in-the-loop 是生产 Agent 的基础能力。
- OpenTelemetry Trace API:Tracer、Span 与 operation tracing 为 tool call 关联账本提供通用结构。