site logo

Marico's space

上线前你需要的 AI Agent 权限模型

前端技术 2026-08-07 11:28:49 2

最近在给一个客服 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 要派生子 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 能告诉你调用发生时窗口里有哪些文档。

拒绝的能力尝试被记录,同时记录当时上下文中的文档来源。

上线前的检查清单

六条检查:

  • 能力在运行开始时从已认证用户派生
  • 工具强制检查能力,而不是信任参数
  • 所有权检查对照 capability,而不是模型输入
  • 工具列表过滤到有权限的那些
  • 凭证按请求作用域,不全局
  • 拒绝尝试要审计,连同产生它的上下文一起

以上每一条都不依赖模型"表现良好"。这才是关键——其他所有防御机制都建立在"模型可以被说服"这个假设上,而这个模型假设它已经被说服过了。