脚本批准能相信已签名的 AI 智能体解释器吗?
面向 AI 智能体的脚本批准,不能只依赖已签名的解释器。检查确切的源代码字节、执行上下文、依赖关系和操作目标。

已签名的解释器无法证明你批准的是哪个脚本。它只能证明一件范围更小的事情:操作系统启动了一个签名经过验证的特定解释器二进制文件。之后,解释器仍可能读取任意数量的可变文件,接受命令字符串中的源代码,通过包解析器加载代码,并使用脚本本身从未持有的凭据执行操作。
当批准卡写着“Python”或“Node”,旁边还显示一个令人安心的签名徽章时,人们很容易忽略这个区别。我见过评审者因为二进制文件名称很熟悉就批准了提示,随后又花掉一个下午最令人不快的时间,查清楚实际提供代码的到底是哪个仓库检出版本、符号链接、环境设置或预加载包。熟悉的可执行文件应获得与可执行文件身份相称的信任,但它们不会让任意源代码变得可信。
已签名的解释器只能识别解释器
代码签名回答的是有关可执行文件来源的问题。在 macOS 上,codesign 工具可以检查可执行文件的签名和指定要求。Gatekeeper 以及平台的运行时保护机制也会在评估软件时使用相关信息。但这些机制都不会声明:命令行传入的 Python 文件来自同一个开发者、文件自评审后没有发生变化,或它导入的内容是安全的。
看看下面两个调用:
/usr/bin/python3 /Users/dev/work/release/publish.py
/usr/bin/python3 -c "import os; os.system('curl ...')"
两个调用中的解释器身份可能完全相同,但源代码身份截然不同。第一个调用包含一个路径,评审者或许可以据此找到代码。第二个调用根本没有脚本文件。若提示把两者都简化成“已签名的 Python 请求访问网络”,就丢掉了人需要判断的关键信息。
Python 文档为运行文件、使用 -c 执行命令、使用 -m 运行模块,以及从标准输入读取源代码分别规定了不同的命令行形式。这是解释器的正常行为,不是缺陷。真正的错误,是把这些形式当成背后都有同一个稳定且已签名的程序。
进程身份也存在同样的误区。批准系统可以准确告诉你,某个智能体进程来自你认可的代码签名机构,但它仍然无法推断该进程要求解释器执行的每个本地文件都值得获得同样的批准。调用方身份和源代码身份回答的是不同问题:
- 调用方身份回答是谁发起了请求。
- 解释器身份回答哪个二进制文件解析源代码。
- 源代码身份回答解释器将解析哪些字节。
- 操作身份回答最终请求会发送到哪个主机、API 路由、账户或命令。
如果评审界面只能容纳前两项,它就会制造一种虚假的具体感。实际上,决定请求是否可以接受的是源代码和操作目标。
评审对象是一组执行要素
评审者需要的是对确切执行过程的稳定描述,而不是一个友好的标签。我把这份描述称为执行要素组:解析后的解释器、其参数、源代码制品、执行上下文,以及请求的对外操作。只要其中一个重要成员发生变化,就代表一次不同的执行,需要重新做出决定。
对于基于文件的 Python 调用,最低限度有用的执行要素组如下:
{
"caller": {
"pid": 48172,
"signing_authority": "Example Development Team"
},
"interpreter": {
"resolved_path": "/usr/local/bin/python3.12",
"signing_identity": "Python Software Foundation",
"sha256": "c44e...9a10"
},
"argv": ["/usr/local/bin/python3.12", "/private/var/run/gateway-src/publish.py"],
"source": {
"display_path": "/Users/dev/work/release/publish.py",
"sha256": "6ab1...ee42"
},
"context": {
"working_directory": "/Users/dev/work/release",
"environment": {"DEPLOY_ENV": "staging"}
},
"requested_action": "POST https://api.example.invalid/releases"
}
摘要缩写适合放在批准卡中,但完整摘要应写入日志。评审者通常需要可读路径,以及差异对比或源代码预览。调查人员则需要一个之后可以明确比对的值。
不要把每个环境变量都放进批准卡。那会把一次决策变成视力测试。应记录会影响代码选择、命令解析、凭据、代理路由、目标选择和功能开关的值。对 Python 来说,这可能包括 PYTHONPATH、PYTHONHOME 和明确传入的配置路径。执行 shell 时,通常还包括 PATH、当前目录,以及插入命令的变量。如果威胁模型有此要求,可以把完整环境保存到受保护的审计数据中,同时只向人展示其中重要的子集。
团队还经常混淆第二个区别:可复现性不等于授权。锁定文件、Git 提交或容器镜像可以帮助复现曾经运行的内容,但它们没有说明这段代码是否应该访问生产环境、删除远程分支或打开 SSH 会话。应将源代码身份与清晰的操作请求配对。
Python 可以把代码隐藏在普通启动方式之后
Python 的普通文件调用看起来比实际情况简单。python deploy.py 告诉你执行从哪里开始,但解释器可能从脚本目录、已安装包、配置的搜索路径以及应用逻辑选定的位置导入模块。虚拟环境还可能改变未限定的 python 最终解析到哪个解释器。
在评估签名之前,先解析可执行文件。python、python3 或 venv/bin/python 这些标签都不是身份。启动器可能是符号链接、shim,也可能在工具链更新后指向另一个二进制文件。网关应解析内核实际要启动的对象,检查该对象,并记录其路径和摘要。
然后,把源代码路径当作显示辅助信息,而不是安全边界。为评审记录解析符号链接,得到规范位置,但评审完成后不要再执行那个可变的原始路径。仓库检出操作可以在不改变路径的情况下替换 deploy.py,符号链接也可以指向另一个目标。单独检查路径无法捕获这两种变化。
一个实用流程是:
- 读取请求脚本的字节并计算 SHA-256。
- 将这些字节复制到由网关拥有且权限严格受限的私有目录。
- 在批准前显示调用方路径、规范路径、摘要和源代码预览。
- 使用私有副本调用经过验证的解释器,然后将结果与该摘要关联记录。
这份副本不是多余工作,它消除了检查时刻与使用时刻之间的竞态。如果评审者批准的摘要是 6ab1...ee42,解释器就必须读取摘要为 6ab1...ee42 的字节。先对仓库文件计算哈希,再让 Python 稍后重新读取仓库文件,会留下一个虽小却真实存在的替换窗口。
导入仍然需要做出决定。如果 publish.py 导入本地的 release_helpers.py,即使入口文件保持不变,修改后的辅助文件也能改变行为。严格做法是使用源代码清单,列出此次执行允许使用的每个本地模块。对于日常工作,更实际的做法是将入口脚本和声明的本地包树一起暂存,拒绝暂存树之外的导入,并在清单摘要发生变化时要求重新批准。
不要假装这样就能捕获动态导入、原生扩展、sitecustomize 或运行时任意获取的代码。它做不到。存在这些例外时,批准界面应明确写出相关许可。包含 importlib.import_module(os.environ["PLUGIN"]) 的脚本,并不会仅仅因为它和一个自包含脚本都使用同一个已签名解释器启动,就获得同样宽泛的批准。
Node 的入口文件只是程序的一部分
Node 增加了另一层模糊性。node task.js 虽然有入口文件,但模块解析仍可能通过 package.json、包导出、锁定文件、符号链接和当前目录选择代码。Node CLI 文档还介绍了 --require 和 --import 等预加载方式,它们可以在入口文件开始执行前运行代码。
因此,评审系统必须显示完整的参数向量,而不能只显示最后那个 .js 路径。下面这些调用应接受不同程度的审查:
node tools/publish.mjs
node --import ./tools/setup.mjs tools/publish.mjs
node --require ./tools/patch.cjs tools/publish.mjs
node -e "require('child_process').execSync(process.argv[1])" "git push --force"
如果评审者只看到 tools/publish.mjs,就会漏掉第二个和第三个调用中更早运行的代码。最后一个调用没有经过评审的入口文件,命令字符串本身就是源代码制品,必须按源代码处理,进行显示、保存和哈希计算。
Node 的 NODE_OPTIONS 环境变量也应受到同样的处理。Node 将它说明为一种通过环境传递允许的命令行选项的方式。如果进程可以通过它提供预加载内容或调试行为,那么忽略它的网关检查的就是一条不完整的命令。你不必用每个运行时设置吓到评审者,但必须显示那些会导致代码加载或改变目标选择的设置。
包管理器还会制造另一个陷阱。npm run publish 往往让人觉得只是一个有名字的任务,但实际行为来自可变的 package.json、其中的脚本、锁定文件、包管理器钩子,以及项目依赖树中找到的二进制文件。批准前应先展开任务名称,显示解析后的命令、项目版本或暂存清单,以及将执行的每个生命周期钩子。如果无法完成展开,就请求范围更窄的批准,或拒绝请求。当包文件可能在后台发生变化时,“运行包脚本”不是有意义的操作描述。
对于需要重复运行的 Node 自动化任务,应暂存经过评审的工作区快照,或使用不可变的构建制品。只有当脚本没有本地依赖且没有预加载路径时,单独对 publish.mjs 计算哈希才足够。大多数复杂项目都不满足这个条件。
Shell 字符串也必须按源代码处理
当人们把 shell 请求称为命令而不是程序时,评审就容易失效。sh -c 会解析包含展开、替换、重定向、管道、函数和命令查找的源代码字符串。字符串可能很短,但它可以调用数量不限的其他程序。
比较下面两个请求:
/bin/sh -c 'curl -fsS "$RELEASE_URL" | sh'
/bin/sh /private/var/run/gateway-src/release.sh
第一个请求需要提供确切的命令字符串、每个重要的环境值,以及下游程序会接收到什么的说明。第二个请求需要像 Python 或 Node 一样处理脚本路径和内容。/bin/sh 上的签名只能告诉你谁提供了解析器,它不会让任一源代码输入自动获得信任。
不要根据 shell 命令的第一个动词批准它。在一个确切的参数向量中,git status 可能没有风险,而 git -c credential.helper=... 会改变 Git 将加载的输入。curl 可能获取数据、写入文件,或把字节通过管道交给另一个解释器。评审者需要看到足够的语法,以便发现重定向和替换,也需要看到足够的执行上下文,以便了解程序如何解析。
PATH 经常被忽略。脚本如果不使用绝对路径调用 deploy,就把可执行文件的选择交给了环境。如果请求来自智能体工作区,能够修改该工作区的攻击者可能把一个程序放到 PATH 更靠前的位置。条件允许时,应为每个涉及安全的子命令记录解析出的可执行文件路径。如果完整 shell 解析本身就不安全或存在太多不确定性,应使用受限命令接口,而不是试图构建一个完美的 shell 解析器。
这也是为什么“只允许已签名的 shell,并在每次调用时提示”这个常见建议并不可靠。它之所以流行,是因为 shell 到处都有,批准看起来也很简单。问题在于,除非系统记录确切的字符串或不可变脚本,以及重要的执行上下文,否则一次批准没有稳定的源代码对象。按次批准仍可能始终如一地批准错误的内容。
内容哈希必须绑定到实际可绑定的文件
哈希是关于字节的证据,但不能证明预期程序一定会使用这些字节。实现必须把经过评审的字节绑定到实际执行上。许多原本设计得很谨慎的方案,都会在这里出问题。
不安全的模式很容易看出:
1. Read /workspace/scripts/publish.py
2. Calculate and display SHA-256
3. Wait for approval
4. Run python /workspace/scripts/publish.py
在第 2 步和第 4 步之间,另一个进程可以编辑文件、替换符号链接,或更改挂载目录。批准记录仍然准确地反映了评审者看到的内容,却无法说明实际运行的内容。
可以采用以下模型之一:
- 将经过评审的字节复制到私有执行目录,设置权限使请求进程无法修改它们,然后运行副本。
- 执行之前构建的不可变制品,其摘要已经过评审并记录。
- 只有当解释器和操作系统允许你在不重新解析可变路径的情况下执行同一个对象时,才在检查和执行路径中持续使用已经打开的文件对象。
私有副本模型通常更容易解释和审计,也能为事故调查提供稳定制品。保留原始显示路径作为上下文,因为人们需要知道是哪个项目文件触发了运行,但不要把它与实际执行的字节混为一谈。
内容哈希的局限应始终清晰可见。它无法判断源代码是否安全,也无法固定远程响应、依赖时间的行为、随机值,或哈希之后加载的代码。但它确实能防止一类特定的批准错误:评审了一个本地脚本版本,却执行了另一个版本。只要准确说明边界,这就是一个有价值的保护层。
使用 SHA-256 或其他当前的加密摘要算法,并固定编码方式,在记录中始终包含算法名称。单独的十六进制字符串容易造成之后的混淆。摘要记录应写成 sha256:6ab1...ee42,而不能只写 6ab1...ee42。
批准卡应展示人真正能用来判断的证据
好的批准卡能让评审者快速做出决定,同时不会隐藏那些会改变决定的事实。不要用文件哈希开场,人无法凭肉眼评估哈希。应先显示请求的操作和调用方,然后显示解释器、源代码位置、源代码状态以及重要的执行上下文。
对于部署请求,一张精简的卡片可以写成这样:
Caller: signed process from Example Development Team, PID 48172
Action: POST release data to api.example.invalid
Interpreter: /usr/local/bin/python3.12, signed by Python Software Foundation
Source: /Users/dev/work/release/publish.py
Reviewed bytes: sha256:6ab1...ee42
Execution copy: /private/var/run/gateway-src/6ab1...ee42/publish.py
Context: DEPLOY_ENV=staging, working directory /Users/dev/work/release
卡片应提供源代码预览,或提供与上次批准摘要之间的差异。对于重复工作,差异通常更好,因为它会把注意力引向发生变化的行。但也要支持按需查看完整源代码。误导性的截断预览比没有预览更糟。
避免使用“允许部署工具”或“允许 Python 访问”这类含糊的批准标签。它们会教人根据品牌熟悉度一路点击通过。决定还应说明有效期限。一次执行、一个进程会话和一个已评审制品的发布,是三种不同的范围。针对智能体进程的会话批准可以减少提示疲劳,但任何摘要发生变化的脚本,都应在重新使用外部权限之前触发新的源代码决定。
这与规则引擎不同。你不需要让人们编写“允许安全脚本”这样的条件语言。你需要一个批准后不会悄悄扩大的固定评审对象。系统应根据已解析的输入构建该对象,向人展示,并将执行绑定到它。
必须明确划定依赖边界
只有当程序的代码边界确实只有一个文件时,入口脚本摘要才可能足够。应把这种情况视为例外,而不是默认设置。Python 导入、Node 模块、shell 的 source 命令、模板、配置文件和可执行插件,都可能在入口点通过评审后改变行为。
应根据操作的后果来设定边界。对于开发服务上的低风险读取,可以接受暂存的入口脚本,并明确说明它可以导入已安装的包。对于生产写入或 SSH 命令,应在清单中包含本地源代码依赖,固定外部依赖,并拒绝运行时下载可执行代码。这样,记录才能告诉调查人员你所说的“脚本”具体指什么。
简单的清单可以包含相对路径和摘要:
sha256 publish.py 6ab1...ee42
sha256 release_helpers.py 9d07...1a3c
sha256 config/targets.json 743e...64b1
网关应根据暂存副本或受控的构建输入计算这份清单,而不是接受同一个可变工作区提供的清单。如果项目把锁定文件声明为边界的一部分,也要对该锁定文件计算哈希。只有当运行时确实遵守锁定文件,且经过评审的进程无法替换另一棵依赖树时,锁定文件才有帮助。
如果代码可以从任意目录发现插件、获取并执行远程内容,或先写入再运行生成的脚本,那么基于解释器的本地执行就已经过于宽泛,不适合一次点击决定。此时应拆分流程:批准一个产生不可变制品的构建,检查制品声明的操作,再批准该操作。多出的边界成本,低于之后重建一次意外的生产变更。
日志必须回答实际运行了什么,而不是界面把它叫什么
当操作出错时,第一个有用的问题通常是“究竟有什么内容带着这些权限运行了?”一条写着“Python 已批准”的日志无法回答这个问题。应保留执行要素组、决定范围、评审者操作、时间戳和观察到的结果。可以从记录中删除秘密,但不要删除源代码制品或操作目标的身份。
可靠的记录会将相关事件关联起来。会话记录应标识智能体进程及其权限。操作记录应标识解释器、暂存源代码摘要、参数、目标和结果。如果评审者撤销了会话,该事件应与同一个会话身份关联。否则,操作人员无法判断撤销是否停止了发起该调用的请求方。
防篡改证据会提升记录质量。哈希链日志可以让之后的修改变得可检测,但它无法补救缺失的字段。验证哈希链的同时,仍要检查它是否记录了解析后的路径、源代码摘要、上下文和实际操作。完整性可以保留证据,但不会凭空生成系统从未采集的证据。
Sallyport 的拆分日志在这里很有用,因为它们将智能体运行与单独的 HTTP 或 SSH 调用分开,同时又从同一份加密、哈希链审计日志中展示两者。它的 sp audit verify 命令可以在密文上离线验证哈希链,这正适合检查保留的证据是否在事后发生了变化。
将源代码变化视为新的权限
最安全的默认规则很简单:源代码摘要发生变化时,对外部操作要求新的决定。不要因为路径、解释器、项目名称或进程签名看起来熟悉,就默默沿用之前对脚本的批准。
这条规则会让活跃开发期间多出现几次提示,本来就应该如此。当代码可以消耗凭据、修改远程服务或运行 SSH 命令时,代码评审会改变它所拥有的权限。解决办法不是压制所有提示,而是改进评审制品、暂存确定的源代码,并只在源代码仍然被独立识别时授予更广泛的进程会话权限。
对于使用操作网关的团队,应让网关专注于凭据离开本地机器的那个环节。智能体应带着与源代码绑定的评审记录请求 HTTP 调用或 SSH 命令,网关负责持有凭据并返回结果。这种安排可以避免把秘密交给可变脚本,但仍然要求诚实说明是哪段代码请求了该操作。
从最敏感的解释器调用开始。解析二进制文件,显示完整参数,对实际源代码字节计算哈希并暂存,记录重要上下文,并让摘要变化意味着批准变化。拥有这份记录后,已签名解释器才会成为完整决策中的有用证据,而不是覆盖在未知脚本上的安心标签。
常见问题
已签名的 Python 或 Node 二进制文件,能让脚本可以安全批准吗?
不能。签名只能告诉你哪个主体签署了解释器二进制文件,例如 Python、Node 或 shell。它无法说明该二进制文件会读取哪个源文件、源文件是否发生过变化,或源代码接下来会加载哪些文件。
脚本的批准提示应显示哪些内容?
应批准一份精确的执行记录,包括解析后的解释器、其签名身份、参数向量、脚本的规范位置以及脚本摘要。还要加入工作目录、相关环境变量,以及可能改变行为的已声明依赖输入。
检查脚本路径就足够批准智能体了吗?
路径对人来说很有用,但它不是身份。脚本可能被原地修改、通过符号链接替换,或者在同一路径下切换到其他版本。因此,路径必须与内容摘要配套使用。
如何避免哈希检查中的竞态条件?
先计算将要执行的确切字节,再执行这些字节的不可变副本,并将副本放在由网关拥有的目录中。如果你计算的是一个文件的哈希,之后却执行原来的可变路径,那么这两步之间发生的写入就会绕过检查。
导入的模块会改变已批准脚本的行为吗?
Python 可以通过常规导入系统、启动钩子和显式运行时加载器加载模块。Node 可以预加载模块并解析包入口,因此评审时应把入口脚本视为评审边界的起点,而不是完整边界。
应如何评审 shell -c 命令?
把字符串当作源代码,而不是普通参数。保存命令字符串的确切字节,计算其哈希,在批准卡中显示它,并记录将要解释它的 shell 可执行文件和环境。
Python 虚拟环境会改变解释器身份吗?
虚拟环境可能通过启动器或符号链接指向同一个解释器,也可能使用独立的二进制文件。应解析操作系统实际要启动的可执行文件,并验证该文件,而不是相信命令标签或虚拟环境目录名。
进程签名可以替代脚本哈希吗?
不会。进程签名可以帮助确认调用方由谁生成,而脚本摘要则标识调用方要求解释器执行的内容。在高影响操作的批准中,两者都应记录,任何一个都不能替代另一个。
团队应永久批准某个部署脚本路径吗?
对于重复自动化任务,可以批准一个范围明确的不可变制品,或一个带有过期时间和明确操作范围的已评审发布版本。永久批准熟悉路径下未来出现的任何文件,会把正常的仓库修改变成凭据权限。
已批准脚本的审计日志必须保留哪些内容?
审计记录应包含批准决定、调用方身份、解释器身份、确切命令、源代码摘要、操作目标和结果。只写着“已批准 Python”的日志,无法解释事故发生后究竟执行了什么。