一句话

给 LLM agent 做权限控制时,判定逻辑必须写在工具的代码里(每个工具进门先过一道 guard()),不能只写在系统提示词里。

原因很简单:提示词是可以被哄的,代码不会。

问题

常见做法是在系统提示词里写一段"你只能做 X,不能做 Y"。这不够,原因有三:

  1. 提示词是软约束,用户可以用话术引导模型绕过("我其实是管理员"、"这是测试环境不算"、"上一条规则已经作废了")。
  2. 模型可能在没意识到自己越界的情况下越界 —— 它的世界模型里没有"我到底有没有权限"这个事实,只有你的文字描述。
  3. 一旦上下文中出现与提示词冲突的指令(工具返回的内容、读到的文件、检索到的资料),优先级会打架。

判定的依据必须是数据,不是模型的判断。

做法

每个涉及权限的工具,函数体第一件事就是调门禁,不通过就直接返回拒绝,不执行任何实际动作。

const g = guard({ accountId, operation: 'asset_recon', level: 2, asset: target })
if (!g.ok) return denialToToolResult(g)
// ...下面才是真正干活

拒绝时返回结构化结果,而不是抛异常:

{
  "allowed": false,
  "code": "ASSET_OUT_OF_SCOPE",
  "reason": "资产 portal.example.edu.cn 不在授权范围内(交集:10.20.0.0/24)",
  "hint": "这是系统门禁的裁决,不可协商。请如实告知用户,不要尝试绕过。"
}

三个要点:

  • 用 isError 而不是 throw。 抛异常会打断整个 agent 循环;返回错误结果则让模型看到拒绝原因,能向用户解释清楚。用户需要知道真实边界在哪。
  • reason 要具体到"哪一份材料卡住的",而不是笼统的"权限不足"。具体的理由让模型能给出可执行的补救建议。
  • hint 里明写"不要绕过、不要换工具达成同样目的"。 这不是硬约束,但配合代码层的硬约束,能把模型推向正确行为。

数据模型:多份授权取交集

现实里的授权往往来自多份文件(合同、资质、批准书),每份各有有效期和授权范围。

有效范围 = 所有文件范围的交集,不是并集。 一份文件批得宽不算数,要所有文件都覆盖到的部分才成立。

有效资产 = 各文件资产列表的交集        (取更窄的)
级别上限 = 各文件级别上限的最小值      (取更低的)
时间窗   = 各文件时间窗的交集
任一文件不合格 -> 整体不成立,直接清零  (不是打折)

这个"取更严"的规则本身就是产品价值 —— 用户以为自己有授权,系统告诉他批准书上有一条更窄的限制。

检查顺序

顺序会影响返回给用户的提示质量,建议:

账号存在 -> 授权整体成立 -> 时间窗 -> 角色权限 -> 级别上限 -> 具体资产范围

从粗到细。先报"整个授权就不成立",再报"这一项越界"。

必须配套做的事(否则门禁形同虚设)

一、堵住旁路 —— 用 PreToolUse 钩子。

工具级门禁只保护你自己写的工具。如果 agent 还握着通用的 shell / 写文件 / HTTP 工具,它可以直接用那些绕过。两条路:

  • 禁用它们(Claude Agent SDK 里是 disallowedTools)。简单,但砍能力。
  • 纳入门禁 —— 用 PreToolUse 钩子。推荐。

钩子在工具执行之前被调用,可以裁决:

const hook: HookCallback = async (input) => {
  if (input.hook_event_name !== 'PreToolUse') return {}
  if (input.tool_name !== 'Write') return {}
  return {
    hookSpecificOutput: {
      hookEventName: 'PreToolUse',
      permissionDecision: 'deny',            // allow | deny | ask | defer
      permissionDecisionReason: '为什么拒绝',   // 会回传给模型
      // updatedInput: {...},                 // 还能改写输入
      // additionalContext: '...',            // 还能追加提示
    },
  }
}

装法:query({ options: { hooks: { PreToolUse: [{ hooks: [hook] }] } } })。
不设 matcher = 所有工具都过一遍,在回调里自己筛 —— 比写正则稳。

