【雕虫大技】Agent 动态 Skill 供应链安全加固(二):执行隔离沙箱实战

简介: 阶段二给 Skill 脚本搭执行沙箱:环境白名单、强超时、输出上限常驻,审计钩子加容器隔离双保险,默认拒绝、显式授权。

一、背景:静态扫描的盲区

第一篇我们给 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_KEYANTHROPIC_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.systemctypes.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.*urllibhttpftplib 底层都走 socket,一并被拦 联网搜索类 skill,需显式申请 network 权限
起新进程 audit hook 拦 subprocess.* 依赖外部命令行工具的 skill,需显式申请
执行系统命令 / 加载原生库 audit hook 拦 os.systemctypes.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 的构建逻辑是:从宿主环境只拷贝白名单里的键(PATHSYSTEMROOTLANGPYTHONIOENCODING 等),再显式注入沙箱内部约定变量(SKILL_SANDBOX_WORKDIRSKILL_SANDBOX_MEMORY_MB,以及指向工作目录的 TMP/TEMP 等)。白名单之外的键一律不出现。DATABASE_URLNACOS_PASSWORDDEEPSEEK_API_KEYJWT_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 可以不要。

常见变体。

  1. skill 带 Python 依赖:在 skill 目录里加 pyproject.toml,请求里加上 .useUvProject(true),依赖解析交给 uv(项目里的 PdfService 就是这么用的)。
  2. 脚本需要读宿主文件:把文件所在目录放进 .readRoots(List.of(inputFile.getParent())),脚本只能读不能写。
  3. 切 docker 模式:mode 传 "docker" 并填 dockerImage,镜像需预装 Python 和依赖;docker 模式要求脚本位于 skillDir 内。

常见问题。

  1. 报 python 找不到:PATH 不在环境白名单里,或本机没装 python,检查 envAllowlist 和系统环境。
  2. Windows 上内存、CPU 上限不生效:正常现象,rlimit 只在 POSIX 系统存在,超时和输出上限仍然有效。
  3. 输出被截断告警:脚本输出太大,调大 max-output-bytes
  4. 用 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 接入点清单

  1. SkillSandboxExecutor 替换所有执行 skill 脚本的裸 ProcessBuilder
  2. 调用方只传脚本、skill 目录、参数,输入文件目录一律走 readRoots(只读),不要传宿主写路径;
  3. 需要脚本产物时,让脚本把产物写进工作目录,再由 Java 按白名单复制回宿主(阶段三会把这个流程规范化为产物白名单)。

九、知识点小结

  1. 环境泄露比代码执行更常见ProcessBuilder 默认继承全部环境变量,脚本只要读 os.environ 就能拿到。先 clear() 再按白名单放行,是成本最低、收益最高的一步。
  2. 进程级软沙箱和应用进程同生共死,恶意代码可以绕过--network none --read-only --cap-drop ALL 这类内核隔离才是真正的安全边界。两者是纵深关系,不是替代关系。
  3. 审计钩子要兼容平台差异open 审计事件在不同平台/版本上参数形态不同,只认一种就会漏拦或误拦。
  4. 杀进程要杀整棵树destroyForcibly 只杀掉父进程,要先 descendants() 再杀,同时审计钩子拦掉 subprocess.*,两道一起上。
  5. 输出上限要截断但不停止读取。停读会让子进程阻塞在管道上,截断并继续读到 EOF 才能既限制输出又不拖住执行。
  6. --read-onlyuv run 建不了 .venv。容器级硬沙箱要预构建镜像,把依赖解析从运行时剥离。

阶段二解决了执行失控的问题,但脚本输出的结果目前还是被当作可信内容直接使用。输出有没有可能是恶意构造的 JSON?签名链路是不是该从 HMAC 升级成非对称?权限声明要不要变成审批记录?这些留给阶段三(输出校验 + 供应链治理闭环)。


本文来自一次真实的加固实践。印象最深的是两处:一是 ProcessBuilder 的环境变量继承,一个空脚本就能把宿主密钥全部打印出来;二是 Python 审计事件参数在 Windows 上直接抛 ValueError。阶段一保证下载的内容可信,阶段二保证恶意脚本跑不出沙箱,下一篇见。

