
最近在给一个客服 AI Agent 做安全加固,踩了几个权限控制的坑,这篇把核心问题说清楚。
大多数 Agent 的起步都是一样的:给它配一个服务账号,绑定到系统上,再写几个工具调用,基本就能跑了。
但问题在于,你实际上造出了一个"能做任何用户能做的事"的组件,唯一驱动它的是语言模型,而它读取的内容可能来自工单、网页或者用户上传的文档。所以"Agent 什么都能做"到"Agent 做了不该做的事"之间,只差一段来自不可信输入的劝说性文字。
解决方案不是写更好的提示词,而是让 Agent 从一开始就不该拥有那些权限。
Agent 的能力必须是它所代理用户的能力子集。不能是超集,不能是另一套权限。
如果用户小李在前台界面上无法退款别人的订单,那替小李干活的 Agent 也不行。不是因为提示词说了不行,而是因为工具压根没有对应的凭证。
把权限从"角色字符串"变成具体的值结构,好处立竿见影:
export type Capability = | { kind: "orders.read"; userId: string } | { kind: "orders.refund"; userId: string; maxCents: number } | { kind: "email.send"; fromAddress: string; toDomains: readonly string[] } | { kind: "docs.search"; docSets: readonly string[] }; export type Caps = readonly Capability[]; export function capsFor(user: User, session: Session): Caps { const caps: Capability[] = [ { kind: "orders.read", userId: user.id }, { kind: "docs.search", docSets: user.entitledDocSets }, ]; if (user.roles.includes("support")) { caps.push({ kind: "orders.refund", userId: user.id, maxCents: 10_000 }); caps.push({ kind: "email.send", fromAddress: `${user.id}@support.acme.com`, toDomains: ["acme.com"] }); } if (session.elevated) { // 增强认证扩大上限,且仅限本次会话 caps.push({ kind: "orders.refund", userId: user.id, maxCents: 100_000 }); } return caps;
} 两个好处远超角色字符串:一是 maxCents 是具体的数值,不同用户、不同套餐、不同会话都可以不一样;二是 capability 自带 userId,这样工具就算被模型忽悠了也没法冒充别人操作。
另外,能力在每次运行开始时一次性算出,整个对话过程中就不会漂移——防止模型聊着聊着自己给自己加了权限。
function requires<K extends Capability["kind"]>( caps: Caps, kind: K,
): Extract<Capability, { kind: K }> { const c = caps.find((x) => x.kind === kind); if (!c) throw new NotPermitted(kind); return c as Extract<Capability, { kind: K }>;
} const refundOrder = tool({ name: "refund_order", schema: z.object({ orderId: z.string(), amountCents: z.number().int().positive() }), async run({ orderId, amountCents }, ctx) { const cap = requires(ctx.caps, "orders.refund"); if (amountCents > cap.maxCents) { throw new NotPermitted( `refund ${amountCents} exceeds this session's limit of ${cap.maxCents}`); } const order = await db.order.findUnique({ where: { id: orderId } }); if (!order) throw new NotFound("order"); if (order.userId !== cap.userId) throw new NotPermitted("not this user's order"); return refunds.create(order.id, amountCents, { actor: cap.userId }); },
}); 关键点:所有权检查用的是 cap.userId,而不是从模型参数里拿到的 ID。这个区别就是全部的控制逻辑——参数是可能被攻击者操纵的,而 capability 不是。
写入时带上 actor: cap.userId,审计日志里记录的是真实用户而不是服务账号,这样事后溯源才有可能。
模型根本不该看到它用不了的东西。
export function toolsFor(caps: Caps) { return ALL_TOOLS.filter((t) => t.requires.every( (k) => caps.some((c) => c.kind === k)));
} const res = await client.messages.create({ model: MODEL, tools: toolDefs(toolsFor(ctx.caps)), messages,
}); 三个好处:模型不会尝试它看不到的操作,减少了"拒绝但说不出原因"的情况;上下文更小、调用更便宜;而且如果日志里出现对未列出工具的调用,这是个强信号——正经模型不会自创工具名,得查。
但服务端校验还是要留。过滤是体验优化,不是安全边界。
最彻底的做法是把作用域控制下沉到代码之下,就算上面的检查全漏了也穿不透边界。
export async function withUserDb<T>( cap: { userId: string; tenantId: string }, fn: (db: Client) => Promise<T>,
): Promise<T> { const client = await pool.connect(); try { await client.query("BEGIN"); await client.query("SET LOCAL app.user_id = $1", [cap.userId]); await client.query("SET LOCAL app.tenant_id = $1", [cap.tenantId]); const out = await fn(client); await client.query("COMMIT"); return out; } catch (e) { await client.query("ROLLBACK"); throw e; } finally { client.release(); }
} 配合行级安全策略,即使工具把所有权检查忘了,也读不到其他用户的数据。关键是用 SET LOCAL——事务内的 session 级别设置才能隔离,如果是 SET 绑在连接池上,会漏给下一个租户。
出站 API 同理:现用现签一个带用户作用域的短效令牌,别复用长期有效的服务密钥。
如果主 Agent 要派生子 Agent,把同样的 capability 传下去:
export function spawn(parent: Ctx, task: string, subset: Caps) { const invalid = subset.filter((c) => !parent.caps.includes(c)); if (invalid.length) throw new Error("sub-agent cannot exceed parent caps"); return runAgent(task, { ...parent, caps: subset });
} 这个检查堵死了通过委托提权的路。收窄是可以的,也往往是明智的——比如研究子 Agent 只给 docs.search,其他权限一概不给。
catch (err) { if (err instanceof NotPermitted) { return errorResult(block.id, `Not permitted: ${err.message}. Do not retry. Tell the user this ` + `requires approval from someone with higher access.`); } throw err;
} "不要重试"防止死循环。告诉模型用户接下来该怎么做,把死胡同变成有用的回复,而不是让 Agent 道歉三遍然后放弃。
不过注意别泄露太多。"订单 bk_123 不可用"比"那个订单属于其他用户"好——后者确认了记录存在,是通过错误字符串做的信息泄露。
logger.info("agent_capability_check", { runId: ctx.runId, actorUserId: ctx.userId, tool: block.name, granted: ok, reason: ok ? undefined : err.message, sourcesInContext: ctx.retrievedDocIds,
}); granted: false 的事件才是要告警的。一个客服 Agent 在某次运行中试图退 500 万分钱的单,这不是它自己抽风,是上下文里有东西让它申请的。sourcesInContext 能告诉你调用发生时窗口里有哪些文档。
六条检查:
以上每一条都不依赖模型"表现良好"。这才是关键——其他所有防御机制都建立在"模型可以被说服"这个假设上,而这个模型假设它已经被说服过了。