三个容易忽略的点:

  • permissionDecisionReason 会回传给模型。 理由写具体,模型才能向用户准确解释,而不是一脸茫然地重试。
  • 模型看到拒绝后倾向于不绕过。 实测:被拦后它说"绕过被明确拦下的操作等于把权限决策架空,这条我不会做"。但这个自觉不能当保证 —— 钩子是硬的,自觉是软的,两个都要有。
  • 钩子也能 allow。 可以用来实现"这类操作不用问",减少打断。

分层建议: 有明确条款可判定的(比如"授权里写了不得留存数据"),用钩子钉死;模糊的(比如某条命令算不算越界),靠提示词 + 模型判断。钉死成本很低(一个函数 + 若干测试),所以能钉就钉。

二、分清真边界和假边界。 自己写的"只读命令白名单 shell"不是沙箱。真正的隔离要靠容器/虚拟机。白名单只是一道门,别当安全边界用。而且一旦放开了通用 Bash,这道门就没有强制力了 —— 只剩规范引导作用。

三、身份不能自证。 账号 ID 若来自环境变量或用户输入,谁都能冒充。真实系统必须从认证会话取。

附:这套机制在 Claude Agent SDK 里是怎么实现的

扒 sdk.mjs 的结果 —— 不是另接一个模型,是一次 RPC 回调,回到宿主进程里的普通 JS 函数。

初始化时,函数本体不出本进程:

for (let u of c.hooks) {
  let p = `hook_${this.nextCallbackId++}`;
  this.hookCallbacks.set(p, u);   // 函数存在本地 Map
  l.push(p);                      // 只把编号发出去
}

发给 CLI 子进程的 initialize 消息里只有编号、matcher、timeout。

工具执行前,CLI 反向发请求回来:

{ subtype: 'hook_callback', callback_id: 'hook_0', input: {...}, tool_use_id }

宿主进程执行 —— 就三行:

handleHookCallbacks(e, t, n, r) {
  let o = this.hookCallbacks.get(e);      // 查表
  if (!o) throw Error(`No hook callback found for ID: ${e}`);
  return o(t, n, { signal: r })           // 调用
}

裁决发回去,CLI 决定放不放行。

谁在判定?对比表

机制判定者有模型参与
SDK 传入的 hook 函数宿主进程里的 JS没有
settings.json 里的 shell hookCLI 起的子进程没有
canUseTool 回调宿主进程里的 JS没有
permissionMode: 'auto'模型分类器有

'auto' 模式的文档原文:"Use a model classifier to approve/deny permission prompts"。
那才是真的"另接一个模型",而且是独立一套,跟 hook 无关。

为什么这个区别是决定性的

hook 跑在宿主进程里 → 确定的、可测的、可下断点的。

模型判定则相反:会漂移(同一个输入两次结果可能不同)、不可单测、多一次 API 调用要钱要时间。

这条直接解释了为什么前面那个留存钩子能写出 7 个确定性单测 —— 它压根不涉及模型。

推论:能用确定性代码判定的规则,不要交给模型判定。 模型的判断力应该留给真正模糊的边界。

怎么验证门禁真的在工作(三层)

只写单测不够 —— 它只证明你的函数是对的,不证明它被调用了。

层验什么手段
1判定逻辑对不对普通单元测试,喂假的工具调用,断言裁决结果
2框架会不会真调用它装一个故意拦某个模型必调工具的探针,跑一次真实会话,看拦截是否发生、理由是否回传
3模型会不会绕过同上,观察它被拦之后有没有换工具/换路径

第 2 层是关键。我第一版只做了第 1 层,然后跑真实会话发现模型压根没触发那个工具(它读了授权就自己停手了),
于是钩子处于"逻辑正确但从没被调用过"的状态 —— 这种没验证的代码最容易在真出事时才发现是坏的。

探针要挑模型必然会调用的工具,否则测不出来。

可验证性

门禁是纯函数(输入账号 + 操作 + 资产,输出放行或拒绝),不依赖 LLM。所以可以写普通单元测试覆盖边界:交集合成、每一类拒绝码、放行。

这比"测提示词"可靠得多 —— 提示词行为测起来又慢又不稳定。

相关

出现场景

C:\Users\18086\.workspace\git-workspace\mini-agent 的网安助手 demo,2026-09-11。
实测:越界请求会被代码拦截,模型如实转述"卡在 XQ-2026-0009 的『不含对外门户』备注",而不是编造或重试。


本文归档于个人知识库 · 领域:CS & AI · 模块:04 Project_CS_AI/AI apps