目录
相关文章
|
6天前
|
存储 弹性计算 缓存
阿里云服务器租赁费用:新版租赁收费标准及活动报价参考
本文更新了2026年阿里云全系列云服务器租赁活动报价,所有特惠资源均可前往阿里云活动中心选购,整体覆盖从个人入门到企业级高性能场景的全梯度需求。其中轻量应用服务器主打极致性价比,2核2G峰值200M带宽配置每日10点、15点限时抢购价仅38元/年,2核4G配置379元/年起;高性价比的经济型e实例、通用算力型u2i实例覆盖2核4G至4核32G全档位,适配开发测试与中小型企业业务;搭载英特尔至强6处理器的第九代c9i企业级实例算力较上代提升20%,支撑高并发生产环境,不同实例规格价差清晰,用户可根据自身业务负载与预算灵活选型。
1601 116
|
7天前
|
人工智能 程序员 API
Codex 接入 DeepSeek-V4-Flash:还能补上识图,提供两套方案
Codex 接入 DeepSeek-V4-Flash 怎么配?本文覆盖 CLI 与桌面端,再用 qwen3-vl-flash 补识图,两套方案可直接照做
1086 5
|
13天前
|
云安全 人工智能 运维
阿里云联动百位企业安全专家,共识Agent防御最佳实践
当Agent成为新员工,你的安全边界在哪里?
1954 9
阿里云联动百位企业安全专家,共识Agent防御最佳实践
|
7天前
|
编解码 人工智能 安全
2核4G/4核8G/8核16G阿里云服务器如何选择实例?经济型e、通用算力型u2i与计算型c9i选哪个?
本文介绍了阿里云2核4G、4核8G、8核16G三档主流配置下经济型e、通用算力型u2i和计算型c9i三种实例的最新活动价格与适用场景。同配置下三者价差显著,以2核4G为例,经济型e低至599.93元/年,计算型c9i则高达1742.08元/年。文章详细解析了各实例的性能定位:经济型e适合轻负载入门场景,u2i兼顾稳定算力与性价比,c9i凭借第9代至强处理器与芯片级安全能力支撑高性能业务。同时提示用户可叠加满减优惠券享受折上折,建议根据业务负载与预算综合决策。
536 112
|
19天前
|
人工智能 前端开发 Linux
Codex 桌面版安装 + CC Switch 接入第三方 API 完整教程(2026 最新)
2026最新教程:手把手教你安装Codex桌面版,通过CC Switch v3.17.0一键接入Fenno等国产API(兼容OpenAI Responses格式),跳过账号登录,完整启用代码审查、多步任务与上下文感知功能。零基础友好,全程图文实操。(239字)
2726 4
|
11天前
|
存储 人工智能 关系型数据库
阿里云AI产品与云产品最新组合套餐:Token Plan、AI coding及云服务器和建站等组合优惠价
阿里云推出全新“算力+模型+应用”一站式云与AI组合套餐活动,覆盖从个人开发者到中大型企业的全场景需求。核心亮点为分三档定价的Token Plan订阅服务,支持Qwen3.8-Max-Preview大模型调用,错峰时段最低可享0.2折优惠。活动同步推出AI Coding、智能体部署、云电脑托管、0代码建站等十余类场景化组合,搭配99元/年的普惠云服务器、88元/年的入门数据库等经典特惠产品,还为企业提供1V1定制化AI转型方案,大幅降低了不同用户群体拥抱AI的技术门槛与采购成本。
729 111
|
21天前
|
人工智能 JSON 安全
Fastjson远程代码执行漏洞,阿里云AI安全为您保驾护航
阿里云AI安全产品联动防御Fastjson攻击
2651 13
Fastjson远程代码执行漏洞,阿里云AI安全为您保驾护航
|
7天前
|
人工智能 JSON Shell
2026AI漫剧本地全开源方案(附各个软件模型链接),8G显卡也能流畅运行
这是一套完全本地化部署的AI漫剧生成技术链路:涵盖LLM剧本分镜生成、FLUX文生图(IP-Adapter人脸锁定)、StoryDiffusion时序连贯控制、LTX-2.3唇形同步视频生成,及ComfyUI全流程调度。零云端费用,仅耗硬件算力,单集2–4小时可产出竖屏短视频,适配抖音/B站分发。
|
5天前
|
人工智能 API 开发工具
2026 零基础本地 AI 漫剧完整实操教程(8G 笔记本显卡可用|附可直接复制命令与代码)
本方案提供完全离线、本地运行的漫剧全自动制作流程:RTX3060/4050 8G显卡即可驱动,涵盖Qwen写分镜→ComfyUI统一角色绘图→LTX2.3图生微动画→Qwen3-TTS本地配音→FFmpeg自动合成,全程无水印、免API、不限次。专为低显存优化,解决变脸、闪烁、爆内存三大痛点。(239字)

热门文章

最新文章