
工具调度器把一个请求交给 Python 子进程,进程启动了,读到的配置却和任务本身无关。subprocess.run() 如果只写命令和 cwd,会换工作目录,但会继续使用父进程的环境变量。开发机上这通常只是方便;放进 Agent 的工具边界里,意外传入的密钥、代理配置或调试开关就可能跟着进入不该看到它们的程序。
临时目录里的哨兵值
本地验证脚本在父进程环境里放入 AGENT_SENTINEL=local-test-secret,再启动一个子进程读取当前目录、哨兵值和 PATH。第二次调用保留相同的 cwd,但传入只含 AGENT_RUN 的 env 字典。临时目录退出后会自动清理,不会动项目文件。
== inherited child ==
cwd=/private/var/folders/vz/zf2_sm250pgd4qb_p847p1240000gn/T/agent-env-check-l5k06wbs
AGENT_SENTINEL=local-test-secret
PATH_PRESENT=yes
== explicit-env child ==
cwd=/private/var/folders/vz/zf2_sm250pgd4qb_p847p1240000gn/T/agent-env-check-l5k06wbs
AGENT_SENTINEL=<missing>
AGENT_RUN=isolated
PATH_PRESENT=no
== assertion ==
PASS: cwd follows cwd=; env= replaces inherited variables
输出里两次的目录相同,因为两次都设置了 cwd。第一次读到了哨兵变量,也读到了 PATH;第二次两个值都没有,新增的 AGENT_RUN=isolated 则留了下来。也就是说,cwd 只决定子进程从哪里启动,和环境隔离不是一回事。把 env 传进去时,Python 使用这张映射替代继承来的环境,并不会自动把父进程的 PATH 等变量补回去。
目录切换也只发生在新进程里。父进程的当前目录没有跟着改变,所以一段任务结束后,调度器后续读取相对路径时仍按自己的位置解析。这个分隔很实用,但也容易藏错:写进子进程的相对文件名,落点取决于 cwd,而非启动脚本所在的位置。实际工具若同时接收用户给出的文件路径,最好把路径归一化后再交给子进程,并把允许写入的工作目录限制在专用临时区。
白名单比删名单可靠
若要让工具子进程只读到少数配置,先从明确的变量映射开始,再逐项添加所需项。只删掉已知的几个敏感键,容易漏掉新增的令牌或供应商变量;白名单让新增环境值默认不可见。运行时也要留意 PATH、区域设置、证书目录等依赖,省略它们可能让命令找不到可执行文件,或改变文本和 TLS 行为。脚本里的 env={"AGENT_RUN": "isolated"} 是刻意做的最小对照,不是通用生产配置。
调用工具时,命令本身同样要用固定的可执行文件和经过检查的参数。环境收窄不能替代参数校验,也不能限制文件系统访问;它只回答子进程能读到哪些变量。若工具依赖系统 PATH,可以在白名单里明确保留它,或直接使用可执行文件绝对路径。这里我更倾向显式传入必要项,省得父进程里后来多出某个凭据变量,调用侧却毫无察觉。
调用边界留在调用处
cwd 与 env 应在启动子进程的那一层写清楚,不能指望工具内部再把环境收拾干净。若工具需要读取某个密钥,就把具体键名和传递路径写进契约;没有列出的变量不应因为当前机器恰好存在就顺手继承。这个约束还要和执行权限分开检查:变量不外泄,不代表子进程不能读其他文件或访问网络。
这次实验只确认了 Python subprocess.run() 在本机上的目录和环境行为,没有验证 Agent 框架、容器或远端执行器是否采用相同策略。对本地工具来说,显式设置 cwd 可以固定相对路径的起点;显式给 env 才能收住变量范围。简单的一次性命令可以继承环境,处理外部输入或凭据的工具应从白名单开始。