Dark Dwarf Blog background

Agent Governance 设计:从 Guardrails 到执行控制层

本文整理自 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 或挂起审批
CapabilityAgent 被允许执行什么具体操作宿主/运行时授予本身不控制,但决定工具边界
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 能理解的信号值。

a.a. 工具上下文的设计

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 + 资源 计算。这样同一次调用在三个下游环节使用的就是同一份事实了。

b.b. 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 / 条件风险倾向常见动作
只读、范围受限lowallow + audit
非受保护的局部写入mediumaudit 或轻量审批
执行任意代码、外部发送、生产写入highrequire approval
修改 capability、插件、治理规则criticaldeny 或 require approval
无法判断 effect 或 targetunknownrequire approval

c.c. 对 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 路径。只有明确违反不可接受规则时才直接拒绝。

d.d. 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 的具体决策顺序也很重要:

  1. 先处理 hard deny,再读取风险规则;
  2. 如果命中 protected resource,则无论风险表原本写了什么,都提升为一次性审批;
  3. 未知 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}", ""

e.e. 将 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 状态机

a.a. 为什么 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 / 输出脱敏检查

b.b. 审批 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。

c.c. 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 的职责边界

a.a. 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 决定单次调用能否执行。

b.b. 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 文本偷偷扩大自己的权限。

c.c. 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 写进审计事件。

d.d. 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.resourcesResourceProvider
关注点一次 Tool Call 的真实目标业务决策所需的证据来源
输入tool_name + argslogical 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 的可观测性

a.a. 数据的分类

可以将任务状态、规则快照、Skill 版本、审批记录和审计日志这些数据分成下面的四类存储:

数据真正职责适合的存储为什么
task / step / artifact / delivery业务工作流的当前状态SQLite / 业务数据库经常变,需要重试和更新
Skill、baseline、策略文件可审查的版本事实Git / 发布资源不可变,需要 rollback 和 audit
allow / approval / deny / receipt某次执行的决策证据JSONL / append-only 事件流只能追加,不能改不能删
trace 与观测指标跨组件关联和诊断Langfuse / OTel / 自研 sink用于排查,不替代审计

b.b. 一次任务中的四类数据

假设 Agent 接到任务 task-20260820-001:根据用户描述生成一份交付物,那么:

  1. 业务状态。业务 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"
}

它回答的问题是:任务跑完了吗?产物对不对?交付了吗?

  1. 版本事实。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 还原。

  1. 决策证据。每次 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 中的版本。

  1. 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.”