☰
生产环境Agent授权分层方案:用100行Python给LangGraph装上“不可变Mandate
2026/11/5 6:53:55 网站建设 项目流程

结论先给:主流框架的拦截点(LangGraph middleware、SK filter、CrewAI hook)解决的是"能不能拦住",没解决"拦截规则本身会不会被改"。生产环境要在框架之上自建四层:不可变授权对象(frozen capability)、版本化审批规则、stop-only人工通道、以及一套验证"真的不可变"的对抗测试清单。文末给一个百行级的Python最小实现,可直接挂到LangGraph上。


0. 为什么写这篇

前两篇把一个问题拆成了两半:

  • 《我审了DeepSeek开源的超级Agent原型》:dsh-superagent的Mandate是真不可变——93处引用全是读,运行时无写入路径。
  • 《我翻了5个主流Agent框架的授权模型:DeepSeek那个"不可变授权"设计,是孤例》:LangGraph、AutoGen、CrewAI、Assistants API、Semantic Kernel,全部没有不可变授权对象,授权本质是运行时可变的进程内配置。

那结论就只剩一个问题:dsh的Mandate深度耦合在它自己的DSH运行时里,你总不能为了一个授权模型换掉整个技术栈。怎么在现有框架上,自己造一个不可变授权层?

这篇就是答案。不是理论,是可落地的分层方案+最小实现。

1. 先定威胁模型:你到底在防什么

授权层的设计完全取决于威胁模型。生产环境Agent的权限风险就三类,按出现频率排:

威胁攻击路径现有框架能防吗
T1:prompt注入诱导扩权恶意网页/邮件内容诱导Agent"顺便"执行超出原任务的操作⚠️ 框架拦截点能拦工具调用,但拦不住Agent"决定"去做什么
T2:授权配置被运行时篡改同进程代码(插件、依赖、业务代码)改掉策略dict/hook/filter❌ 全部防不了——配置本来就是可变的
T3:审批规则被降级攻击者不改授权本身,改"什么时候需要审批"的规则❌ CrewAI的hook能被clear_all一键清空;SK的filter运行时增删

看清楚:**T2和T3防的不是LLM,是你自己进程里的代码。**这是大多数团队没想明白的——他们以为授权层防的是Agent乱来,其实更硬的威胁是授权层本身被改。

dsh-superagent的Mandate恰好同时防住三个:scope创建时冻结(防T1的扩权结果落不了地)、运行时无写入路径(防T2)、没有自主Watchdog/审批规则不可降级(防T3)。

2. 分层方案:在现有框架上叠四层

不要指望一个机制解决所有问题。生产环境的授权层应该是四层叠加,每层防一个面:

┌─────────────────────────────────────────┐ │ L4 人工通道(stop-only) │ 防T3:人只能停,不能改 ├─────────────────────────────────────────┤ │ L3 版本化审批规则(rule_version绑定) │ 防T3:规则变更=新授权 ├─────────────────────────────────────────┤ │ L2 不可变授权对象(Mandate) │ 防T1+T2:创建后冻结 ├─────────────────────────────────────────┤ │ L1 框架拦截点(middleware/filter/hook) │ 框架自带:执行前拦截 └─────────────────────────────────────────┘

L1:框架拦截点——用现有的,别重造

你用什么框架就用什么拦截点,这一层框架已经给了:

  • LangGraph:HumanInTheLoopMiddleware的interrupt_on
  • Semantic Kernel:IFunctionInvocationFilter(不调next就不执行)
  • CrewAI:@before_tool_callhook

这一层的职责只有一个:在工具执行前拿到一个判断点。判断什么,是上面三层的事。

L2:不可变授权对象——本篇的核心

这是5个框架都没有的。设计原则照抄dsh-superagent的三条:

  1. 创建即冻结:授权对象用不可变数据结构(Python用@dataclass(frozen=True)),字段包括strength、scope、rule_version、actor、issued_at。
  2. 只有两种命运:被使用(只读),或被收回(标记revoked,不修改任何字段)。
  3. 防篡改签名:对象创建时用HMAC签名,校验时验签——防止有人绕过类型系统直接构造一个假授权。

