一、背景:静态扫描的盲区
第一篇我们给 Skill 加了消费侧三层防线:HMAC 完整性校验、静态 import 扫描、路径防逃逸。这三道防线有一个共同点,它们都在执行之前完成,保证下载的内容没被换过、脚本没有明显的恶意代码、路径不会逃逸出目录。但静态分析是有成本的,它至少有四个管不住的地方:
| 盲区 | 攻击示例 | 为什么静态扫描管不住 |
|---|---|---|
| 动态执行 | exec("import socket")、__import__ |
黑名单只匹配 import 语句文本,动态构造的内容根本不在 AST 里 |
| 混淆 | 字符串拼接模块名、编码后解码执行 | 追不完,继续追就是在和攻击者赛跑 |
| 运行时数据 | 脚本读取宿主环境变量 | import 扫描不检查环境,而密钥就在那里 |
| 零日/未知库 | 依赖某个库的隐蔽行为 | 静态扫描不认识第三方库内部 |
更现实的问题是,阶段一的防线全部通过之后,脚本仍然要在裸子进程里执行。改造前我们的 PdfService 是这样做的:
ProcessBuilder pb = new ProcessBuilder("uv", "run", "--project", skillDir, "python", script, filePath);
Process process = pb.start(); // 继承 JVM 全部环境变量,无资源限制,无输出上限
ProcessBuilder 默认继承宿主进程的全部环境变量。我们实测过,一个什么都不干的脚本只要 print(os.environ),就能看到宿主环境里的 OPENAI_API_KEY、ANTHROPIC_AUTH_TOKEN、数据库连接串、Nacos 密码、JWT secret。也就是说,通过静态扫描的脚本依然能轻易拿到这些敏感信息。
所以阶段二的目标很明确:脚本即使带恶意,也只能在受限环境里执行,不能碰到宿主系统的其他部分。
二、整体设计:基础执行约束 + 两级沙箱
阶段二落地为两件事。
第一件是三项基础执行约束:环境变量白名单、强超时、输出上限。这三件事与沙箱开关无关,永远生效。它们不决定隔离强度,只保证基本纪律:密钥不传给子进程、跑不完就杀掉、输出太多就截断。具体实现见第四节。
第二件是两级沙箱,通过 mode 配置三档(off 是关闭选项,真正的隔离强度只有进程级和容器级两级):
off 关闭沙箱(仅保留三项基础执行约束,不注入引导脚本)
process 进程级软沙箱:python -I + audit hook + rlimit ← 零新基础设施,默认开启
docker 容器级硬沙箱:--network none --read-only --cap-drop ALL ... ← 生产建议
两种方案的区别在于隔离强度。audit hook 是 Python 解释器内部的应用层钩子,能拦截明显的危险操作,但它和脚本跑在同一个进程里,恶意代码理论上可以绕过(比如直接调 libc)。要真正和攻击者对抗,需要容器或内核层面的隔离。所以生产环境建议启用容器级硬沙箱(docker 模式),开发环境用进程级软沙箱(process 模式)就够了。
整个执行链路现在是:
下载 → 完整性校验(阶段一) → 物化/路径守卫(阶段一) → 静态扫描(阶段一)
→ 沙箱执行(阶段二) → 输出进入下游(阶段三做输出校验)
三、进程级软沙箱:一个引导脚本实现
这个方案的关键设计是:不直接运行目标脚本,而是先经过一个引导脚本。引导脚本做四件事:设置资源限制、安装审计钩子、建立文件系统围栏,最后以 __main__ 语义加载目标脚本。
"""Skill 沙箱引导脚本(阶段二 · 执行隔离)。
用法:
python -I sandbox_bootstrap.py <script> [args...]
职责:
1. 资源限制(仅 POSIX): 内存 RLIMIT_AS、CPU 时间 RLIMIT_CPU、文件描述符 RLIMIT_NOFILE
2. 审计钩子: 拦截 socket.* / subprocess.* / os.system / ctypes.dlopen
3. 文件系统围栏: 只允许读写 SKILL_SANDBOX_WORKDIR,
只允许读 SKILL_SANDBOX_READ_ROOTS 与解释器自身 sys.path 目录
4. 以 __main__ 语义加载目标脚本,保持 sys.argv 与直接执行一致
这是"软沙箱"(应用层护栏),不是安全边界;对抗恶意代码请配合 docker 硬沙箱。
"""
import os
import sys
def _norm(path: str) -> str:
"""转绝对路径并解析符号链接,保证后续比较的基准一致。"""
return os.path.realpath(os.path.abspath(path))
def _within(path: str, roots: set) -> bool:
"""判断 path 是否位于 roots 中某个根目录之内(含根目录本身)。"""
for root in roots:
if path == root or path.startswith(root + os.sep):
return True
return False
def _is_write(mode: int, flags: int) -> bool:
access = mode & 3
if access != 0:
return True
return bool(flags & (getattr(os, "O_CREAT", 0) | getattr(os, "O_APPEND", 0) | getattr(os, "O_TRUNC", 0)))
def _open_is_write(args) -> bool:
"""判断一次 open 审计事件是否是写操作。
CPython 在不同平台/版本上审计事件参数形态不一致:
- 部分版本: (path, mode_int, flags_int)
- Windows 3.14 等: (path, mode_str, flags_int),mode_str 形如 'r'/'w'/'a'/'x'
"""
if len(args) < 2:
return False
mode_arg = args[1]
if isinstance(mode_arg, str):
return any(c in mode_arg for c in "wax+")
try:
mode = int(mode_arg)
flags = int(args[2]) if len(args) > 2 else 0
return _is_write(mode, flags)
except (TypeError, ValueError):
return False
def main() -> None:
if len(sys.argv) < 2:
print("usage: sandbox_bootstrap.py <script> [args...]", file=sys.stderr)
sys.exit(2)
script = _norm(sys.argv[1])
sys.argv = [script] + sys.argv[2:]
script_dir = os.path.dirname(script)
# 1. 资源限制(Windows 下依赖 Job Object / 容器,进程级软沙箱降级为超时 + 输出上限)
# 资源预算由 Java 执行器通过环境变量传入,对应配置里的 memory-mb 与 cpu-seconds
memory_mb = int(os.environ.get("SKILL_SANDBOX_MEMORY_MB", "512"))
cpu_seconds = int(os.environ.get("SKILL_SANDBOX_CPU_SECONDS", "30"))
# 仅类 Unix 系统支持 resource.setrlimit;Windows 上 os.name == 'nt',整个块直接跳过
if os.name != "nt":
try:
import resource
resource.setrlimit(resource.RLIMIT_AS, (memory_mb * 1024 * 1024,) * 2)
resource.setrlimit(resource.RLIMIT_CPU, (cpu_seconds,) * 2)
resource.setrlimit(resource.RLIMIT_NOFILE, (256, 256))
except (ImportError, ValueError, OSError):
pass
# 2. 文件系统围栏
workdir = _norm(os.environ.get("SKILL_SANDBOX_WORKDIR", os.getcwd()))
rw_roots = {
workdir}
ro_roots = {
script_dir}
# 解释器自身的 sys.path(stdlib / venv site-packages)允许读取,
# 否则脚本连 import 标准库都会被自己的审计钩子拦掉
for entry in sys.path:
if entry:
ro_roots.add(_norm(entry))
for root in os.environ.get("SKILL_SANDBOX_READ_ROOTS", "").split(os.pathsep):
if root:
ro_roots.add(_norm(root))
def audit(event, args):
# 危险通道直接拒绝:联网 / 起子进程 / 系统命令 / 加载原生库
if event.startswith(("socket.", "subprocess.")) or event in ("os.system", "ctypes.dlopen"):
raise PermissionError("blocked by skill sandbox: " + event)
if event == "open" and args:
path = _norm(str(args[0]))
if _open_is_write(args):
if not _within(path, rw_roots):
raise PermissionError("blocked write outside workdir: " + path)
else:
if not _within(path, rw_roots) and not _within(path, ro_roots):
raise PermissionError("blocked read outside sandbox roots: " + path)
# 注册审计钩子:此后脚本的每次安全敏感操作(打开文件、建 socket、起子进程等)
# 都会先经过 audit(),钩子抛异常即中断该操作
sys.addaudithook(audit)
# 3. 执行目标脚本(-I 隔离模式下脚本目录不在 sys.path,需手动注入)
sys.path.insert(0, script_dir)
import runpy
runpy.run_path(script, run_name="__main__")
if __name__ == "__main__":
main()
先解释一下引导脚本第一件事里的 rlimit。它是 POSIX 系统的进程资源上限机制,通过 resource.setrlimit 给当前进程设上限,超限的资源申请会直接失败。引导脚本设了三道线:RLIMIT_AS 限制虚拟内存总量(对应配置里的 memory-mb),RLIMIT_CPU 限制 CPU 时间(对应 cpu-seconds),RLIMIT_NOFILE 限制可同时打开的文件描述符数量。这个机制只在类 Unix 系统存在,Windows 没有对应实现,所以进程级软沙箱在 Windows 上自动降级为超时加输出上限,这也是博客后面多次提到 Windows 的原因。
下面拆开讲四个设计点。
① python -I 隔离模式。-I 会忽略所有 PYTHON* 环境变量、禁用 user site-packages、不把脚本目录加进 sys.path,从解释器层面与宿主环境切割。但要注意,-I 不会跳过 venv 的标准 site-packages,所以 uv run 建好的虚拟环境里装的 pypdf 等依赖仍然可以 import,这一点我们实测验证过。
② 审计钩子拦什么。socket.* 把网络全部拦住,连 getaddrinfo 也会被拦;subprocess.* 禁止起新进程;os.system 和 ctypes.dlopen 分别拦住系统调用和加载原生库。选这些模块的理由和阶段一的黑名单一致:只拦能脱离解释器干坏事的 fork、exec、联网类能力,不拦纯计算。exec/eval 这类间接能力静态扫描追不完,审计钩子同样不拦,否则误伤面太大,这部分留给容器级硬沙箱处理。
③ 文件系统围栏。这是最容易踩坑的地方:审计钩子必须在脚本启动前把解释器自己的 sys.path 加进只读根,否则脚本里第一句 import json 读标准库都会被自己的钩子拦掉。围栏策略是:工作目录(每次执行新建的临时目录)可读可写;脚本目录、skill 目录、输入文件目录(SKILL_SANDBOX_READ_ROOTS)、解释器 sys.path 全部只读。写操作判定要兼容两种审计事件形态,Windows 上 Python 3.14 的 open 事件第二个参数是 'r'/'w' 这样的模式字符串,其他平台是 int 标志位,只认一种就会漏拦或误拦。
④ runpy.run_path(script, run_name="__main__")。它保持脚本 if __name__ == "__main__" 的语义,同时把 sys.argv 重写为 [脚本路径, 原参数...],对脚本来说和直接执行完全一致,不需要脚本做任何适配。
上面四个设计点分散讲了三类拦截,这里收拢成一张清单,方便对照:
| 默认封锁的能力 | 拦截手段 | 受影响但不代表永久禁用 |
|---|---|---|
| 联网 / DNS / 调外部 API | audit hook 拦 socket.*,urllib、http、ftplib 底层都走 socket,一并被拦 |
联网搜索类 skill,需显式申请 network 权限 |
| 起新进程 | audit hook 拦 subprocess.* |
依赖外部命令行工具的 skill,需显式申请 |
| 执行系统命令 / 加载原生库 | audit hook 拦 os.system、ctypes.dlopen |
调系统工具或 C 库的 skill |
| 读写宿主文件系统 | open 围栏:只能读写工作目录,只能读白名单根 |
需要往宿主指定路径写产物的 skill |
| 资源无限占用 | 超时 + 输出上限 + rlimit(内存/CPU) | 无,所有 skill 都应遵守 |
需要说明的是,默认拒绝不等于永久禁用。它只是把能力从谁都能用变成申请了才能用:skill 在 SKILL.md 里声明权限,管理端审批通过后,沙箱再按审批结果放行对应的能力(这是阶段三权限模型要落地的事)。在两种沙箱模式下,这份清单都默认生效,docker 不会解锁任何一项,只是让逃逸变得更难。
另外注意清单最后一行的实现归属:超时和输出上限由 Java 执行器实现,与沙箱开关无关;rlimit 由引导脚本实现,只在 POSIX 系统生效。
四、三项基础执行约束(Java 侧)
引导脚本负责脚本运行时的行为。脚本运行之前,Java 侧还有三道防线,都在 SkillSandboxExecutor 里实现,而且不随沙箱开关关闭。
4.1 环境白名单:先 clear() 再放行
ProcessBuilder pb = new ProcessBuilder(command);
pb.directory(workDir.toFile());
// 关键:清空继承的环境,只放白名单(防 os.environ 偷密钥)
pb.environment().clear();
pb.environment().putAll(env);
env 的构建逻辑是:从宿主环境只拷贝白名单里的键(PATH、SYSTEMROOT、LANG、PYTHONIOENCODING 等),再显式注入沙箱内部约定变量(SKILL_SANDBOX_WORKDIR、SKILL_SANDBOX_MEMORY_MB,以及指向工作目录的 TMP/TEMP 等)。白名单之外的键一律不出现。DATABASE_URL、NACOS_PASSWORD、DEEPSEEK_API_KEY、JWT_SECRET 从源头就不在子进程的环境里,脚本想读也读不到。
4.2 强超时:超时后杀整棵进程树
timedOut = !startedProcess.waitFor(timeoutSeconds, TimeUnit.SECONDS);
if (timedOut) {
destroyTree(startedProcess); // descendants().forEach(destroyForcibly) + destroyForcibly
awaitTermination(startedProcess);
}
这里有个容易犯的错:process.destroyForcibly() 只杀掉父进程,Python 起的孙进程会变成孤儿继续运行。所以要先遍历 process.descendants() 把整棵进程树都杀掉。审计钩子已经拦了 subprocess.*,进程级软沙箱里出现孙进程的情况本来就少,但该做的清理不能省。
4.3 输出上限:超过就截断,但管道必须继续排空
private static void readBounded(InputStream input, int maxBytes, StringBuilder target, boolean[] truncated) {
...
while ((n = reader.read(buffer)) != -1) {
int room = maxBytes - total;
if (room > 0) {
int take = Math.min(n, room);
target.append(buffer, 0, take);
total += take;
if (take < n) truncated[0] = true;
} else {
truncated[0] = true; // 超限部分丢弃,但继续读到 EOF 排空管道
}
}
}
为什么超限了还要继续读?因为一旦停止读取,子进程会阻塞在写满的管道上,要么永远不退出,要么耗尽内存。正确的做法是继续读但只丢弃内容:数据不进内存,管道保持畅通。
五、容器级硬沙箱:软沙箱不是安全边界
进程级软沙箱的 audit hook 和脚本跑在同一个进程里,恶意代码拿到执行权后理论上可以绕(例如通过 ctypes 之外的途径调原生接口)。所以生产环境建议启用容器级硬沙箱(docker 模式),运行参数如下:
docker run --rm -i \
--network none \ # 无网络
--read-only \ # 根文件系统只读
--cap-drop ALL \ # 丢弃全部 Linux capabilities
--security-opt no-new-privileges \ # 禁止提权
--pids-limit 64 \ # 进程数上限
--memory 512m --cpus 0.5 \ # 内存/CPU 配额
-v <skillDir>:/skill:ro \ # skill 目录只读挂载
-v <workdir>:/work \ # 工作目录可写
-w /work \
<image> python -I /sandbox/sandbox_bootstrap.py /skill/scripts/xxx.py <args...>
进入容器后,audit hook 和 rlimit 变成第二道防线,真正的隔离由内核的 Namespace/Cgroup 提供。
有一个工程问题必须交代:容器级硬沙箱(docker 模式)不能用 uv run --project。原因是 --read-only 下 uv 无法在 skill 目录里创建 .venv。所以容器级硬沙箱要求预构建镜像,把 Python 运行时和 skill 依赖直接打进镜像;进程级软沙箱保留 uv run --project,依赖解析照旧走 uv。
六、接入:一个执行器统一入口
Java 侧新增 SkillSandboxExecutor(@Component),作为 skill 脚本执行的统一入口,目前接入的是 PdfService 这条动态 skill 脚本执行通道。调用方构造一个 SandboxRequest 即可:
SkillSandboxExecutor.SandboxRequest sandboxReq = SkillSandboxExecutor.SandboxRequest.builder()
.script(scriptFile.toPath().toAbsolutePath())
.skillDir(skillDir.toAbsolutePath())
.args(List.of(pdfAbs.toString()))
// 输入文件目录只读挂载:脚本能读 PDF,但不能写回宿主任意路径
.readRoots(List.of(pdfAbs.getParent()))
.extraEnv(Map.of("PYTHONIOENCODING", "utf-8"))
.useUvProject(true)
.build();
SkillSandboxExecutor.SandboxResult result = sandboxExecutor.execute(sandboxReq);
if (result.isTimedOut()) {
... }
if (result.getExitCode() != 0) {
... }
return Map.of("success", true, "content", result.getStdout());
useUvProject(true) 时,执行器拼出 uv run --quiet --project <skillDir> python -I <bootstrap> <script> <args...> 的命令(已用最小项目实测),skill 的 pyproject.toml 依赖解析行为与阶段一完全一致,只是脚本改由引导脚本启动。
需要说明的是,目前接入执行器的只有 PdfService 这一条动态 skill 脚本通道。项目里另一个会拉起 Python 的 VisualizationService 执行的是 bank-model-server 的本地固定模块,属于可信代码,不在动态 skill 脚本范围内,所以仍走原来的调用方式;将来如果有新的 skill 脚本执行入口,应统一接到这里。
配置延续第一篇的 yml 风格:
app:
ai:
skill:
sandbox:
mode: ${
SKILL_SANDBOX_MODE:process} # off | process | docker
timeout-seconds: ${
SKILL_SANDBOX_TIMEOUT_SECONDS:120}
max-output-bytes: ${
SKILL_SANDBOX_MAX_OUTPUT_BYTES:1048576}
memory-mb: ${
SKILL_SANDBOX_MEMORY_MB:512}
cpu-seconds: ${
SKILL_SANDBOX_CPU_SECONDS:30}
pids-limit: ${
SKILL_SANDBOX_PIDS_LIMIT:64}
cpus: ${
SKILL_SANDBOX_CPUS:0.5}
docker-image: ${
SKILL_SANDBOX_DOCKER_IMAGE:}
env-allowlist: [PATH, SYSTEMROOT, WINDIR, COMSPEC, PATHEXT, LANG, LC_ALL, PYTHONIOENCODING]
七、落地与验证
针对上面的默认封锁清单和基础执行约束,我们用一批脚本逐一做了验证:
| 攻击脚本 | 预期 | 实测 |
|---|---|---|
print(os.environ) |
裸进程能看到 OPENAI_API_KEY 等密钥 |
✅ 裸进程确实泄露;白名单后子进程环境只剩白名单 + 内部变量 |
import socket; socket.socket() |
被拦截 | ✅ PermissionError: blocked by skill sandbox: socket.__new__ |
subprocess.run(['echo','hi']) |
被拦截 | ✅ 非零退出,stderr 含 blocked |
open('/宿主路径/evil.txt','w') |
被拦截且宿主文件不生成 | ✅ 越界写被拦,宿主文件不存在 |
print('x' * 5000)(上限 1KB) |
输出被截断但进程正常退出 | ✅ truncated=true,stdout 长度受限 |
time.sleep(30)(超时 1s) |
超时后进程树被杀 | ✅ timedOut=true,1058ms 返回 |
良性脚本 print('hello') |
正常执行、参数透传 | ✅ 退出码 0,sys.argv 与直接执行一致 |
测试落在 SkillSandboxExecutorTest,共 10 个用例,加上阶段一原有的 25 个用例,全量 35 个用例通过。期间还遇到一个很隐蔽的平台差异:Windows 上 Python 3.14 的 open 审计事件第二个参数是模式字符串 'r'/'w' 而不是 int,最初只按 int 解析,直接抛了 ValueError。修正后两种形态都兼容(见引导脚本的 _open_is_write)。
八、复用指南
8.1 文件清单与放置位置
| 文件 | 放置位置 | 说明 |
|---|---|---|
| sandbox_bootstrap.py | src/main/resources/sandbox/ |
引导脚本,纯标准库。执行器启动时会自动物化到 <cache-dir>/_sandbox/,不用手动拷贝 |
| SkillSandboxExecutor.java | 你的 Java 包(示例为 com.yangtze.bankwarning.ai.security) |
沙箱执行器,完整代码见 8.2 |
| SkillPathGuard.java | 与执行器同包 | 完整代码见第一篇 7.2。执行器编译时引用它(docker 模式校验脚本位置),只用 process 模式的话可以删掉 docker 分支再移除 |
组件依赖关系:
sandbox_bootstrap.py(Python,纯标准库)
↑ 被 SkillSandboxExecutor 以 python -I 方式加载
SkillSandboxExecutor(Java)
依赖:slf4j(日志)+ spring-core(ClassPathResource 物化引导脚本,可替换,见 8.5)
可选依赖:SkillPathGuard(仅 docker 模式需要)
8.2 完整代码:SkillSandboxExecutor.java
直接复制即可使用:
package com.yangtze.bankwarning.ai.security;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* Skill 脚本执行沙箱(阶段二 · 执行隔离)。
* 作为 skill 脚本执行的统一入口,替代原先散落在执行链路里的裸 ProcessBuilder。
* 无论沙箱开关如何,始终执行三项基础执行约束:
* 1. 环境变量白名单,子进程绝不继承宿主密钥(DATABASE_URL / NACOS_* / API Key / JWT);
* 2. 强超时,超时后强杀整棵进程树;
* 3. 输出上限,stdout/stderr 超限即截断并标记,防止输出洪泛打爆内存。
* 沙箱强度由 mode 决定:
* OFF —— 阶段一原行为(直接跑脚本,不注入引导脚本)
* PROCESS —— 进程级软沙箱:python -I + sandbox_bootstrap.py(audit hook + rlimit)
* DOCKER —— 容器级硬沙箱:--network none --read-only --cap-drop ALL --no-new-privileges ...
*/
@Component
public class SkillSandboxExecutor {
private static final Logger log = LoggerFactory.getLogger(SkillSandboxExecutor.class);
public enum Mode {
OFF, PROCESS, DOCKER
}
private final Mode mode;
private final int timeoutSeconds;
private final int maxOutputBytes;
private final int memoryMb;
private final int cpuSeconds;
private final int pidsLimit;
private final double cpus;
private final String dockerImage;
private final List<String> envAllowlist;
private final Path bootstrapFile;
private final String uvCacheDir;
public SkillSandboxExecutor(
@Value("${app.ai.skill.sandbox.mode:process}") String mode,
@Value("${app.ai.skill.sandbox.timeout-seconds:120}") int timeoutSeconds,
@Value("${app.ai.skill.sandbox.max-output-bytes:1048576}") int maxOutputBytes,
@Value("${app.ai.skill.sandbox.memory-mb:512}") int memoryMb,
@Value("${app.ai.skill.sandbox.cpu-seconds:30}") int cpuSeconds,
@Value("${app.ai.skill.sandbox.pids-limit:64}") int pidsLimit,
@Value("${app.ai.skill.sandbox.cpus:0.5}") double cpus,
@Value("${app.ai.skill.sandbox.docker-image:}") String dockerImage,
@Value("${app.ai.skill.sandbox.env-allowlist:}") List<String> envAllowlist,
@Value("${app.ai.skill.cache-dir:${user.dir}/.skills-cache}") String cacheDir) {
this.mode = parseMode(mode);
this.timeoutSeconds = timeoutSeconds;
this.maxOutputBytes = maxOutputBytes;
this.memoryMb = memoryMb;
this.cpuSeconds = cpuSeconds;
this.pidsLimit = pidsLimit;
this.cpus = cpus;
this.dockerImage = dockerImage;
this.envAllowlist = envAllowlist == null || envAllowlist.isEmpty()
? List.of("PATH", "SYSTEMROOT", "WINDIR", "COMSPEC", "PATHEXT",
"LANG", "LC_ALL", "PYTHONIOENCODING")
: List.copyOf(envAllowlist);
Path cacheRoot = Paths.get(cacheDir).toAbsolutePath().normalize();
this.uvCacheDir = cacheRoot.resolve("_uv").toString();
this.bootstrapFile = extractBootstrap(cacheRoot);
log.info("[skill-sandbox] mode={}, timeout={}s, max-output={}B, memory={}MB, cpu={}s, pids={}",
this.mode, timeoutSeconds, maxOutputBytes, memoryMb, cpuSeconds, pidsLimit);
}
public Mode getMode() {
return mode;
}
public boolean isSandboxEnabled() {
return mode != Mode.OFF;
}
/**
* 执行一个沙箱化命令。
*
* @param request 执行请求(脚本、skill 目录、参数、只读根、额外环境变量等)
* @return 结构化执行结果
* @throws IOException 进程启动失败
*/
public SandboxResult execute(SandboxRequest request) throws IOException {
Path script = request.script.toAbsolutePath().normalize();
if (!Files.isRegularFile(script)) {
throw new IllegalArgumentException("脚本不存在: " + script);
}
Path skillDir = request.skillDir == null
? script.getParent()
: request.skillDir.toAbsolutePath().normalize();
boolean ownWorkDir = request.workDir == null;
Path workDir = ownWorkDir ? Files.createTempDirectory("skill-sandbox-")
: request.workDir.toAbsolutePath().normalize();
Files.createDirectories(workDir);
Map<String, String> env = buildEnv(skillDir, request.readRoots, workDir, request.extraEnv);
List<String> command = buildCommand(request, script, skillDir, workDir);
long started = System.nanoTime();
Process process = null;
boolean timedOut = false;
try {
ProcessBuilder pb = new ProcessBuilder(command);
// 工作目录 = 本次执行的临时目录,脚本的相对路径写入都落在这里
pb.directory(workDir.toFile());
// 关键:清空继承的环境,只放白名单(防 os.environ 偷密钥)
pb.environment().clear();
pb.environment().putAll(env);
final Process startedProcess = pb.start();
process = startedProcess;
writeStdin(startedProcess, request.stdin);
StringBuilder stdout = new StringBuilder();
StringBuilder stderr = new StringBuilder();
boolean[] stdoutTruncated = {
false};
boolean[] stderrTruncated = {
false};
Thread outThread = new Thread(
() -> readBounded(startedProcess.getInputStream(), maxOutputBytes, stdout, stdoutTruncated));
Thread errThread = new Thread(
() -> readBounded(startedProcess.getErrorStream(), maxOutputBytes, stderr, stderrTruncated));
outThread.start();
errThread.start();
timedOut = !awaitCompletion(startedProcess, timeoutSeconds);
if (timedOut) {
log.warn("[skill-sandbox] 执行超时(>{}s),强杀进程树", timeoutSeconds);
destroyTree(startedProcess);
awaitTermination(startedProcess);
}
joinQuietly(outThread);
joinQuietly(errThread);
destroyDescendants(startedProcess);
long durationMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started);
log.info("[skill-sandbox] done mode={} exit={} timedOut={} truncated={} duration={}ms",
mode, process.exitValue(), timedOut, stdoutTruncated[0] || stderrTruncated[0], durationMs);
return new SandboxResult(
startedProcess.exitValue(),
stdout.toString(),
stderr.toString(),
timedOut,
stdoutTruncated[0] || stderrTruncated[0],
mode.name(),
durationMs);
} finally {
if (process != null && process.isAlive()) {
process.destroyForcibly();
}
if (ownWorkDir) {
deleteRecursively(workDir);
}
}
}
/** 构建子进程环境:仅白名单 + 沙箱内部约定变量,绝不继承宿主密钥 */
Map<String, String> buildEnv(Path skillDir, List<Path> readRoots, Path workDir, Map<String, String> extraEnv) {
Map<String, String> env = new LinkedHashMap<>();
for (String key : envAllowlist) {
String value = System.getenv(key);
if (value != null) {
env.put(key, value);
}
}
env.putIfAbsent("PYTHONIOENCODING", "utf-8");
// 固定哈希种子,保证同一脚本每次执行行为可复现
env.put("PYTHONHASHSEED", "0");
// 临时目录指向工作目录,脚本用 tempfile 建的文件同样落在沙箱内
env.put("TMP", workDir.toString());
env.put("TEMP", workDir.toString());
env.put("TMPDIR", workDir.toString());
env.put("SKILL_SANDBOX_WORKDIR", workDir.toString());
env.put("SKILL_SANDBOX_MEMORY_MB", String.valueOf(memoryMb));
env.put("SKILL_SANDBOX_CPU_SECONDS", String.valueOf(cpuSeconds));
if (mode == Mode.PROCESS || mode == Mode.OFF) {
// uv 缓存固定到沙箱自有目录,避免依赖/污染宿主用户目录
env.put("UV_CACHE_DIR", uvCacheDir);
env.put("UV_NO_PROGRESS", "1");
}
List<Path> roots = new ArrayList<>();
roots.add(skillDir);
if (readRoots != null) {
roots.addAll(readRoots);
}
// 只读根列表通过环境变量传给引导脚本:脚本能读这些目录,但不能写
env.put("SKILL_SANDBOX_READ_ROOTS", roots.stream()
.filter(p -> p != null)
.map(p -> p.toAbsolutePath().normalize().toString())
.distinct()
.collect(Collectors.joining(File.pathSeparator)));
if (extraEnv != null) {
extraEnv.forEach((k, v) -> {
if (k != null && !k.isBlank()) {
env.put(k, v);
}
});
}
return env;
}
private List<String> buildCommand(SandboxRequest request, Path script, Path skillDir, Path workDir) {
List<String> cmd = new ArrayList<>();
// docker 硬沙箱:一次性容器,网络、文件系统、内核权限全部收紧,跑完即毁(--rm)
if (mode == Mode.DOCKER) {
if (dockerImage == null || dockerImage.isBlank()) {
throw new IllegalStateException("docker 模式需要配置 app.ai.skill.sandbox.docker-image");
}
if (!SkillPathGuard.isWithin(skillDir, script)) {
throw new IllegalArgumentException("docker 模式要求脚本位于 skillDir 内: " + script);
}
String containerScript = "/skill/" + skillDir.relativize(script).toString().replace('\\', '/');
cmd.add("docker");
cmd.add("run");
cmd.add("--rm");
cmd.add("-i");
cmd.add("--network");
cmd.add("none");
cmd.add("--read-only");
cmd.add("--cap-drop");
cmd.add("ALL");
cmd.add("--security-opt");
cmd.add("no-new-privileges");
cmd.add("--pids-limit");
cmd.add(String.valueOf(pidsLimit));
cmd.add("--memory");
cmd.add(memoryMb + "m");
cmd.add("--cpus");
cmd.add(String.valueOf(cpus));
cmd.add("-v");
cmd.add(skillDir + ":/skill:ro");
cmd.add("-v");
cmd.add(bootstrapFile + ":/sandbox/sandbox_bootstrap.py:ro");
cmd.add("-v");
cmd.add(workDir + ":/work");
cmd.add("-w");
cmd.add("/work");
cmd.add("-e");
cmd.add("SKILL_SANDBOX_WORKDIR=/work");
cmd.add("-e");
cmd.add("SKILL_SANDBOX_READ_ROOTS=/skill");
cmd.add("-e");
cmd.add("SKILL_SANDBOX_MEMORY_MB=" + memoryMb);
cmd.add("-e");
cmd.add("SKILL_SANDBOX_CPU_SECONDS=" + cpuSeconds);
cmd.add("-e");
cmd.add("PYTHONIOENCODING=utf-8");
cmd.add("-e");
cmd.add("PYTHONHASHSEED=0");
cmd.add(dockerImage);
cmd.add("python");
cmd.add("-I");
cmd.add("/sandbox/sandbox_bootstrap.py");
cmd.add(containerScript);
} else {
// PROCESS / OFF:统一经 uv 解析 skill 项目依赖(保留阶段一执行方式),再注入引导脚本
if (request.useUvProject) {
cmd.add("uv");
cmd.add("run");
cmd.add("--quiet");
cmd.add("--project");
cmd.add(skillDir.toString());
cmd.add("python");
} else {
cmd.add("python");
}
if (mode == Mode.PROCESS) {
cmd.add("-I");
cmd.add(bootstrapFile.toString());
}
cmd.add(script.toString());
}
if (request.args != null) {
cmd.addAll(request.args);
}
return cmd;
}
private static Mode parseMode(String mode) {
if (mode == null || mode.isBlank()) {
return Mode.PROCESS;
}
try {
return Mode.valueOf(mode.trim().toUpperCase());
} catch (IllegalArgumentException e) {
log.warn("[skill-sandbox] 未知 sandbox.mode={},回退 process", mode);
return Mode.PROCESS;
}
}
private static Path extractBootstrap(Path cacheRoot) {
try {
Path dir = cacheRoot.resolve("_sandbox");
Files.createDirectories(dir);
Path target = dir.resolve("sandbox_bootstrap.py");
try (InputStream in = new ClassPathResource("sandbox/sandbox_bootstrap.py").getInputStream()) {
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
}
return target;
} catch (IOException e) {
throw new IllegalStateException("无法物化沙箱引导脚本 sandbox/sandbox_bootstrap.py", e);
}
}
private static void writeStdin(Process process, byte[] stdin) {
if (stdin == null) {
return;
}
try (OutputStream out = process.getOutputStream()) {
out.write(stdin);
} catch (IOException ignored) {
// 脚本未读取 stdin 属正常情况
}
}
/** 读取流,超过 maxBytes 的部分丢弃但继续排空管道,避免子进程写阻塞 */
private static void readBounded(InputStream input, int maxBytes, StringBuilder target, boolean[] truncated) {
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(input, StandardCharsets.UTF_8))) {
char[] buffer = new char[8192];
int total = 0;
int n;
while ((n = reader.read(buffer)) != -1) {
int room = maxBytes - total;
if (room > 0) {
int take = Math.min(n, room);
target.append(buffer, 0, take);
total += take;
if (take < n) {
truncated[0] = true;
}
} else {
truncated[0] = true;
}
}
} catch (IOException ignored) {
// 进程被杀后流提前关闭属正常情况
}
}
private static void destroyTree(Process process) {
process.descendants().forEach(ProcessHandle::destroyForcibly);
process.destroyForcibly();
}
private static void destroyDescendants(Process process) {
process.descendants().forEach(ProcessHandle::destroyForcibly);
}
private static void awaitTermination(Process process) {
try {
process.waitFor(5, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private static boolean awaitCompletion(Process process, int timeoutSeconds) {
try {
return process.waitFor(timeoutSeconds, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
private static void joinQuietly(Thread thread) {
try {
thread.join(5000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
/** 递归删除临时工作目录:脚本可能往 workdir 写入产物,执行完必须清理 */
private static void deleteRecursively(Path dir) {
if (dir == null || !Files.exists(dir)) {
return;
}
try (Stream<Path> walk = Files.walk(dir)) {
walk.sorted(Comparator.reverseOrder()).forEach(p -> {
try {
Files.deleteIfExists(p);
} catch (IOException ignored) {
}
});
} catch (IOException ignored) {
}
}
/** 沙箱执行请求 */
public static final class SandboxRequest {
private final Path script;
private final Path skillDir;
private final List<String> args;
private final List<Path> readRoots;
private final Map<String, String> extraEnv;
private final byte[] stdin;
private final Path workDir;
private final boolean useUvProject;
private SandboxRequest(Builder builder) {
this.script = builder.script;
this.skillDir = builder.skillDir;
this.args = builder.args == null ? List.of() : List.copyOf(builder.args);
this.readRoots = builder.readRoots == null ? List.of() : List.copyOf(builder.readRoots);
this.extraEnv = builder.extraEnv == null ? Map.of() : Map.copyOf(builder.extraEnv);
this.stdin = builder.stdin;
this.workDir = builder.workDir;
this.useUvProject = builder.useUvProject;
}
public static Builder builder() {
return new Builder();
}
public static final class Builder {
private Path script;
private Path skillDir;
private List<String> args;
private List<Path> readRoots;
private Map<String, String> extraEnv;
private byte[] stdin;
private Path workDir;
private boolean useUvProject;
public Builder script(Path script) {
this.script = script;
return this;
}
public Builder skillDir(Path skillDir) {
this.skillDir = skillDir;
return this;
}
public Builder args(List<String> args) {
this.args = args;
return this;
}
public Builder readRoots(List<Path> readRoots) {
this.readRoots = readRoots;
return this;
}
public Builder extraEnv(Map<String, String> extraEnv) {
this.extraEnv = extraEnv;
return this;
}
public Builder stdin(byte[] stdin) {
this.stdin = stdin;
return this;
}
public Builder workDir(Path workDir) {
this.workDir = workDir;
return this;
}
public Builder useUvProject(boolean useUvProject) {
this.useUvProject = useUvProject;
return this;
}
public SandboxRequest build() {
if (script == null) {
throw new IllegalStateException("script 必填");
}
return new SandboxRequest(this);
}
}
}
/** 沙箱执行结果 */
public static final class SandboxResult {
private final int exitCode;
private final String stdout;
private final String stderr;
private final boolean timedOut;
private final boolean outputTruncated;
private final String mode;
private final long durationMs;
SandboxResult(int exitCode, String stdout, String stderr, boolean timedOut,
boolean outputTruncated, String mode, long durationMs) {
this.exitCode = exitCode;
this.stdout = stdout;
this.stderr = stderr;
this.timedOut = timedOut;
this.outputTruncated = outputTruncated;
this.mode = mode;
this.durationMs = durationMs;
}
public int getExitCode() {
return exitCode;
}
public String getStdout() {
return stdout;
}
public String getStderr() {
return stderr;
}
public boolean isTimedOut() {
return timedOut;
}
public boolean isOutputTruncated() {
return outputTruncated;
}
public String getMode() {
return mode;
}
public long getDurationMs() {
return durationMs;
}
/** 执行成功的定义:未超时且退出码为 0 */
public boolean isSuccess() {
return !timedOut && exitCode == 0;
}
}
}
8.3 完整接入示例(真实 Skill 形态)
这一节按真实 Skill 的标准结构演示接入:skill 目录里有 SKILL.md 和 scripts 脚本,执行器负责跑脚本。执行器本身不依赖 Skill 机制,任何 .py 脚本都能跑,这里按项目里的实际用法演示。前置条件:JDK 9 以上、本机有 python 3.8 以上,可选 uv。
第一步,建三个文件。 引导脚本放到 src/main/resources/sandbox/sandbox_bootstrap.py(完整代码见第三节);SkillSandboxExecutor.java 放到你的包下(完整代码见 8.2);SkillPathGuard.java 从第一篇 7.2 复制,放到与执行器同包。运行时不需要手动拷贝引导脚本,执行器构造时会自动把它物化到 <cache-dir>/_sandbox/sandbox_bootstrap.py。
第二步,搭一个本地 skill 目录。 真实环境里这个目录由 Nacos 下载或 classpath 物化生成(阶段一做的事),这里手工搭一个等价结构用于演示。新建 .skills-cache/demo/:
.skills-cache/demo/
├── SKILL.md
└── scripts/
└── hello.py
SKILL.md 内容(说明书,agent 会读它来决定怎么调用):
---
name: demo
description: 演示用 skill
---
# Demo Skill
调用 scripts/hello.py,传入要打招呼的名字。
scripts/hello.py:
import sys
print("hello " + sys.argv[1])
第三步,写一个可运行的 Demo.java。 构造函数 10 个参数和请求字段的含义都写在注释里,按你的包名改 import 即可:
import com.example.security.SkillSandboxExecutor;
import java.nio.file.Path;
import java.util.List;
public class Demo {
public static void main(String[] args) throws Exception {
// 1. 创建执行器,参数按顺序:
// mode 沙箱模式:off | process | docker
// timeoutSeconds 超时秒数,超时后强杀整棵进程树
// maxOutputBytes stdout/stderr 各自最多捕获的字节数
// memoryMb 内存上限 MB,仅 POSIX(Linux/macOS)生效
// cpuSeconds CPU 时间上限秒,仅 POSIX 生效
// pidsLimit docker 模式容器内进程数上限
// cpus docker 模式 CPU 配额
// dockerImage docker 镜像名,process 模式留空
// envAllowlist 环境白名单,白名单外的宿主环境变量一律不继承
// cacheDir 缓存目录,引导脚本自动物化到 <cacheDir>/_sandbox/
SkillSandboxExecutor executor = new SkillSandboxExecutor(
"process",
30,
1024 * 1024,
512,
30,
64,
0.5,
"",
List.of("PATH", "SYSTEMROOT", "LANG"),
System.getProperty("user.dir") + "/.skills-cache");
// 2. 构造执行请求:脚本和 skill 目录都来自上面的 skill 结构
SkillSandboxExecutor.SandboxRequest req = SkillSandboxExecutor.SandboxRequest.builder()
.script(Path.of(".skills-cache/demo/scripts/hello.py").toAbsolutePath())
.skillDir(Path.of(".skills-cache/demo").toAbsolutePath())
.args(List.of("world"))
.build();
// 3. 执行并处理结果
SkillSandboxExecutor.SandboxResult result = executor.execute(req);
if (result.isTimedOut()) {
System.out.println("执行超时(" + result.getDurationMs() + "ms 后强杀)");
} else if (result.getExitCode() != 0) {
System.out.println("执行失败,退出码 " + result.getExitCode());
System.out.println(result.getStderr());
} else {
System.out.println("标准输出:" + result.getStdout());
if (result.isOutputTruncated()) {
System.out.println("(输出超过上限,已截断)");
}
}
}
}
运行后预期输出:
标准输出:hello world
想验证拦截是否生效,把 scripts/hello.py 换成下面的内容再跑一次,会得到非零退出码,stderr 里出现 PermissionError: blocked by skill sandbox: socket.__new__:
import socket
socket.socket()
第四步(可选),用 Spring 时加配置。 执行器本身就是 @Component,直接注入即可;yml 里的值都有默认值,想调整才写:
app:
ai:
skill:
sandbox:
mode: process # off | process | docker
timeout-seconds: 120
max-output-bytes: 1048576
memory-mb: 512
cpu-seconds: 30
pids-limit: 64
cpus: 0.5
docker-image: ""
env-allowlist: [PATH, SYSTEMROOT, WINDIR, COMSPEC, PATHEXT, LANG, LC_ALL, PYTHONIOENCODING]
不用 Spring 的话,构造函数里的参数就是全部配置入口,yml 可以不要。
常见变体。
- skill 带 Python 依赖:在 skill 目录里加
pyproject.toml,请求里加上.useUvProject(true),依赖解析交给 uv(项目里的 PdfService 就是这么用的)。 - 脚本需要读宿主文件:把文件所在目录放进
.readRoots(List.of(inputFile.getParent())),脚本只能读不能写。 - 切 docker 模式:mode 传
"docker"并填dockerImage,镜像需预装 Python 和依赖;docker 模式要求脚本位于 skillDir 内。
常见问题。
- 报 python 找不到:PATH 不在环境白名单里,或本机没装 python,检查
envAllowlist和系统环境。 - Windows 上内存、CPU 上限不生效:正常现象,rlimit 只在 POSIX 系统存在,超时和输出上限仍然有效。
- 输出被截断告警:脚本输出太大,调大
max-output-bytes。 - 用 uv 报找不到 pyproject.toml:skill 目录里需要有
pyproject.toml。
8.4 快速自测
不需要 Java 环境,把引导脚本和下面三个测试脚本放在同一目录,直接执行:
# 良性脚本,应输出 hello
python -I sandbox_bootstrap.py hello.py
# 联网脚本,应报 PermissionError: blocked by skill sandbox: socket.__new__
python -I sandbox_bootstrap.py evil_socket.py
# 越权写文件,应报 PermissionError: blocked write outside workdir
python -I sandbox_bootstrap.py evil_write.py
# hello.py
print("hello")
# evil_socket.py
import socket
socket.socket()
# evil_write.py
open("../evil.txt", "w").write("owned")
Windows 上 rlimit 不生效属于正常现象,超时和输出上限仍然有效。
8.5 不用 Spring 的用法
SkillSandboxExecutor 的 Spring 注解只有 @Component 和构造参数上的 @Value,手动 new 即可脱离容器使用(见 8.3 第三步)。唯一要注意的是物化引导脚本时用到了 ClassPathResource(spring-core 的工具类),不想依赖 Spring 就把它换成标准类加载器:
try (InputStream in = SkillSandboxExecutor.class.getResourceAsStream("/sandbox/sandbox_bootstrap.py")) {
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
}
8.6 接入点清单
- 用
SkillSandboxExecutor替换所有执行 skill 脚本的裸ProcessBuilder; - 调用方只传脚本、skill 目录、参数,输入文件目录一律走
readRoots(只读),不要传宿主写路径; - 需要脚本产物时,让脚本把产物写进工作目录,再由 Java 按白名单复制回宿主(阶段三会把这个流程规范化为产物白名单)。
九、知识点小结
- 环境泄露比代码执行更常见。
ProcessBuilder默认继承全部环境变量,脚本只要读os.environ就能拿到。先clear()再按白名单放行,是成本最低、收益最高的一步。 - 进程级软沙箱和应用进程同生共死,恶意代码可以绕过。
--network none --read-only --cap-drop ALL这类内核隔离才是真正的安全边界。两者是纵深关系,不是替代关系。 - 审计钩子要兼容平台差异。
open审计事件在不同平台/版本上参数形态不同,只认一种就会漏拦或误拦。 - 杀进程要杀整棵树。
destroyForcibly只杀掉父进程,要先descendants()再杀,同时审计钩子拦掉subprocess.*,两道一起上。 - 输出上限要截断但不停止读取。停读会让子进程阻塞在管道上,截断并继续读到 EOF 才能既限制输出又不拖住执行。
--read-only下uv run建不了.venv。容器级硬沙箱要预构建镜像,把依赖解析从运行时剥离。
阶段二解决了执行失控的问题,但脚本输出的结果目前还是被当作可信内容直接使用。输出有没有可能是恶意构造的 JSON?签名链路是不是该从 HMAC 升级成非对称?权限声明要不要变成审批记录?这些留给阶段三(输出校验 + 供应链治理闭环)。
本文来自一次真实的加固实践。印象最深的是两处:一是 ProcessBuilder 的环境变量继承,一个空脚本就能把宿主密钥全部打印出来;二是 Python 审计事件参数在 Windows 上直接抛 ValueError。阶段一保证下载的内容可信,阶段二保证恶意脚本跑不出沙箱,下一篇见。