Aklman · Library

16 / 第十六章

身份与鉴权 · 让 agent 以自己的身份替人行动.

企业里的第一个问题不是「它能做什么」,是「它以谁的名义在做」—— agent 需要自己的身份、一份受限的委托,而不是你的万能钥匙。


到上一章为止,你的研究助手一直用一把 ANTHROPIC_API_KEY 加一台机器的全部权限在跑 —— 本地开发这没问题。企业篇从这里开始:当它要在一家公司里、在别人的数据上行动,第一个要回答的问题不是「它能做什么」,是「它以谁的名义在做」。这一章给它一个自己的身份、一份受限的委托、和一张工具级的权限表 —— 全部落在工程里,能跑。 先把「企业级」这个词说清。企业级 agent 和「能跑的 agent」的差距,不是更强的模型,而是一整层工程:让它可信地、规模化地、在别人的数据上、留得下审计地行动。这一层有八根支柱 —— 身份鉴权、带权限的检索、对抗性安全、治理审计、常设评测、失败工程、控制平面、多租户。前三根各占一章(本章与接下来两章),其余的已经深化进第 4、10、11 章;最后的 capstone 把八根收成一个可交付的工程。

I

一把 API key 走天下,死在企业门口.

个人项目里,agent 用你的 key、你的 cookie、你的数据库账号 —— 它就是你。企业里这个「就是你」恰恰是问题本身。 三个死法。归因死:审计日志里全是你的名字,没人分得清哪次查询是你点的、哪次是 agent 自己决定的 —— 出了事故无法追责,也无法只关掉 agent 的那部分。爆炸半径死:你的凭据能做的一切,被注入劫持的 agent 也能做 —— 一把万能钥匙意味着最坏情况就是全部。离职死:agent 挂在某个员工的账号下,那人离职销号,生产里的 agent 一起停摆。OWASP 的 Agentic Top 10 把这类问题单列成一条 ——ASI03「Identity and Privilege Abuse」。5注 5OWASP · Top 10 for Agentic Applications 2026(2025-12-09 发布)—— ASI03 即「Identity and Privilege Abuse」:agent 的身份与权限被滥用,是官方清单里单列的一条。以官方 PDF 为准。 解法的形状是确定的:agent 要有自己的 workload identity —— 像一个服务、而不是像一个人那样存在。这不用发明新轮子:SPIFFE 就是「在动态异构环境里安全标识软件系统」的开源标准,workload 通过 API 拿到短命的身份文件(SVID)去互相认证。4注 4SPIFFE —— 「Secure Production Identity Framework for Everyone,一组在动态异构环境里安全标识软件系统的开源标准」。workload identity 的既有标准:workload 通过 API 拿到短命的 SVID 身份文件去互相认证 —— agent 的「自己的身份」不必发明新轮子。在云上,对应物是 service account / managed identity。要点只有一个:agent 的身份独立于任何员工的身份存在,可以单独授权、单独吊销、单独审计。

II

委托,不是冒充.

agent 有了自己的身份,第二个问题接上:它替用户干活时,是「变成」那个用户,还是「代表」那个用户?这两个词在标准里有精确的分界。 RFC 8693(OAuth 2.0 Token Exchange)的原文把线画得很清楚:冒充(impersonation)时,「A 在该上下文里与 B 不可区分」;委托(delegation)时,「A 仍保有自己独立于 B 的身份 —— 明确的是 A 在代表 B 行动」。委托靠 token 里的 act(actor)声明表达:「委托已经发生,行动方是谁」。1注 1IETF · RFC 8693「OAuth 2.0 Token Exchange」—— act(actor)声明「表达委托已经发生,并标识被委托了权限的行动方」;委托与冒充的区分原文:冒充时「A 在该上下文里与 B 不可区分」,委托时「A 仍保有自己独立于 B 的身份,明确的是 A 在代表 B 行动」。对 agent,答案几乎总是委托 —— 审计里要能同时看到两个名字:替谁干(用户),谁在干(agent)。第一节的三个死法,全靠这两个名字分开来救。 协议层面,这套东西正在标准化成型。OAuth 2.1 把十年的安全最佳实践收编成一份规范(PKCE 全面要求、去掉 implicit flow)—— 注意它截至 2026-03 还是活跃草案,不是 RFC,引用要按草案对待。2注 2IETF OAuth WG · draft-ietf-oauth-v2-1 —— OAuth 2.1 把 OAuth 2.0 的安全最佳实践收编成一份规范(PKCE 全面要求、去掉 implicit 等)。注意它截至 2026-03 仍是活跃草案(-15,2026-03-02),尚未成为 RFC —— 引用时按草案对待。而最能说明「agent 世界怎么用它」的一手样本是 MCP:MCP 的授权规范整体是可选项 ——stdio 传输被建议直接从环境取凭据;一旦走 HTTP 并启用授权,措辞立刻变硬:授权服务器 MUST 实现 OAuth 2.1,客户端 MUST 用 PKCE、MUST 带 RFC 8707 的 resource 参数把 token 绑定到目标服务器;MCP 服务器 MUST 校验 token 是发给自己的,且 MUST NOT 接受或转传任何其他 token3注 3MCP 规范(2025-11-25)· Authorization —— 授权对 MCP 是可选项;HTTP 传输「SHOULD 遵循本规范」、stdio 传输「SHOULD NOT,改从环境取凭据」。采用时:授权服务器「MUST 实现 OAuth 2.1」(基于 draft-ietf-oauth-v2-1-13),客户端 MUST 实现 PKCE、MUST 带 RFC 8707 resource 参数;MCP 服务器「MUST 校验 token 是专门发给自己的」且「MUST NOT 接受或转传任何其他 token」。截至 2026-07。最后这条禁令背后就是下一章的主角 ——confused deputy。 落到工程里,我们给研究助手加一格 authz.py:Principal 记三件事 —— agent 自己是谁(agent_id)、替谁干活(on_behalf_of)、属于哪个租户(tenant_id)。这就是委托语义的最小载体:
@dataclass(frozen=True)
class Principal:
    agent_id: str = "research-agent@local"
    on_behalf_of: str = "dev@local"   # 委托:替 TA 干活,但不是 TA(RFC 8693 act 语义)
    tenant_id: str = "local"
    scopes: frozenset[str] = frozenset({"search:read", "web:read", "sandbox:exec"})
 
