本文整理自 Airia 的 Agent Governance 框架、Microsoft Agent Governance Toolkit (AGTK)、Token Security 的 Credentials-to-Capabilities 范式、Organizational Control Layer 论文、Cordum 的 HITL 生产模式、WorkOS 的 Agent Audit Logs、AxonIQ 的事件溯源与 Glass Box AI、Padiso 的 Agent 版本管理、Skillware 论文 与 Agent Patterns 的 Overengineering 反模式,并结合实际开发的一些项目而成。
Agent Governance 设计:从 Guardrails 到执行控制层
1. Agent Governance 到底是什么?
在普通的 LLM 应用里,我们通常关心两件事:输入有没有越过安全边界,输出是否符合格式和内容要求。Agent 把模型放进了一个持续运行的循环中:模型会观察上下文、规划下一步、选择工具、构造参数,再根据工具结果继续规划。模型输出不再只是文字,也可能变成文件写入、数据库更新、消息发送或一次交付。
因此,Agent Governance 不是给 prompt 再加几条规则,而是围绕 Agent Runtime 建立一套责任、权限、策略、人工介入、版本事实和审计机制。它需要回答一组跨层问题:
- 这个 Agent 代表谁,当前任务的范围是什么?
- 它能调用哪些能力,参数指向哪些真实资源?
- 哪些动作可以自动执行,哪些动作必须让人确认,哪些动作无论如何都不能执行?
- 一个模型生成的候选结果,什么时候才算获得业务授权?
- Skill、策略、资源和模型变化后,如何知道某次执行依据的是哪一版?
- 出现错误或争议时,能不能还原输入、决策、审批和最终副作用?
可以把一次 Agent 执行抽象为下面这条链路:
用户意图
↓
模型规划与工具选择
↓
Tool Call(工具名 + 参数 + 真实目标)
↓
治理决策(allow / audit / approval / deny)
↓
人工确认或自动继续
↓
真实副作用(文件、数据库、消息、外部 API、交付物)
↓
结构化证据(事件、版本、receipt、provenance)
这里的关键点是:模型提出的是意图,治理层授予的是一次具体执行的资格。两者之间必须有一个可检查、可暂停、可追溯的边界。
2. Governance、Guardrails 与权限系统的边界
下面是这几个概念的简单区分:
| 能力 | 主要问题 | 典型位置 | 是否直接控制副作用 |
|---|---|---|---|
| Guardrail | 输入、输出或模型行为是否满足约束 | LLM 调用前后、输出解析阶段 | 有时能阻断,但不等于工具授权 |
| Execution Governance | 这一次具体 Tool Call 能否发生 | 工具真正执行前 | 能返回 deny 或挂起审批 |
| Capability | Agent 被允许执行什么具体操作 | 宿主/运行时授予 | 本身不控制,但决定工具边界 |
| Human-in-the-loop | 风险判断是否需要人在场 | 执行边界与宿主 UI | 确认前不执行 |
| Audit / Observability | 发生了什么、依据是什么 | 决策完成后、事件 sink | 只能记录,不能代替授权 |
| Application Authorization | 业务主体是否有权访问对象 | 业务服务或资源系统 | 最终资源系统仍应强制检查 |
OpenAI Agents SDK 的 Guardrails 提供了模型输入、输出和工具调用附近的检查点;LangGraph 的 Human-in-the-loop 用 interrupt/resume 把人工判断插入工作流;OPA Decision Logs 则强调记录决策输入、结果和上下文。这些机制可以组合:
Guardrail = 约束模型交互
Governance = 决定工具调用能否通过
Capability = 宿主授予的具体操作令牌
Approval = 把高风险判断交给人
Authorization = 资源系统确认主体是否有权
Audit = 保存决策证据
其中 Capability 需要多说几句。它不是传统 RBAC 里的“角色权限”,而是一个具体、短时、可验证的操作令牌。比如:
{
"capability": "file:write",
"scope": { "paths": ["/tmp/agent-session-xxx/"], "max_size": "10MB" },
"issued_by": "host",
"expires_at": "2026-08-20T10:30:00Z",
"task_id": "task-xxx"
}
Capability 由宿主/运行时根据任务上下文 mint,Agent 和 Skill 不能自己声明或扩大。它的好处是: compromised Agent 的爆炸半径被限制在单个 capability 范围内,不需要查中央权限表就能验证。第 5 章会进一步讲 Capability 与 Skill、Policy 的职责边界。
判断一个机制是不是控制层,标准只有一条:它能否在副作用发生之前改变执行结果。如果治理层只写日志,既不能放行、阻断也不能挂起任何工具调用,那它只是审计,不是执行控制。
HITL 不是简单弹窗,而是一个 durable 状态机。durable propose-then-commit 的通用模式我在 Agent 持久化设计 里展开过;第 4 章从 governance 视角补充审批 key、提案字段和模式选择这些内容。
3. Execution Governance 的通用设计
一个基本的执行控制层,可以拆成四个确定性阶段:识别 effect、解析 target、评估 policy、执行或暂停。具体而言我们可以:
- 设计一个工具上下文来将工具调用结构化;
- 根据工具上下文返回之后的动作;
- 将返回的动作转换成 agent 能理解的信号值。
工具上下文的设计
Agent Runtime 在执行工具前,通常已经能提供 tool_name、args、session_id、当前 workdir 等结构化信息。ToolContext 工具上下文将这些乱七八糟的原始输入规范化成 policy、approval 和 audit 可以共同消费的中间表示:
+-------------------------------------------------------+
| Runtime Hook / Tool Wrapper |
| (tool_name + args + workdir) |
+-------------------------------------------------------+
|
v
+-------------------------------------------------------+
| classify_tool() |
| - Normalize Name & Paths |
| - Parse Targets |
| - Classify Effect & Risk Level |
+-------------------------------------------------------+
|
v
+-------------------------------------------------------+
| ToolContext |
+-------------------------------------------------------+
| | |
v v v
+--------------------+ +-----------------+ +--------------------+
| policy.evaluate | | Approval Key | | Governance Event |
+--------------------+ +-----------------+ +--------------------+
这个中间表示至少要保存原始参数、抽象副作用、执行风险和其他的一些东西:
from dataclasses import dataclass
from enum import Enum
from typing import Any
class ToolEffect(str, Enum):
READ = "read"
WRITE = "write"
EXECUTE = "execute"
EXTERNAL_SEND = "external_send"
MANAGE = "manage_capability"
UNKNOWN = "unknown"
class RiskLevel(str, Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
@dataclass(frozen=True)
class ToolContext:
tool_name: str
args: dict[str, Any]
effect: ToolEffect
risk_level: RiskLevel
resources: tuple[str, ...]
protected_resources: tuple[str, ...]
@property
def protected(self) -> bool:
return bool(self.protected_resources)
从 Tool Call 到 ToolContext 的转换应在一个确定性的函数中完成:从参数和 command/code 中抽路径,将相对路径按 workdir 归一化,分类 effect/risk,最后检查路径是否落入 protected roots。
def classify_tool(tool_name: str, args: dict[str, Any], *, protected_paths=()):
name = (tool_name or "").lower()
command = str(args.get("command") or args.get("code") or "")
workdir = str(args.get("workdir") or args.get("cwd") or "")
# _paths 读取 path/file_path/target/destination,
# 对 terminal/python 还会从 command/code 中提取路径。
resources = tuple(dict.fromkeys(
canonicalize(path, workdir) for path in _paths(name, args)
))
if name in _MANAGE:
effect, risk = ToolEffect.MANAGE, RiskLevel.CRITICAL
elif name in _EXEC:
effect = ToolEffect.EXECUTE
risk = RiskLevel.HIGH if name == "execute_code" or _WRITE_CMD.search(command) else RiskLevel.MEDIUM
elif name in _WRITE:
effect, risk = ToolEffect.WRITE, RiskLevel.MEDIUM
elif _SEND.search(name):
effect, risk = ToolEffect.EXTERNAL_SEND, RiskLevel.HIGH
elif name in _READ or name.startswith(("get_", "list_", "search_", "read_")):
effect, risk = ToolEffect.READ, RiskLevel.LOW
else:
effect, risk = ToolEffect.UNKNOWN, RiskLevel.MEDIUM
protected = tuple(path for path in resources if _within(path, protected_paths))
if protected and effect in {ToolEffect.WRITE, ToolEffect.MANAGE, ToolEffect.EXECUTE}:
risk = RiskLevel.CRITICAL
return ToolContext(name, args, effect, risk, resources, protected)
后续逻辑就不需要重新解析原始 args 了:policy 根据 effect、risk_level 和 protected 返回动作;审计事件直接记录 action 和 rule;审批 key 用 tool_name + 资源 计算。这样同一次调用在三个下游环节使用的就是同一份事实了。
Tool 分类
工具的 effect 是对副作用的抽象,常见 effect 包括:
READ, WRITE, EXECUTE, EXTERNAL_SEND, MANAGE_CAPABILITY, UNKNOWN
_READ = {"read_file", "search_files", "web_search", "browser_navigate", "list_files"}
_WRITE = {"write_file", "patch", "apply_patch", "remove_file", "edit_file"}
_EXEC = {"terminal", "execute_code", "python", "shell", "computer"}
_MANAGE = {"skill_manage", "plugin_manage", "mcp_manage"}
_SEND = re.compile(r"(?:send|post|publish|reply|notify|email|message|webhook)", re.I)
_WRITE_CMD = re.compile(
r"(?:^|[;&|]\s*)(?:rm|mv|cp|install|mkdir|touch|chmod|chown|sed\s+-i|git\s+(?:push|commit|reset|clean)|tee)\b",
re.I,
)
这只是一层初始分类。实际代码还会从 terminal/python 的 command 或 code 中提取路径,并用 AST 识别 open(..., "w")、write_text()、unlink()、rename() 等间接写入。对于名称未知的 MCP 工具,结果会进入 unknown/approval 路径。
风险还应考虑可逆性、影响范围、是否跨出系统边界、是否改变 Agent 自身能力等因素。一个简单的决策矩阵如下:
| effect / 条件 | 风险倾向 | 常见动作 |
|---|---|---|
| 只读、范围受限 | low | allow + audit |
| 非受保护的局部写入 | medium | audit 或轻量审批 |
| 执行任意代码、外部发送、生产写入 | high | require approval |
| 修改 capability、插件、治理规则 | critical | deny 或 require approval |
| 无法判断 effect 或 target | unknown | require approval |
对 Tool 涉及资源对象的管理
然后我们需要对 target/resource 解析:把 Tool Call 中分散的路径、工作目录、命令参数,转换成规范化后的实际目标,再判断这些目标是否落在保护边界内。
这里的 resource 不只指“要读取的资源”,也包括要写入、删除、执行或发送到的目标。例如:
read_file(path="docs/spec.md")的 resource 是docs/spec.md;write_file(path=".hermes/skills/demo/SKILL.md")的 resource 是这个 Skill 文件;terminal(command="echo x > resources/spec/formulas/a.yaml")的 resource 是重定向后的 YAML 文件;- HTTP 工具的 resource 可以是 endpoint,数据库工具的 resource 可以是表或记录。
文件类工具通常从 path、file_path、target、destination、workdir 等字段取值;terminal/python 工具还需要从 command 或 code 中提取路径。
得到路径后不能直接拿字符串判断,因为相对路径、
..、软链接和路径前缀都会造成误判。
第一步是 canonicalize:根据工具调用的工作目录,把相对路径转换为绝对路径,并解析 .、.. 和已有的软链接:
from pathlib import Path
import os
def canonicalize(path: str, workdir: str) -> str:
expanded = os.path.expanduser(path)
if not os.path.isabs(expanded):
expanded = os.path.join(workdir, expanded)
return str(Path(expanded).resolve(strict=False))
第二步是 containment 检查:判断规范化后的目标是否位于某个 protected root 内:
def within(path: str, protected_roots: list[str]) -> bool:
candidate = canonicalize(path, workdir="")
for root in protected_roots:
canonical_root = canonicalize(root, workdir="")
try:
if os.path.commonpath([candidate, canonical_root]) == canonical_root:
return True
except ValueError: # 不同盘符等无法比较的情形
continue
return False
例如工作目录是 /repo/work,保护根目录是 /repo/.hermes/skills:
| 原始输入 | canonicalize 后 | 是否命中 protected root |
|---|---|---|
../.hermes/skills/demo/SKILL.md | /repo/.hermes/skills/demo/SKILL.md | 是 |
/repo/.hermes/skills/demo/SKILL.md | /repo/.hermes/skills/demo/SKILL.md | 是 |
/repo/.hermes/skills-old/demo.md | /repo/.hermes/skills-old/demo.md | 否 |
命中 protected resource 并不意味着一定拒绝;它表示这次访问不能按普通低风险操作自动放行。在当前 policy 中,受保护目标会进入 require_approval;如果工具本身属于 hard deny,则仍然直接 deny。如果路径无法可靠解析,也不能把“不知道目标”当成“没有目标”,而应进入 unknown/approval 路径。只有明确违反不可接受规则时才直接拒绝。
Policy 设计
执行策略通常只需要四类信息:受保护资源、明确禁止的工具、风险到动作的映射,以及未知情况的 fallback。例如:
schema: agent.guard-policy.v1
protected_paths:
- .hermes/skills
- plugins/agent_guard/policies
- resources/spec/formulas
hard_deny_tools:
- plugin_manage
rules:
low: allow
medium: audit
high: require_approval
critical: require_approval
unknown: require_approval
把 loaded Skill、复杂的嵌套条件和多 provider 合并塞进这份 policy,会让“谁最终做了决定”变得难以解释。复杂业务规则应在业务层表达,工具执行 policy 只负责工具执行边界。
Policy 的具体决策顺序也很重要:
- 先处理 hard deny,再读取风险规则;
- 如果命中 protected resource,则无论风险表原本写了什么,都提升为一次性审批;
- 未知 action 或 policy 文件读取失败时,最后回退到
require_approval。
def evaluate(context) -> tuple[str, str, str]:
policy = load_policy()
if context.tool_name in set(policy.get("hard_deny_tools") or ()):
return "deny", "hard-deny-tool", "tool disabled by local policy"
rules = policy.get("rules") if isinstance(policy.get("rules"), dict) else {}
action = str(
rules.get(context.risk_level.value)
or rules.get("unknown")
or "require_approval"
).lower()
if context.protected:
return "require_approval", "protected-resource", "explicit approval required"
if action not in {"allow", "audit", "require_approval", "deny"}:
action = "require_approval"
return action, f"risk-{context.risk_level.value}", ""
将 Policy 转换为执行信号
有了 Policy 的 action 后,就可以将这个和先前的 ToolContext 一起放回 Agent Loop 继续执行。下面这个是通过 hook 方式将 ToolContext 和 policy action 接到 Agent Loop 的简单实现:
def on_pre_tool_call(*, tool_name="", args=None, session_id="", task_id="", **_):
call_args = args if isinstance(args, dict) else {}
context = classify_tool(tool_name, call_args, protected_paths=protected_paths())
action, rule, reason = evaluate(context)
emit("governance.end", context=trace_context(session_id, task_id), payload={
"tool_name": context.tool_name,
"effect": context.effect.value,
"risk_level": context.risk_level.value,
"resources": list(context.resources),
"protected_resources": list(context.protected_resources),
"action": action,
"rule": rule,
"reason": reason,
})
if action in {"allow", "audit"}:
return None # Agent Runtime 继续执行原始工具调用
if action == "deny":
return {"action": "deny", "message": f"【治理阻断】{reason or rule}"}
return {
"action": "require_approval",
"message": reason or "Governance approval required",
"approval": {
"key": _approval_key(context.tool_name, context.resources),
"command": f"{context.tool_name}({json.dumps(call_args)[:500]})",
"allow_session": False,
"metadata": {"action": action, "rule": rule, "reason": reason},
},
}
这些返回的东西都会在 Agent Loop 中被使用和处理。
4. Human-in-the-loop:把审批变成 durable 状态机
为什么 durable HITL 不是模态框
2023 年常见的 HITL 是模态框:“我要给 X 发邮件,批准吗?”用户点 Approve。这个界面给人的感觉是“系统在人类控制下”,但实操中几乎必然变成 rubber-stamp:用户点击速度比阅读还快,审计日志里只有一长串自己都想不起来的“已批准”。
真正的高风险审批需要 durable propose-then-commit:把意图、数据 lineage、权限影响、爆炸半径、回滚方案和幂等键打包成一个提案,等人明确 commit,再把同一条 Tool Call 执行下去。
这个通用模式我在 Agent 持久化设计:从 durable execution 到 checkpoint 里已经展开过,包括 propose / surface / commit / verify 四阶段、PAUSED + checkpoint 的跨进程恢复、以及为什么“批准的是同一条 Tool Call,不是同一个意图”。这里不再复述,只从 governance 视角补充两点:审批 key 的设计,以及 HITL 在生产中的模式选择。
Cordum 总结的 HITL 五种生产模式 很有参考价值:
| 模式 | 适用场景 | 某制造企业 Agent 项目中的对应 |
|---|---|---|
| Pre-execution approval gate | 不可逆/高爆炸半径动作 | require_approval 的 tool call |
| Exception-based escalation | 默认自治,异常/低置信度才升级 | 物料清单的 pending_human 状态 |
| Graduated autonomy | 新代理从全监督开始,逐步晋升 | 可选的 approval mode 配置 |
| Sampled audit at scale | 高吞吐量下按风险加权抽样 | audit 动作 + 后期抽查 |
| Post-execution output review | 拦截 PII、幻觉承诺等输出问题 | receipt / 输出脱敏检查 |
审批 key 与提案字段
把 Tool Call 从执行路径中取出来保存为 pending 时,应该带上足够信息,让审批人在脱离原始对话上下文后仍能判断。一个最小提案至少包含:
@dataclass(frozen=True)
class ApprovalProposal:
key: str # 稳定标识 pending 调用
intent: str # 模型为什么要做这个调用
tool_name: str
args: dict[str, Any] # 原始参数
targets: tuple[str, ...] # 解析后的真实资源
effect: ToolEffect
risk_level: RiskLevel
permissions_touched: list[str] # 涉及哪些 capability/权限
blast_radius: str # 影响范围描述
rollback_plan: str # 如果执行错误如何回滚
idempotency_key: str # 重复提交时的去重键
session_id: str
task_id: str
审批 key 的生成方式:
def _approval_key(tool_name: str, resources: tuple[str, ...]) -> str:
value = json.dumps(
{"tool": tool_name, "resources": resources},
sort_keys=True,
)
digest = hashlib.sha256(value.encode()).hexdigest()[:24]
return f"agent-guard:{digest}"
这个 key 的职责只是标识等待审批的调用,不是审批结果本身。审批恢复时必须按原 args 执行同一条 Tool Call,否则人批准的内容和实际执行的内容就会不一致。PAUSED、checkpoint 与跨进程恢复的详细设计参见 Agent 持久化设计:从 durable execution 到 checkpoint。
receipt:跨宿主执行后的回执
当 Agent 只负责决定要调用什么工具,而另一个宿主进程负责真正执行时,宿主返回的不能只是一段 "command completed" 文本。它还需要返回结构化回执:
+--------------+ +-----------------+
| Nonoka Agent | | Host / Frontend |
+-------+------+ +--------+--------+
| |
| 1. External Tool Call |
|---------------------------------------------->|
| |
| |--+ Approve & Execute
| | |
| |<-+
| 2. ExternalToolReceipt |
| (result + exit_code + workspace) |
|<----------------------------------------------|
| |
|--+ resume_external_tools() |
| | Write results back to Tool Call |
|<-+ Resume Agent Loop |
| |
v v
receipt 在治理中的意义,是把“模型请求执行”与“宿主实际执行后的事实”连接起来;它不替代宿主的审批或沙箱。ExternalCapability、ExternalToolReceipt 和 resume_external_tools() 的完整协议流程可参见 从零开始的 Nonoka Agent 框架设计。
5. Capability、Skill 与 Policy 的职责边界
Skill 不能声明权限
这部分是针对我在开发过程中设计的在 Skill yaml frontmatter 中标记 Skill 相关权限的讨论,这个设计后面作废了。
Skill 通常是 markdown 文件,描述一段可复用的知识或流程。如果 Skill 本身可以声明“我需要 write_file 权限”或“我可以修改 protected path”,那么修改一段 markdown 就变相改变了 Agent 的授权边界。这是危险的。
正确的分层是:
| 层 | 职责 | 例子 |
|---|---|---|
| Skill | 知识和流程 | 怎么解析物料清单长描述、怎么调用某个 API |
| Capability | 运行时权限 | 能否写 .hermes/skills,能否执行 shell |
| Policy | 单次调用决策 | 这次 write_file 是否允许 |
Skill 可以请求加载,但 capability 必须由 host 显式授予;policy 再根据 capability、risk、target 决定单次调用能否执行。
Capability 的边界固定
Token Security 提出的 Credentials-to-Capabilities 范式 强调:能力应该是短时、任务绑定、带作用域的 token,而不是长期有效的 standing privilege。 compromised 代理的爆炸半径因此被限制在单个能力范围内。
以 nonoka-cli 举例, nonoka-cli 的 project agent role 会被编译成边界固定的 AgentTool。role 可以限制 model、prompt、tools、turn budget 和递归深度,但不能动态选择这些字段,更不能传入会改变权限的参数。这说明了 capability 的设计重点不是“给 Agent 更多工具”,而是让每个工具集合都带有明确的责任边界:一个子角色或扩展可以比父角色能力更少,但绝不能通过提示词、参数或 Skill 文本偷偷扩大自己的权限。
Skill 的版本化与 rollback
Skill 修改应该走版本控制。最小可行的版本管理就是 git:
修改 SKILL.md
↓
git diff / code review
↓
合并到主分支
↓
运行时读取当前 commit hash
↓
governance.end 事件记录 skill_version=hash
↓
需要 rollback → git checkout 旧版本 或 revert
如果 Skill 触及 protected path,修改前应该创建 git checkpoint;失败时按 author 过滤 rollback。
Padiso 关于 Agent 版本管理的文章 建议把 model + prompt + integrations + configuration 作为一个不可变快照版本化,并用 SemVer 管理;Skillware 论文 则进一步提出 Skill 是具有独立软件身份的工件,其 update/rollback 必须保持 unit identity。这两个观点可以结合:Git 管理文本版本,CI 构建时生成 hash,运行时把 hash 写进审计事件。
Policy 只读当前有效规则
Policy 文件是静态规则,运行时只读取当前生效版本,不维护第二套权限数据库。复杂业务规则(如物料清单授权等)应该在业务层表达,工具执行 policy 只负责“这次 tool call 能不能通过”。
6. 资源治理与业务授权
Agent 经常需要从文档、目录、数据库或业务规则中找候选结果。这里有两个不同的问题:资源读取和业务授权。
用户描述 → 实体与上下文
→ 受限 ResourceProvider
→ 候选集合 + evidence_refs
→ 业务规则与人工判断
→ authorized / pending_human
针对这个场景,ResourceProvider 就是一个受控的资源读取中间层:Agent/Skill 不直接操作硬盘路径,而是通过 logical id 向它请求资源,它负责检查、读取并返回带 provenance 的证据。例如下面这个简单的例子,ResourceProvider 提供一些读取的接口来让 Agent 进行安全的资源读取并返回额外的信息、而不是直接让 Agent 读源文件:
provider = ResourceProvider()
# 按 logical id 解析到实际文件,返回内容 + hash
provider.resolve("spec.catalog")
# 路径必须落在 resource_root 内,防止路径穿越
provider.path("spec.catalog")
# 按 markdown heading 分段读取
provider.section("spec.catalog", "## 某个章节")
# 全文搜索,最多返回 12 条
provider.search("某个参数", resource_ids=["spec.catalog"], limit=12)
每次返回都附带 provenance:
{
"id": "spec.catalog",
"path": "resources/spec/catalog.yaml",
"sha256": "a1b2c3...",
"mtime": 1724140800,
"size": 4096,
"content": "..."
}
ResourceProvider 的四个约束是同一层中间件的四种能力:用 logical id 代替真实路径、限制访问范围、限制读取粒度、记录版本证据。它的职责不是替业务做决定,而是让业务授权层拿到可审计的证据。
它和 ToolContext.resources 的区别:
ToolContext.resources | ResourceProvider | |
|---|---|---|
| 关注点 | 一次 Tool Call 的真实目标 | 业务决策所需的证据来源 |
| 输入 | tool_name + args | logical id + query |
| 输出 | canonical path 列表 | 候选集合 + evidence_refs |
| 例子 | write_file(path="...") 要写的文件 | spec.catalog 这份 baseline 文件 |
业务授权层拿到候选后,根据业务规则判断状态。返回下面的状态给外界:
{
"status": "authorized | pending_human",
"reason": "candidate_only",
"evidence_refs": [{ "resource_id": "spec.catalog", "sha256": "..." }]
}
这里的例子中,授权被实现为 authorized / pending_human 两层:
- authorized:规则命中、用户明确规格、或已发布 baseline 能唯一确定;
- pending_human:候选不足、身份未解析、来源冲突等需要人工判断的情况。
业务层内部可以保留更细的原因(如 explicit_identity、standard_rule、candidate_only 等),但工具执行 policy 只关心一件事:当业务 Runtime 最终调用 write_file 生成内容时,这次写入是否命中 protected path、风险等级是什么。
这样分层的好处是:业务规则变了只需要改业务 Runtime 的授权逻辑,工具执行 policy 不需要跟着变;反之,当 policy 升级了风险分级标准,业务授权层也不需要重写。这两个不同层级的东西解耦了、审计也比较方便。还是拿 write_file 这个工具调用举例:
-
业务规则变化:原来“命中 baseline 就算
authorized”,现在业务要求“命中 baseline 且用户明确确认过规格才算authorized”。这只影响业务 Runtime 里的授权逻辑,policy 不用动——policy 仍然只判断这次write_file是否命中 protected path、风险等级如何。 -
Policy 升级:原来
external_send是require_approval,现在安全要求更严,直接改为deny。这只影响 policy 的风险矩阵、也只需要修改一下对应的 yaml 即可,业务 Runtime 里的“候选是否授权”逻辑完全不受影响。
7. Governance 的可观测性
数据的分类
可以将任务状态、规则快照、Skill 版本、审批记录和审计日志这些数据分成下面的四类存储:
| 数据 | 真正职责 | 适合的存储 | 为什么 |
|---|---|---|---|
| task / step / artifact / delivery | 业务工作流的当前状态 | SQLite / 业务数据库 | 经常变,需要重试和更新 |
| Skill、baseline、策略文件 | 可审查的版本事实 | Git / 发布资源 | 不可变,需要 rollback 和 audit |
| allow / approval / deny / receipt | 某次执行的决策证据 | JSONL / append-only 事件流 | 只能追加,不能改不能删 |
| trace 与观测指标 | 跨组件关联和诊断 | Langfuse / OTel / 自研 sink | 用于排查,不替代审计 |
一次任务中的四类数据
假设 Agent 接到任务 task-20260820-001:根据用户描述生成一份交付物,那么:
- 业务状态。业务 Runtime 记录 task 和 step 的当前状态:
{
"task_id": "task-20260820-001",
"input_hash": "sha256:abc...",
"status": "delivered",
"artifact_id": "artifact-001",
"artifact_checksum": "sha256:def...",
"delivery_id": "delivery-001"
}
它回答的问题是:任务跑完了吗?产物对不对?交付了吗?
- 版本事实。Agent 在生成过程中读取了一份 baseline 文件
resources/spec/baseline.yaml。这份文件由业务人员维护,提交到 Git:
{
"resource_id": "spec.baseline",
"path": "resources/spec/baseline.yaml",
"git_commit": "a1b2c3d4",
"sha256": "sha256:ghi..."
}
它回答的问题是:Agent 当时依据的是哪一版规则?两个月后审计时,可以
git show a1b2c3d4:resources/spec/baseline.yaml还原。
- 决策证据。每次 governance 决策写一条事件:
{
"trace_id": "task-20260820-001",
"tool_name": "write_file",
"effect": "write",
"risk_level": "medium",
"action": "audit",
"reason": "risk-medium-not-protected",
"evidence_refs": [
{ "resource_id": "spec.baseline", "git_commit": "a1b2c3d4" }
]
}
它回答的问题是:这次 tool call 被怎么处理了?依据是什么?事件不保存 baseline 内容,只通过
evidence_refs引用 Git 中的版本。
- trace 与观测。用于把一次 task 跨多个 session、工具调用和交付事件串起来,方便排查问题。
三类核心数据的更新频率和一致性要求完全不同:
- 业务状态经常变,需要支持重试、更新;
- 版本事实一旦写入就不该变,Git 已经提供了不可变性;
- 决策证据必须是 append-only,不能更新或删除。
如果把它们塞进同一张表,比如早期的 policy_snapshots,业务状态更新时要动它,Git 版本变更时也要动它,记录治理事件时还要动它。最终这张表既像业务表又像审计表,查询时很难说清楚某个字段到底代表什么。
正确的做法是让它们互相引用,但独立存储:
业务 task 表 Git 版本库 审计事件流
│ │ │
│ artifact_id │ git_commit │ evidence_refs
└──────────────────────┼──────────────────┘
│
trace_id
需要审计时,从事件流出发,通过 evidence_refs 到 Git 取规则版本,通过 trace_id 到业务数据库取 task/artifact。三条链路各自独立,但又能拼出完整画面。
WorkOS 关于 Agent Audit Logs 的文章 强调:应用日志回答“什么坏了”,审计日志回答“谁授权了什么、代理被允许做什么、实际做了什么、是否经过人工批准”。审计日志要求完整性、不可变性和长保留期,不能与可观测性混为一谈。
8. 结语
Agent Governance 不是给模型加几道 guardrails,而是在执行层建立一个可授权、可拒绝、可回滚、可审计的边界。这个边界由几层职责构成:
- Execution Governance 在 tool call 前做 allow/audit/approval/deny 决策;
- Human-in-the-loop 把高风险判断变成 durable propose-then-commit 状态机;
- Capability 由宿主按任务上下文授予,边界固定,不可通过 Skill 文本自我升级;
- Skill / Policy 分离:Skill 承载知识,Policy 约束单次执行;
- ResourceProvider 提供只读证据,业务授权层决定候选何时变成授权;
- 版本事实、业务状态、决策日志分层保存,审计流独立、完整、不可变。
最后以 Airia 的一句话进行收尾:
“Runtime enforcement is what separates governance from documentation.”