第三条比dsh更进一步。dsh的Mandate不可变靠的是"代码里没有写入路径"的纪律,在Python这种什么都能改的语言里,光靠frozen=True不够——object.__setattr__能绕过。加签名才是硬保证。

L3:版本化审批规则——规则变更=新授权

把"什么时候需要人工审批"的规则做成版本化的:

  • 规则对象同样frozen,带rule_version字段
  • Mandate的basis字段绑定创建时的rule_version
  • 规则要改?发新版本,旧Mandate在revalidate时发现版本不匹配→自动失效,走重新授权

这就是dsh的revalidate机制:policy版本变了,旧授权不能偷偷升级,只能作废重来。规则没有"修改",只有"替换"。

L4:stop-only人工通道

人工介入只给一个动作:stop。不给"修改scope"“提升strength”"临时放行"这些选项。

原因很简单:每一个"人能改授权"的入口,都是社工攻击的入口。dsh的设计里,人唯一能做的是停止一个Ticket(设置ended.by='human'),要新权限就创建新Ticket。麻烦是麻烦,但这就是代价。

3. 最小实现:不到100行

Python实现,可直接挂LangGraph。核心是两个frozen dataclass加一个验签:

importhmac,hashlib,time,osfromdataclassesimportdataclass,fieldfromtypingimportOptional _SECRET=os.environ["MANDATE_HMAC_KEY"].encode()def_sign(payload:str)->str:returnhmac.new(_SECRET,payload.encode(),hashlib.sha256).hexdigest()@dataclass(frozen=True)classMandate:"""不可变授权对象。创建后只能读,不能改;要改只能revoke后重建。"""mandate_id:stractor:str# 谁授的权(人类用户标识)scope:str# 授权范围(自然语言描述,accept时冻结)strength:str# ask_first | auto_low_riskrule_version:str# 绑定的审批规则版本issued_at:floatrevoked:bool=Falsesig:str=""def__post_init__(self):ifnotself.sig:# 创建时签名;此后任何字段篡改都会导致验签失败payload=f"{self.mandate_id}|{self.actor}|{self.scope}|{self.strength}|{self.rule_version}|{self.issued_at}|{self.revoked}"object.__setattr__(self,"sig",_sign(payload))defverify(self)->bool:payload=f"{self.mandate_id}|{self.actor}|{self.scope}|{self.strength}|{self.rule_version}|{self.issued_at}|{self.revoked}"returnhmac.compare_digest(self.sig,_sign(payload))@dataclass(frozen=True)classApprovalRule:"""版本化审批规则。要改规则?发新版本,旧的全部失效。"""version:strrequire_approval_for:frozenset# 需要审批的工具名集合sig:str=""def__post_init__(self):ifnotself.sig:payload=f"{self.version}|{sorted(self.require_approval_for)}"object.__setattr__(self,"sig",_sign(payload))classMandateStore:"""授权存储。只有三个操作:签发、收回、校验。没有'修改'。"""def__init__(self):self._store:dict[str,Mandate]={}defissue(self,m:Mandate)->None:ifnotm.verify():raiseValueError("mandate signature invalid at issue time")self._store[m.mandate_id]=mdefrevoke(self,mandate_id:str)->None:old=self._store[mandate_id]# 收回也是创建新对象:除了revoked标志,其他字段原样,重新签名new=Mandate(mandate_id=old.mandate_id,actor=old.actor,scope=old.scope,strength=old.strength,rule_version=old.rule_version,issued_at=old.issued_at,revoked=True,)self._store[mandate_id]=newdefcheck(self,mandate_id:str,rule:ApprovalRule,tool:str)->str:"""返回 allow / need_approval / denied。这是执行前唯一的判断入口。"""m=self._store.get(mandate_id)ifmisNoneorm.revokedornotm.verify():return"denied"ifm.rule_version!=rule.version:return"denied"# 规则已升级,旧授权自动失效iftoolinrule.require_approval_for:return"need_approval"return"allow"

三个设计决策值得说:

为什么revoke也是重建对象?因为frozen=True在Python里可以被object.__setattr__绕过。与其赌没人这么干,不如让"收回"本身也走"创建新对象+重新签名"的路径——MandateStore外部根本拿不到可变引用,拿到的永远是verify()之后的判断结果。

为什么scope还是自由文本?这是从dsh审计里学到的教训:结构化scope(比如枚举操作集合)更安全但表达力差,自由文本表达力强但依赖LLM的自律。最小实现选自由文本,生产环境建议换成结构化操作集合(比如frozenset({"git:read","git:write:src/**"})),把判断从prompt层提到代码层。

为什么check返回三态而不是布尔?need_approval是显式状态,交给L4的人工通道处理,而不是让调用方自己猜"False是拒绝还是待审批"。

4. 挂到框架上

挂LangGraph只需要一个自定义middleware,在interrupt_on之前先过MandateStore.check(下面是示意代码,实际接口签名按各框架的middleware规范实现):

defmandate_middleware(store:MandateStore,rule:ApprovalRule,mandate_id:str):defbefore_tool_call(tool_name:str,tool_args:dict)->Optional[str]:verdict=store.check(mandate_id,rule,tool_name)ifverdict=="denied":returnf"DENIED: mandate{mandate_id}revoked, expired, or rule upgraded"ifverdict=="need_approval":returnNone# 交给框架的interrupt走人工审批returnNone# allow,继续执行returnbefore_tool_call

挂Semantic Kernel就是实现一个IFunctionInvocationFilter,在next(context)之前调check,denied时不调next。挂CrewAI就是一个@before_tool_callhook返回False。模式完全一样:框架给拦截点,你的授权层给判断,两边解耦。

5. 怎么验证你的授权层真的不可变

写完不算完,得测。这是从dsh审计的方法论直接搬过来的——把"不可变"当成可证伪的命题来攻击。五个测试,少过一个都不算完成:

测试操作预期结果
T1 直接改字段m.strength = "admin"FrozenInstanceError
T2 绕过frozenobject.__setattr__(m, "strength", "admin")改成功了,但verify()返回False,check拒绝
T3 伪造授权不知道密钥,构造新Mandateverify()失败,issue时报错
T4 规则降级用旧rule_version的Mandate配新规则check返回denied
T5 revoke后复活revoke后拿旧Mandate对象继续用check返回denied(存储里的已换)

T2是最关键的——它验证的是"即使攻击者在你进程里执行了任意代码,授权判断结果仍然不可篡改"。这就是HMAC签名比dsh的"代码纪律"更进一步的地方。

6. 什么不该抄dsh

dsh-superagent的Mandate设计值得抄三条(不可变对象、版本化规则、stop-only),但有两条不要照抄:

不要抄它的"靠纪律保证不可变"。dsh是TypeScript,没用readonly,靠的是全仓没有写入路径——这在code review可控的原型里成立,在你的生产代码库里不成立。Python必须用frozen=True+签名双保险。

不要抄它的scope自由文本然后靠prompt约束。dsh的mandateInstructions里写"do not expand scope"是prompt层防护,不是代码硬约束(我上一篇审计里明确说了这个层次差)。你的生产实现应该把scope做成结构化操作集合,让越权判断发生在check()的代码里,而不是LLM的自觉里。

7. 一句话总结

主流框架给你的是拦截点,不是授权。生产环境的Agent授权要自己造四层:框架拦截点管"拦得住",不可变Mandate管"改不了",版本化规则管"降不了级",stop-only人工通道管"社工进不来"。上面那个100行的实现是最小骨架,scope结构化和持久化存储(Mandate应该落盘而不是只在内存)是下一步。

前两篇解决了"看清楚",这篇解决"造出来"。下一篇做运行时实测:把这个授权层挂到真实Agent上,用prompt注入打一遍,看它拦不拦得住。


附:本系列三篇

篇内容链接
①dsh-superagent静态审计:Mandate不可变、Subscription不扩权、Watchdog不存在链接
②5个主流框架授权模型对比:全部没有不可变授权对象链接
③生产环境Agent授权分层方案(本篇)—

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询