@dataclass
class TokenBroker:
    """教学版:真实世界这是一次 OAuth token exchange(RFC 8693)。
    这里只保住两条不变量 —— 短命 + 只降权。"""
    ttl_s: float = 300.0
    def mint(self, principal: Principal, scopes: set[str]) -> dict:
        granted = sorted(scopes & set(principal.scopes))   # 交集 = 只能降权
        return {"token": "tk-" + secrets.token_hex(8),
                "act": principal.agent_id,     # 谁在替人行动(可归因)
                "sub": principal.on_behalf_of, # 替谁行动
                "scopes": granted,
                "expires_at": round(time.time() + self.ttl_s, 1)}

authz.py(工程里可跑的真代码):Principal 与只降权的 token broker

III

每个工具一个 scope,密钥不进上下文.

第三章说过「工具的 schema 就是它的权限」—— 那是能力面。企业里还差身份面:同一个工具,这个身份能不能用。两张表相乘,才是完整的权限模型。 工程里这是一张 TOOL_SCOPES 表加一个 check_scope():每个工具登记一个 scope,未登记的默认拒绝;循环在执行任何工具前先查它 —— 排在第九章那道白名单闸的前面。研究助手只有三个只读/沙箱 scope(search:readweb:readsandbox:exec),一个只拿到前两个的 Principal 跑起来,会看到 run_python 被当场拒绝、拒绝原因进审计。这就把第九章「最小授权」的口号变成了可以按身份配发的东西:不同用户、不同租户、不同任务,发不同的 scope 集合,而工具代码一行不改。
why = check_scope(principal, name)        # 身份面:这个 Principal 能用这个工具吗
if why:
    return deny(tid, name, args, why)      # 拒绝原因 emit 给用户 + 写进审计链
if not check_allowed(name, cfg):           # 能力面:这个部署暴露这个工具吗(ch9)
    return deny(tid, name, args, f"工具 {name} 不在白名单")

loop.py 里的接线:鉴权在白名单之前,拒绝有原因、进审计

最后是密钥本身。规矩一条:密钥不进模型的上下文 —— 不进 system、不进消息、不进工具描述。上下文会被 trace 记录、被压缩摘要、被 llms 导出,写进去的密钥等于写进了日志。工程里 ANTHROPIC_API_KEY 走环境变量、模型层懒加载,工具拿到的是执行结果而不是凭据;MCP 的 stdio 传输给的建议是同一句话 —— 从环境取凭据。3注 3MCP 规范(2025-11-25)· Authorization —— 授权对 MCP 是可选项;HTTP 传输「SHOULD 遵循本规范」、stdio 传输「SHOULD NOT,改从环境取凭据」。采用时:授权服务器「MUST 实现 OAuth 2.1」(基于 draft-ietf-oauth-v2-1-13),客户端 MUST 实现 PKCE、MUST 带 RFC 8707 resource 参数;MCP 服务器「MUST 校验 token 是专门发给自己的」且「MUST NOT 接受或转传任何其他 token」。截至 2026-07。生产里再加两条:密钥放专门的 secret manager(不进代码库),并让第二节的 broker 只发短命凭据 —— 泄露的钥匙五分钟后是废铁,和泄露一把永久钥匙是两种事故。 动手 · 让鉴权真的拒绝一次:
01

拿一个降权的 Principal 跑一遍

config.py 里把 principal_scopes 改成 ("search:read", "web:read"),离线跑一遍 —— 看 run_python 被拒、任务照样体面收尾(错误回传,ch3 的老朋友)。
02

用 broker 换一次「只降权」的 token

在 REPL 里 TokenBroker().mint(principal, {"web:read", "admin:write"}) —— 确认拿回来的 scopes 里没有 admin:write:交集语义,升权在结构上不可能。
03

搜一遍你自己 agent 的上下文里有没有密钥

把你项目里的 system prompt、工具描述、trace 输出全文搜一遍 sk-BearerBEGIN PRIVATE KEY —— 搜到任何一个,就是这一节要你先修的洞。

企业问的第一个问题不是它多能干, 是它以谁的名义在干。.

The enterprise's first question isn't how capable it is — it's in whose name it acts..

Aklman Library

讨论

讨论.

评论区初始化中…