在这里插入图片描述

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


0. 为什么写这篇

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

那结论就只剩一个问题: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_call hook

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

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加一个验签:

import hmac, hashlib, time, os
from dataclasses import dataclass, field
from typing import Optional

_SECRET = os.environ["MANDATE_HMAC_KEY"].encode()

def _sign(payload: str) -> str:
    return hmac.new(_SECRET, payload.encode(), hashlib.sha256).hexdigest()

@dataclass(frozen=True)
class Mandate:
    """不可变授权对象。创建后只能读,不能改;要改只能revoke后重建。"""
    mandate_id: str
    actor: str            # 谁授的权(人类用户标识)
    scope: str            # 授权范围(自然语言描述,accept时冻结)
    strength: str         # ask_first | auto_low_risk
    rule_version: str     # 绑定的审批规则版本
    issued_at: float
    revoked: bool = False
    sig: str = ""

    def __post_init__(self):
        if not self.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))

    def verify(self) -> bool:
        payload = f"{self.mandate_id}|{self.actor}|{self.scope}|{self.strength}|{self.rule_version}|{self.issued_at}|{self.revoked}"
        return hmac.compare_digest(self.sig, _sign(payload))

@dataclass(frozen=True)
class ApprovalRule:
    """版本化审批规则。要改规则?发新版本,旧的全部失效。"""
    version: str
    require_approval_for: frozenset  # 需要审批的工具名集合
    sig: str = ""

    def __post_init__(self):
        if not self.sig:
            payload = f"{self.version}|{sorted(self.require_approval_for)}"
            object.__setattr__(self, "sig", _sign(payload))

class MandateStore:
    """授权存储。只有三个操作:签发、收回、校验。没有'修改'。"""
    def __init__(self):
        self._store: dict[str, Mandate] = {}

    def issue(self, m: Mandate) -> None:
        if not m.verify():
            raise ValueError("mandate signature invalid at issue time")
        self._store[m.mandate_id] = m

    def revoke(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] = new

    def check(self, mandate_id: str, rule: ApprovalRule, tool: str) -> str:
        """返回 allow / need_approval / denied。这是执行前唯一的判断入口。"""
        m = self._store.get(mandate_id)
        if m is None or m.revoked or not m.verify():
            return "denied"
        if m.rule_version != rule.version:
            return "denied"          # 规则已升级,旧授权自动失效
        if tool in rule.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规范实现):

def mandate_middleware(store: MandateStore, rule: ApprovalRule, mandate_id: str):
    def before_tool_call(tool_name: str, tool_args: dict) -> Optional[str]:
        verdict = store.check(mandate_id, rule, tool_name)
        if verdict == "denied":
            return f"DENIED: mandate {mandate_id} revoked, expired, or rule upgraded"
        if verdict == "need_approval":
            return None  # 交给框架的interrupt走人工审批
        return None      # allow,继续执行
    return before_tool_call

挂Semantic Kernel就是实现一个IFunctionInvocationFilter,在next(context)之前调check,denied时不调next。挂CrewAI就是一个@before_tool_call hook返回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授权分层方案(本篇)—
Logo

更多推荐