AI Agent 能准备什么、由谁签名:Safe 多签运维中的权限边界
AI Agent 可以准备并核对一笔 Safe 交易,但真正让资金移动的签名应当留在人类 owner 手中。
本文仅用于教育目的,不能替代正式的智能合约审计、法律建议、金融建议或针对具体协议的安全审查。
把 AI Agent 接入资金库,工作的性质就变了:读取余额、解码调用、起草转账,并不等于授权资金转出。准备、签名和执行之间的边界,必须由 AI Agent 能接触到的工具、密钥和合约路径来强制执行,而不是靠它的提示词。

在 Safe 上,权限审查不只看 owner 和阈值:已启用的模块、交易 guard,以及每位签名者批准的那个确切的 EIP-712 哈希,共同决定谁能移动资金。
对一个运营多签的小型 Web3 团队来说,真正有用的产出是一个签名者可以逐项检查的审查包:要做什么、在哪条链上、调用哪个合约、在什么条件下、有哪些证据。服务页面从整体上介绍这套方法;本文把它落到运维人员的层面,给出可以逐项核对的命令,而不必依赖显示这些信息的界面。
什么是签名与权限节点?
在有 AI Agent 参与的 Safe 多签运作中,签名与权限节点,是把 Agent 可以准备的工作与必须由人授权的操作分开的技术与运营边界。它明确谁提出交易、拥有哪些权限、审查的是哪一笔交易、由谁签名,以及事后用哪些证据核实发生了什么。
人工审批早已是有文档记载的架构的一部分,并不是新概念。Safe 关于 AI Agent 操作人工审批的指南,让 Agent 作为签名者之一,阈值可以是 2/3、3/5 或 5/7,从而至少需要一次人工批准。本文选择更严格的配置:任何签名都不归 Agent 掌控。这是针对这一运营流程的设计决定。
这种差别是有代价的,值得看清楚。让 Agent 担任签名者,团队每笔操作可以少一次人工签名,但 Agent 持有的密钥也就成了 Safe 权限的一部分。而且,如果 Agent 是 owner,同时又是提交交易的账户,Safe 会在没有额外签名的情况下计入它的批准:预批准哈希这种签名类型接受执行交易的 owner。委托签名在重复、范围有限的操作中可能合理,但那时限制应当写在单独审查过的模块或 guard 里,而不是写在 Agent 的指令里。
如果希望 Agent 不作为 owner 也能提出交易,Safe Transaction Service 支持委托地址:owner 信任并允许其代为提交提案的地址。由委托地址或 owner 签名的提案会显示在 Safe Wallet 中;其他提案虽会被服务保存,但不会显示。委托地址没有任何链上权限:它不是 owner,合约不会把它的签名计入阈值。
让准备工作可审查的五个组成部分
与签名者分离的身份。Agent 的运行环境需要自己的运营身份,只持有查询所需数据源的凭证;如果它要提交提案,还需要一个不同于任何 owner 的委托地址。这样就能把每一次读取或起草归因到某一次具体运行。签名者的密钥留在各自的钱包或托管系统中,不进入 Agent 的上下文和工具。
按操作定义的权限。策略应当描述具体动作:查询某个网络、读取某个 Safe 的状态、解码一笔调用、在固定区块上模拟,或提交一份提案。读取、模拟和提案工具应当分开,并明确禁止会签名或发送交易的 RPC 方法。范围宽泛的凭证,或接受任意命令的工具,需要额外审查其权限范围。
下面的片段是一个自定义格式的示例,并非任何产品的配置模式。其中的 RPC 方法名是以太坊和钱包的标准名称。
# Agent 工具策略:示例,自定义格式
agent: treasury-preparer
chains: [1] # 允许的 chainId
safes: ["0xSAFE..."] # 可读取并可为其提交提案的 Safe
rpc:
allow: [eth_chainId, eth_blockNumber, eth_call, eth_getCode, eth_getStorageAt, eth_getLogs]
deny: [eth_sendTransaction, eth_sendRawTransaction, eth_sign, eth_signTransaction,
eth_signTypedData, eth_signTypedData_v3, eth_signTypedData_v4, personal_sign,
wallet_sendCalls, eth_sendUserOperation]
default: deny # 未列入 allow 的一律拒绝
tools:
decode_calldata: allow
simulate_call: allow # 只读,固定区块
propose_to_tx_service: allow # 作为委托地址:没有链上权限
sign: deny
execute: deny
payload_rules:
operation: [0] # 0 = CALL;1 = DELEGATECALL 一律升级给人处理
safeTxGas: 0 # 为 0 时,内部调用失败会使整笔交易回滚
baseGas: 0
gasPrice: 0 # 不退还 gas
gasToken: "0x0000000000000000000000000000000000000000"
refundReceiver: "0x0000000000000000000000000000000000000000"
on_missing_data: stop_and_escalate在我们提出的设计中,限制在模型推理之外执行:工具会拒绝超出授权范围的请求。如果缺少评估这笔操作所需的数据,流程就停下来,请求人工审查。
与 payload 绑定的审查包。owner 签名的是一个覆盖十个字段的 EIP-712 哈希:to、value、data、operation、safeTxGas、baseGas、gasPrice、gasToken、refundReceiver 和 nonce。从 1.3.0 版本起,这个哈希的域包含 chainId 和该 Safe 自身的地址。审查包必须展示全部十个字段,而不只是目标地址和金额,因为其中好几个字段会改变签名的含义:
- operation 为 0 表示 CALL,为 1 表示 DELEGATECALL。使用 DELEGATECALL 时,目标合约的代码在 Safe 的上下文中运行,可以写入 Safe 的存储,包括它的配置。在这个流程中,任何不为 0 的值都升级给人处理。
- gasPrice、gasToken 和 refundReceiver 定义由 Safe 支付的 gas 退款。gasPrice 为 0 时没有退款;如果 refundReceiver 是零地址,退款会付给 tx.origin,也就是提交交易的人。
- nonce 决定顺序:每次未回滚的 execTransaction 都会使其递增,即使内部调用失败也是如此(safeTxGas 或 gasPrice 不为 0 时可能出现),因此一组签名只对某一个特定的 nonce 有效。
Safe 区分智能账户交易哈希,即 safeTxHash,与最终发送到网络的交易哈希。审查包应同时记录两者,并保留它们与人工决定之间的对应关系。
签名前的独立核对。我们建议用独立于 Agent 的代码和工具,核对所有可以确定性验证的内容:网络、地址、参数、nonce 和适用的策略。最关键的一步,是在提出交易的工具之外重新计算 safeTxHash,并与钱包或签名设备显示的哈希比对。如果屏幕分别显示域哈希和消息哈希,就两者都要比对。
签名授权的是呈现给钱包的那条具体消息。payload 的任何字段一旦改变,哈希就会改变,审查必须重做。对上一版草稿的批准不会自动延续到下一版。
把准备过程与结果连起来的证据。运营记录应把查阅过的来源、草稿、核对结果、审查者的决定、签名和最终执行关联起来,并记下每次读取所用的区块号。在我们提出的模型中,Agent 可以提供证据,但不能修改人工决定,也不能替换验证器的原始结果。
流程分解
- 第 1 步:Agent 以读取和提案权限收集 Safe 的状态,连同证据一起生成草稿。
- 第 2 步:独立于 Agent 的验证器解码调用、重新计算 safeTxHash,并在固定区块上进行模拟。
- 第 3 步:如果审查包不完整或某项核对失败,草稿退回第 1 步或升级给人处理;缺少数据时绝不继续推进。
- 第 4 步:审查包有效时,签名者进行审查,并对自己核对过的哈希签名。
- 第 5 步:由一个不受 Agent 控制的账户,携带收集到的签名提交 execTransaction。
- 第 6 步:团队核实结果:Safe 的成功或失败事件、对目标合约的影响,以及与批准内容是否一致。
签名与执行所需的组件和权限都在 Agent 之外。
为什么通用权限策略需要链上上下文
通用的权限系统决定某个身份能否使用某个工具。审查一笔链上操作,还需要知道每个签名授权了什么,以及有哪些路径可以行使这种权限。
在 Safe 上,常规的 execTransaction 路径会按照配置的阈值验证 owner 签名。这种验证并不取决于由谁提交交易:只要附带达到阈值的签名,任何账户都可以执行。因此,“Agent 不执行交易”是一条运营规则,取决于它持有哪些密钥和工具;也可以是某个 guard 检查提交地址的规则;它并不是合约本身的属性。合约真正阻止的,是篡改已签名的 payload:任何改动都会产生不同的哈希,原有签名随即失效。
Safe 接受四种签名类型:ECDSA、eth_sign、EIP-1271 合约签名,以及通过 approveHash 预批准的哈希。如果某个 owner 本身是合约,例如另一个 Safe,它的权限取决于该合约的规则,同样需要审查。
此外还有通过模块执行的路径,可以跳过签名验证。Safe 的文档提醒:模块可以执行任意交易,恶意模块可以接管一个 Safe。只审查 owner 和阈值,权限分析并不完整:如果 Agent 能触发某条模块路径,即使它不是 owner,也必须评估这种能力。有些部署从第一天起就启用了模块,例如 ERC-4337 模块,所以永远不要默认模块列表为空。
guard 在交易前后增加检查,从 1.3.0 版本开始提供。在 1.4.1 及之前的版本中,用 setGuard 设置的 guard 只检查 execTransaction:模块交易不经过它。1.5.0 版本新增 setModuleGuard,把模块路径也纳入检查。Safe 提醒,有缺陷的 guard 可能阻断执行,因此要在每个部署中核实它覆盖哪些路径,以及有什么恢复机制。
这份权限快照可以用 Foundry 的 cast 在固定区块上获取。存储槽位是 Safe 代码中的常量,我们已对照 1.4.1 和 1.5.0 版本核实:
# bash:按顺序、在同一个 shell 会话中运行本文的代码块
export ETH_RPC_URL=... # 对应网络的 RPC
SAFE=0x... # Safe 地址
BLOCK=$(cast block-number) # 固定区块,使所有读取基于同一区块
cast call $SAFE "VERSION()(string)" --block $BLOCK
cast call $SAFE "getOwners()(address[])" --block $BLOCK
cast call $SAFE "getThreshold()(uint256)" --block $BLOCK
cast call $SAFE "nonce()(uint256)" --block $BLOCK
# 模块:从哨兵地址 0x...01 开始的链表。
# 如果返回的第二个值不是 0x...01,说明还有下一页。
cast call $SAFE "getModulesPaginated(address,uint256)(address[],address)" \
0x0000000000000000000000000000000000000001 50 --block $BLOCK
# 交易 guard:slot = keccak256("guard_manager.guard.address")
cast storage $SAFE 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8 --block $BLOCK
# fallback handler:slot = keccak256("fallback_manager.handler.address")
cast storage $SAFE 0x6c9a6c4a39284e37ed1cf53d337577d14212a4870fb976a4366c693b939918d5 --block $BLOCK
# 仅限 1.5.0,模块 guard:slot = keccak256("module_manager.module_guard.address")
cast storage $SAFE 0xb104e0b93118902c651344349b610029d694cfdec91c589c91ebafbcd0289947 --block $BLOCK槽位值为零,表示没有设置 guard 或 fallback handler,前提是前面的调用都有正常返回:对于没有代码的地址或错误的网络,所有槽位读出来也都是零。fallback handler 和 DELEGATECALL 还会打开其他权限路径,我们会在一篇关于权限矩阵的专门文章中讨论。在 1.3.0 之前的 Safe 版本中没有 guard,因此 guard 槽位为零只说明该版本不支持 guard。
链本身也是签名上下文的一部分。EIP-712 定义了 chainId 和 verifyingContract 等域字段,由各协议决定使用哪些。从 1.3.0 版本起,Safe 两个都用:chainId 和 Safe 的地址,因此同一个 payload 为同一个 Safe 在另一条链上签名,会得到不同的哈希。更早的 Safe 版本不把 chainId 放进域中,因此在 Safe 地址相同的另一条链上,同一个签名可能同样有效。该标准本身并不防止已签名消息被重复使用;在 Safe 中,这一作用由 nonce 承担。
由 Agent 准备的一笔资金库付款
来看一个示例场景,它不是客户案例,也不是效果测量:一个团队要从一个有五个 owner、阈值为三个签名的 Safe 转出 5,000 个单位的某种 ERC-20 代币。
在直接的 ERC-20 转账中,调用的目标是代币合约。收款人和金额都在 transfer 的数据里。金额以最小单位表示,因此必须读取合约的小数位数:ERC-20 标准把 decimals 列为可选项;如果是 6 位小数,5,000 个单位就是 5000000000。
TOKEN=0x... # 该网络上的代币合约,已与批准的来源核对
RECIPIENT=0x... # 已批准的收款人
cast call $TOKEN "decimals()(uint8)" --block $BLOCK
DATA=$(cast calldata "transfer(address,uint256)" $RECIPIENT 5000000000)
cast decode-calldata "transfer(address,uint256)" $DATA # 审查者应看到的内容
# 合约自身给出的 safeTxHash:CALL,不退还 gas
NONCE=$(cast call $SAFE "nonce()(uint256)" --block $BLOCK | cut -d' ' -f1) # 从 10000 起,cast 会附加 " [1e4]"
ZERO=0x0000000000000000000000000000000000000000
SAFE_TX_HASH=$(cast call $SAFE \
"getTransactionHash(address,uint256,bytes,uint8,uint256,uint256,uint256,address,address,uint256)(bytes32)" \
$TOKEN 0 $DATA 0 0 0 0 $ZERO $ZERO $NONCE --block $BLOCK)
echo $SAFE_TX_HASH为了既不依赖界面、也不依赖被查询的合约,可以用 Safe 代码中的常量离线重新计算哈希。从 1.3.0 版本起,这一计算与 getTransactionHash 的结果一致:我们已在 1.3.0 和 1.4.1 的 Safe 上,并对照 1.5.0 的代码核实。更早的版本使用不同的域,所以先读取 VERSION。
SAFE_TX_TYPEHASH=0xbb8310d486368db6bd6f849402fdd73ad53d316b5a4b2644ad6efe0f941286d8
DOMAIN_TYPEHASH=0x47e79534a245952e8b16893a336b85a3d9ea9fa8c573f3d803afb92a79469218
CHAIN_ID=$(cast chain-id)
DOMAIN_HASH=$(cast keccak "$(cast abi-encode "f(bytes32,uint256,address)" $DOMAIN_TYPEHASH $CHAIN_ID $SAFE)")
MESSAGE_HASH=$(cast keccak "$(cast abi-encode \
"f(bytes32,address,uint256,bytes32,uint8,uint256,uint256,uint256,address,address,uint256)" \
$SAFE_TX_TYPEHASH $TOKEN 0 "$(cast keccak "$DATA")" 0 0 0 0 $ZERO $ZERO $NONCE)")
RECOMPUTED=$(cast keccak 0x1901${DOMAIN_HASH:2}${MESSAGE_HASH:2})
# 必须与 $SAFE_TX_HASH 一致;值为空说明之前的读取失败了
[ -n "$SAFE_TX_HASH" ] && [ "$RECOMPUTED" = "$SAFE_TX_HASH" ] && echo "MATCH $RECOMPUTED" || echo "NO MATCH"内部调用按照由 Safe 发出的方式、在快照区块上模拟,命令见本节最后一段代码。这种模拟不会触发签名验证和 guard;要覆盖这些,需要使用分叉环境,下文会说明。这笔付款的审查包应当能够核对以下内容:
- 网络与 Safe:chainId 和地址已与授权配置核对,并在某个具体区块读取了 VERSION、owner、阈值、nonce、模块、guard 和 fallback handler。
- 目标合约:该网络上的代币地址,已与批准的来源核对,以及它的小数位数。
- 调用:解码后的 transfer,包括收款人和以最小单位表示的金额。
- Safe 参数:operation 为 0,原生币数量为 0,safeTxGas、baseGas 和 gasPrice 为 0,gasToken 和 refundReceiver 为零地址。
- 哈希:合约返回的 safeTxHash 与离线重新计算的结果完全一致。
- 模拟:所用区块、调用结果和检查过的余额变化。
- 审查与签名:审查过的 payload、每位签名者看到的哈希及其决定。
# 按由 Safe 发出的方式、在快照区块上模拟内部调用
# 有些代币,例如 USDT,不返回 bool:这时去掉 (bool),只检查调用不回滚
cast call $TOKEN "transfer(address,uint256)(bool)" $RECIPIENT 5000000000 --from $SAFE --block $BLOCK如果核对通过,签名者按惯常流程提供所需的三个签名,再由一个不受 Agent 控制的账户提交 execTransaction。之后,团队要核实两件不同的事:交易已经上链,以及内部调用确实成功。Safe 会带着 safeTxHash 发出 ExecutionSuccess 或 ExecutionFailure 事件;当 safeTxGas 或 gasPrice 不为零时,内部调用失败并不会让整笔交易回滚。交易上链本身并不能证明付款已经完成。
这个场景展示的是职责分离。付款金额和阈值都是示例中的假设,并不证明节省了时间、减少了事故,也不代表任何实现的性能。
这一架构中 Agent 的五条限制
在我们提出的流程中,Agent 的权限不包括以下操作:
- 访问私钥、助记词或签名凭证。
- 对 Safe 拥有权限的消息或交易进行签名:不能用 ECDSA,不能用 eth_sign,也不能从 owner 账户调用 approveHash。
- 通过模块路径执行 Safe 操作,或用自己的密钥提交 execTransaction。前者在模块列表中核查,从 1.5.0 起还可以通过模块 guard 核查;后者是运营规则或 guard 规则,因为合约接受任何发送者。
- 修改 owner、阈值、模块、guard 或 fallback handler 来扩大自身权限,或提出可能达到同样效果的 DELEGATECALL。
- 把被修改过的 payload 当作已批准,或推翻尚未作出的人工决定。
团队应当在工具、凭证和可达路径中逐一核实这些限制。Agent 的指令用于引导它的工作;即使模型输出有误,运营限制也必须依然有效。面向数字资产的 Agent 完整权限矩阵及其升级路径,是本系列另一篇文章的主题;生产环境中 Agent 权限的基础知识,见文末链接的《生产中的 AI Agent 需要权限,而不是感觉》。
如何在 Web3 团队中引入
从一个重复发生、范围有限、并且已经有负责人和签名流程的操作开始。对于这个模型,我们建议分四步:
- 梳理现有权限:谁准备、谁审查、谁签名、谁执行,以及存在哪些模块、guard、委托地址和本身是合约的 owner。用 cast 获取的权限快照就是起点。
- 定义审查包:在请求签名之前,必须具备哪些字段、核对项和证据。
- 在受控环境中测试准备流程。在用 anvil 启动的本地分叉中,可以不动用真实资金跑完整个操作,用以 impersonate 方式接管的账户通过 approveHash 批准哈希。正适合在这里注入网络、地址、小数位数、operation 或 nonce 错误,确认流程会拦截或升级这些错误。
- 评估整体工作:准备和审查耗时、被退回的草稿,以及批准的 payload 与实际执行的操作是否一致。
# 在快照区块上启动本地分叉,在后台运行:不会动用任何真实资金
anvil --fork-url $ETH_RPC_URL --fork-block-number $BLOCK --silent &
sleep 5 # 留出时间让 anvil 监听 8545 端口
export ETH_RPC_URL=http://127.0.0.1:8545
OWNERS="0x... 0x... 0x..." # Safe 中达到阈值所需的 owner(本例为 3 个)
EXECUTOR=0x... # 任意一个不是 owner 的账户
cast rpc anvil_setBalance $EXECUTOR 0xDE0B6B3A7640000 # 1 个测试 ETH,用于支付 gas
# 每个 owner 用以 impersonate 方式接管的账户批准哈希。
# 预批准哈希签名:r = 左侧补零到 32 字节的 owner 地址,s = 0,v = 1,
# 按 owner 地址升序拼接(Safe 会拒绝其他任何顺序)。
SIG=0x
for OWNER in $(printf '%s\n' $OWNERS | tr 'A-F' 'a-f' | LC_ALL=C sort); do
cast rpc anvil_setBalance $OWNER 0xDE0B6B3A7640000
cast rpc anvil_impersonateAccount $OWNER
cast send $SAFE "approveHash(bytes32)" $SAFE_TX_HASH --from $OWNER --unlocked
SIG=${SIG}000000000000000000000000${OWNER:2}000000000000000000000000000000000000000000000000000000000000000001
done
# 任何账户(包括非 owner)都可以提交执行
cast rpc anvil_impersonateAccount $EXECUTOR
cast send $SAFE \
"execTransaction(address,uint256,bytes,uint8,uint256,uint256,uint256,address,address,bytes)" \
$TOKEN 0 $DATA 0 0 0 0 $ZERO $ZERO $SIG --from $EXECUTOR --unlocked
# 结束后:kill %1 会停止 anvil验收测试还应证明:Agent 没有任何可达路径去行使留给人的权限。一次成功的运行,无法用来评估这条边界。在分叉环境中,从非 owner 账户再次提交同样的签名必须失败,因为 nonce 已经改变,哈希也随之改变。
哪些地方仍需要人的判断
权限分离减少了 Agent 的直接权限,但它的提案质量仍然需要审查。如果收到的信息不完整,或者没有交叉核对,人也可能签下一个错误的 payload。
核对结果的有效期同样需要管理。从准备到执行之间,nonce、Safe 的配置或目标合约的状态都可能变化。我们建议制定明确的标准:当这些变化影响决定时,重新进行验证和模拟。一次顺利的模拟只描述了所检查的条件,不代表未来的条件。
这个流程会增加集成工作和签名者之间的协调,它是否有用,应当用对真实流程的测量来判断。对于需要不同委托策略的重复操作,应单独设计并审查被委托的权限及其限制。如果 Agent 运行在自带策略引擎的钱包服务商之上,这个问题并不会消失:服务商执行这些策略,但策略的内容仍由你的团队决定,团队也必须核实还有哪些其他路径可以到达 Safe。本系列中有一篇专门文章,会结合权限矩阵讨论这种情况。
在接入资金库之前先设计边界
第一步,是拿一个真实的操作,画出谁能准备、谁能授权、谁能执行。然后核实权限、Safe 的权限快照和审查包是否反映了这种分工。Agent 可以准备 payload、证据和核对结果,但只有人会对自己核实过的 EIP-712 哈希签名,而且这套设计没有给 Agent 留下任何可以执行它的密钥、工具或模块路径。
在 Nexuain,我们把这套方法描述为一个面向 Web3 运维的作品集概念:辅助准备、人工审批和外部执行,配有示例性的产出物。我们面向 Agent 的工具范围、审批和可追溯性服务,介绍在“带有防护栏、审批和审计轨迹的 AI Agent”页面上。
如果你的团队想把这种方法用于某个具体的 Safe 操作,第一项交付物是该操作的权限地图,涵盖 owner、阈值、模块、guard、委托地址以及所有能够到达签名或执行的路径,再加上其审查包的定义。你可以通过联系页面申请技术审查;请在留言中提及本文以及你考虑的操作,不要附上密钥或机密的运营资料。
参考来源
文档查阅日期:2026 年 10 月 1 日。这些来源描述的是机制和架构;付款场景、示例策略和引入步骤是本文提出的示例性方案。文中代码块均从本文原样复制,并用 Foundry 的 cast 和 anvil 1.8.3 从头到尾运行,对象是主网上的一个 Safe 1.3.0 和一个 Safe 1.4.1,以及各自的本地分叉。
- Safe: Human approval for AI agent actions
- Safe: Smart Account Concepts
- Safe: Signatures
- Safe: Safe Modules
- Safe: Safe Guards
- Safe: tx-service 模式命令,委托地址
- safe-smart-account v1.5.0: Safe.sol
- safe-smart-account v1.5.0: ModuleManager.sol
- safe-smart-account v1.4.1: GuardManager.sol
- EIP-712: Typed structured data hashing and signing
- ERC-20: Token Standard
- Foundry: cast 参考
- Ethereum: JSON-RPC API


