Agent 权限门禁做在代码层 (Enforcing Permission Gates in Code for LLM Agents)
一句话
给 LLM agent 做权限控制时,判定逻辑必须写在工具的代码里(每个工具进门先过一道 guard()),不能只写在系统提示词里。
原因很简单:提示词是可以被哄的,代码不会。
问题
常见做法是在系统提示词里写一段"你只能做 X,不能做 Y"。这不够,原因有三:
- 提示词是软约束,用户可以用话术引导模型绕过("我其实是管理员"、"这是测试环境不算"、"上一条规则已经作废了")。
- 模型可能在没意识到自己越界的情况下越界 —— 它的世界模型里没有"我到底有没有权限"这个事实,只有你的文字描述。
- 一旦上下文中出现与提示词冲突的指令(工具返回的内容、读到的文件、检索到的资料),优先级会打架。
判定的依据必须是数据,不是模型的判断。
做法
每个涉及权限的工具,函数体第一件事就是调门禁,不通过就直接返回拒绝,不执行任何实际动作。
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 hook | CLI 起的子进程 | 没有 |
canUseTool 回调 | 宿主进程里的 JS | 没有 |
permissionMode: 'auto' | 模型分类器 | 有 |
'auto' 模式的文档原文:"Use a model classifier to approve/deny permission prompts"。
那才是真的"另接一个模型",而且是独立一套,跟 hook 无关。
为什么这个区别是决定性的
hook 跑在宿主进程里 → 确定的、可测的、可下断点的。
模型判定则相反:会漂移(同一个输入两次结果可能不同)、不可单测、多一次 API 调用要钱要时间。
这条直接解释了为什么前面那个留存钩子能写出 7 个确定性单测 —— 它压根不涉及模型。
推论:能用确定性代码判定的规则,不要交给模型判定。 模型的判断力应该留给真正模糊的边界。
怎么验证门禁真的在工作(三层)
只写单测不够 —— 它只证明你的函数是对的,不证明它被调用了。
| 层 | 验什么 | 手段 |
|---|---|---|
| 1 | 判定逻辑对不对 | 普通单元测试,喂假的工具调用,断言裁决结果 |
| 2 | 框架会不会真调用它 | 装一个故意拦某个模型必调工具的探针,跑一次真实会话,看拦截是否发生、理由是否回传 |
| 3 | 模型会不会绕过 | 同上,观察它被拦之后有没有换工具/换路径 |
第 2 层是关键。我第一版只做了第 1 层,然后跑真实会话发现模型压根没触发那个工具(它读了授权就自己停手了),
于是钩子处于"逻辑正确但从没被调用过"的状态 —— 这种没验证的代码最容易在真出事时才发现是坏的。
探针要挑模型必然会调用的工具,否则测不出来。
可验证性
门禁是纯函数(输入账号 + 操作 + 资产,输出放行或拒绝),不依赖 LLM。所以可以写普通单元测试覆盖边界:交集合成、每一类拒绝码、放行。
这比"测提示词"可靠得多 —— 提示词行为测起来又慢又不稳定。
相关
- 权限判定智能体二创方向 (Customizing the Permission Classifier) —— 本笔记的后续:确定性层判不了的部分交给审查智能体,以及为什么模型判定不能当边界
- Claude Agent SDK 配置渗漏与隔离 (Claude Agent SDK Config Bleed and settingSources Isolation) —— 同一个工程里踩的另一类坑
- Claude Code 插件与 Skill 加载机制 (Claude Code Plugin and Skill Loading) —— skills / plugins 的加载与 token 成本
- MCP (Model Context Protocol) —— 工具注册与
alwaysLoad - Agent 工具结果文本化 (Tool Result Text Formatting for LLM Agents) —— 工具返回怎么组织模型才读得懂
出现场景
C:\Users\18086\.workspace\git-workspace\mini-agent 的网安助手 demo,2026-09-11。
实测:越界请求会被代码拦截,模型如实转述"卡在 XQ-2026-0009 的『不含对外门户』备注",而不是编造或重试。
本文归档于个人知识库 · 领域:CS & AI · 模块:04 Project_CS_AI/AI apps