Dark Dwarf Blog background

Agent 沙箱设计

本文整理自 OpenAI《A Practical Guide to Building Agents》、Anthropic Claude Code Sandboxing、Trail of Bits 关于容器逃逸的研究、Netdata 对 Docker socket 安全风险的说明、Rory McCune 关于 docker.sock 挂载风险的经典分析,以及 12-Factor Agents。并结合个人开发过程整理而成。

Agent 沙箱设计

1. 容器与沙箱的一些基础知识

a.a. Docker 的一些基础知识

Docker 是一个把应用程序和它运行所需的依赖、配置、文件系统打包在一起,然后在一个相对隔离的环境里运行的工具。打包好的静态模板叫镜像(image),运行起来的实例叫容器(container)。同一个镜像可以启动多个容器,每个容器有自己的进程空间和可写层。

VM 通过 Hypervisor 虚拟出一套完整的硬件,里面跑独立的操作系统内核;容器则和宿主机共享同一个内核,只隔离进程视图和资源。所以容器更轻量,但隔离强度也弱于 VM——一旦突破内核隔离,就可能影响宿主机。

dockerd 就是 Docker 的后台服务进程(daemon),通常以 root 身份运行。它负责管理镜像、容器、网络、卷等资源。我们平时敲的 docker 命令只是客户端,真正干活的是 dockerd。

有了这几个概念,下面再介绍下 Docker 的 C/S 架构和具体隔离机制。

b.b. Docker 架构:client、daemon 与 runc

Docker 是 C/S(Client/Server)架构。我们平时在终端里敲的 docker run、docker ps、docker compose up 这些命令,其实只是一个命令行客户端(docker CLI)。它本身并不直接创建容器,而是把请求发给后台一个长期运行的服务进程——dockerd。

这个 daemon 通常以 root 身份跑在宿主机上,监听一个 Unix domain socket 文件 /var/run/docker.sock。当你执行 docker run 时,CLI 实际上做了下面这件事:

[你] 输入 docker run -it ubuntu bash
  ↓
[docker CLI] 把命令转成 HTTP 请求
  ↓
[/var/run/docker.sock] 发给 dockerd(root 进程)
  ↓
[dockerd] 解析请求,决定创建什么容器
  ↓
[containerd] Docker 调用的容器生命周期管理器
  ↓
[runc] 真正调用 Linux namespaces / cgroups 创建并启动容器进程

所以 dockerd 是整个链条里权限最高的节点:它能决定启动什么容器、挂载哪些宿主机目录、给容器分配哪些 capability。任何能直接向 /var/run/docker.sock 发请求的进程,都可以让 dockerd 以 root 身份替它执行这些操作。这也是为什么后面会说“挂载 docker.sock 进容器等于给容器 root 权限”——不是容器自己变成了 root,而是容器里的进程可以指挥一个 root 进程去干活。

containerd 和 runc 的关系可以简单理解为:containerd 负责“管理容器生命周期”(创建、启停、监控),runc 负责“真正执行创建容器的那几个系统调用”(clone、mount、setns 等)。普通用户一般不需要直接碰它们,但理解这个分层有助于理解 Docker 的安全边界。

Docker 里还有一个常用模式叫 sidecar:与主容器一起部署、为主容器提供辅助能力的另一个容器。它跟主容器共享网络(或至少互通),但进程和文件系统独立。Agent Docker MCP 的 strict 模式就用 sidecar 做 Docker API 代理——沙箱容器本身不挂 docker.sock,而是访问一个 socket-proxy sidecar,由 sidecar 决定哪些请求可以转发给宿主机 docker.sock。

容器不是 VM,它和宿主机共享同一个 Linux 内核。Docker 提供的隔离来自下面的 Linux 机制。

c.c. Linux 内核隔离机制

机制隔离什么对 Agent 沙箱的意义
NamespacesPID、网络、挂载点、主机名等视图Agent 看不到宿主机进程,有自己的网络栈和文件系统视图
cgroupsCPU / 内存 / IO / PID 等资源防止 Agent fork bomb 或写爆磁盘
Capabilities细粒度 root 权限拆分不用给完整 root,只给必要能力
seccomp / AppArmor系统调用过滤 / 强制访问控制进一步缩小攻击面

d.d. Docker 运行选项

除了这些内核机制,Docker 还提供了几个直接影响安全模型的运行选项:

选项作用沙箱设计时的典型用法
--cap-drop ALL / --cap-add丢弃/补回 Linux capabilities先给予最小权限,然后添加必需能力
--security-opt no-new-privileges禁止提升权限让 sudo 失效,防止 setuid 逃逸
--user / -u以非 root 用户运行降低容器内进程 UID 权限
--read-only根文件系统只读限制容器内随意写文件
--tmpfs挂载临时可写层给编译/缓存等临时写操作一个出口
--network none/bridge/host网络隔离策略默认 host,strict 模式切 bridge
-v / --mount挂载宿主机路径沙箱内最常见的逃逸面

下面的一个 Agent 沙箱设计就是通过在这些参数上做文章来实现简单的 Agent Sandbox 安全隔离的。

e.e. docker.sock 的一些知识

因为 daemon 以 root 运行,任何能向 /var/run/docker.sock 写入的进程都可以让 daemon 以 root 身份执行任意 Docker API 调用。把 docker.sock 挂进容器,等于让容器里的进程拥有了“让 root 代执行命令”的能力。Netdata 的文档说得很直接:

“Mounting /var/run/docker.sock into a container grants that container a root-equivalent privilege boundary. This is not a Docker vulnerability.”

DooD 的本质是把宿主机 root 的代执行权交给容器,而这个权力发生在容器隔离管不到的边界之外。这也是后面 strict 模式必须做 API 代理的根本原因。

f.f. 进程树沙箱:SRT

nonoka-cli 这个项目没有走 Docker 容器路线,而是用了 Anthropic 的 Sandbox Runtime(SRT)。SRT 不是容器,而是一个进程树级沙箱:Linux 下用 bubblewrap(bwrap),macOS 下用 sandbox-exec,通过显式 allowlist 实现 deny-by-default。

bubblewrap 是一个轻量级的 Linux 沙箱工具,最初来自 Flatpak 项目。它通过 Linux namespaces、cgroups 和 seccomp 创建隔离环境,但不需要 Docker daemon 那样的 root 后台服务。普通用户(在支持 unprivileged user namespaces 的系统上)就能用它来启动一个受限的进程树,所以很适合给交互式 CLI 做“随身沙箱”。

维度Docker 沙箱SRT 进程树沙箱
隔离粒度整个容器单个进程树
跨平台依赖 Linux/Windows 容器Linux 用 bwrap,macOS 用 sandbox-exec
网络策略通常 bridge/none/host 三选一基于域名 allowlist 的 TLS 代理
典型场景执行单条离线命令包裹整个交互式 TUI
对宿主机影响需要 Docker daemon不需要 root daemon

SRT 的边界覆盖文件系统(能读/写哪些路径)和网络(能连哪些域名),并且作用于整个进程树——Node 主进程、provider 子进程、bridge 子进程、用户 MCP servers 以及 OpenCode 原生的 bash 工具都会被罩在同一个策略里。

容器和进程树沙箱默认都不隔离内核,只隔离视图和资源。只要进程能拿到足够的 capability 或能影响宿主机内核,逃逸就可能发生。这也是为什么 --privileged 这么危险。Trail of Bits 在分析容器逃逸时指出:

“The --privileged flag introduces significant security concerns… When using this flag, containers have full access to all devices and lack restrictions from seccomp, AppArmor, and Linux capabilities.”

一旦关闭这三层限制,容器基本就是宿主机 root。反过来看,设计沙箱的核心工作就是:在 capabilities、seccomp、AppArmor、cgroup 这些旋钮上找到“足够 Agent 干活,但尽量不给多余权限”的组合。

2. Agent 沙箱的三层安全模型

OpenAI 在《A Practical Guide to Building Agents》里把 guardrails 描述成一种分层防御:

“Think of guardrails as a layered defense mechanism. While a single one is unlikely to provide sufficient protection, using multiple, specialized guardrails together creates more resilient agents.”

这个“分层”放到沙箱设计上可以分为三个层次:强制边界、软护栏、已知残余风险。

a.a. 强制边界

强制边界可以通过配置前面说的容器参数以及对容器本身进行限制来实现,这些限制由内核或容器运行时强制执行,容器内进程无法通过常规手段绕过。以 Agent Docker MCP 的默认沙箱配置为例:

// docker-agent/src/sandbox.ts:170-183
HostConfig: {
  Memory: resources.memoryMb * 1024 * 1024,
  NanoCpus: Math.round(resources.cpus * 1e9),
  PidsLimit: resources.pidsLimit,
  Init: true,
  CapDrop: ["ALL"],
  CapAdd: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"],
  SecurityOpt: ["no-new-privileges:true"],
},

容器以宿主机当前用户的 uid:gid 运行,不开启 root。这些参数就是第 1 节介绍的 Docker 运行选项,各自做了这些事:

  • CapDrop: ["ALL"] + 5 个 CapAdd:清空 capabilities,只保留 apt/dpkg 装包必需的能力。
  • SecurityOpt: ["no-new-privileges:true"]:禁止通过 setuid 程序提权。
  • cgroups 三限额:限制内存、CPU、进程数。
  • 非 root 用户:容器进程以宿主机普通用户身份运行。

Anthropic 在 Claude Code 的 sandboxing 博客里强调了两个边界的缺一不可:

“Our approach to sandboxing is built on top of operating system-level features to enable two boundaries: Filesystem isolation… Network isolation… It is worth noting that effective sandboxing requires both filesystem and network isolation.”

b.b. 软护栏

软护栏比较简单,就是基于规则的轻量拦截,拦住 rm -rf /、mkfs.、dd of=/dev/、git push --force 这类明显危险的操作。

不过需要注意这不是安全边界。字符串过滤很容易通过别的方式被绕过,比如 Agent 可以换行、用变量、用反引号、用 base64 之类乱七八糟的方法。它的主要作用是对 Agent 在一次正常的 tool call 里直接扔出 rm -rf / 这种操作时,能快速简单地拦截住,给用户一个明确的失败理由。

Trail of Bits 在容器逃逸研究里也表达过类似的精神:真正安全来自细粒度的权限控制,而不是寄希望于攻击者按你设想的字符串格式输入。

“Docker restricts and limits containers by default. Loosening these restrictions may create security issues, even without the full power of the --privileged flag.”

c.c. DooD 的风险

Agent Docker MCP 的 README 里有这样的内容:

“Known residual risk in default mode: the container has access to the host’s docker.sock (DooD) and may use host networking on Linux. This is equivalent to giving the agent local root access. Only use default mode for projects you trust.”

DooD(Docker-out-of-Docker)会把宿主机的 /var/run/docker.sock 挂进容器。Netdata 的文档提到了这个操作的风险:

“Mounting /var/run/docker.sock into a container grants that container a root-equivalent privilege boundary. This is not a Docker vulnerability.”

所以 docker-agent 的默认模式不是“有沙箱”,而是“给 Agent 一个带资源限额的、但仍然等价于 root 的环境”。这是这个设计的边界声明:默认模式为本地开发 convenience 牺牲了一部分隔离,用户必须知情。

层级代表机制能否被熟练攻击者绕过设计态度
强制边界cgroups、CapDrop ALL、no-new-privileges、non-root极难核心信任根基
软护栏正则黑名单容易Convenience,不冒充安全
已知残余风险DooD、host 网络等同于授权必须写进 README

3. 半可信 DooD:socket-proxy + 请求体过滤的第三条路

DooD 场景下有一个经典矛盾:完全不给 docker.sock,Agent 没法跑 docker compose;给了 docker.sock,Agent 就能够拿到宿主机 root。常见的两种做法都不理想:

  • 完全隔离:不挂 docker.sock,Agent 无法使用 Docker。如果 Agent 需要启动 docker compose 环境的话就没法做了。
  • 完全信任:直接挂 docker.sock,Agent 获得 root 等价物。这是 docker-agent 默认模式的做法,本质是 convenience first。

一种比较中和的设计是把 docker.sock 变成一个受控的 API 网关:Agent 不直接访问 docker.sock,而是访问一个代理;代理按策略决定是否把请求转发给真正的 docker.sock。

a.a. 通用设计:三层过滤模型

一个合理的 DooD 代理至少要做三层检查:

  1. 身份/网络层:谁可以连到代理?只能从沙箱容器内访问,不能让宿主机上其他进程或同一网络里的其他容器滥用。
  2. 端点层:允许调用哪些 Docker API 端点?比如允许 /containers/create、/containers/start,但拒绝删除宿主机卷的敏感端点。
  3. 载荷层:对允许的端点,检查请求体里的参数。比如 POST /containers/create 合法,但请求体里写 "Privileged": true 就不合法。

其中第三层最容易被忽略。很多 API 网关只做到端点层,然后攻击者可能用合法端点创建特权容器导致沙箱逃逸。

通用原则:当 API 的“调用什么”和“怎么调用”都能决定安全性时,权限边界必须放在请求载荷上,而不是端点上。

这个原则不仅适用于 Docker API,也适用于任何 Agent tool call 的 pre_tool guardrails。12-Factor Agents 里 Factor 4 也强调:

“This creates a clean separation between the LLM’s decision-making and your application’s actions. The LLM decides what to do, but your code controls how it’s done.”

b.b. 一个具体实现

Agent Docker MCP 的 --strict 模式就是上述三层过滤的一个具体实现,整体链路如下:

沙箱容器 → 自研 filter-shim → socket-proxy → 宿主机 docker.sock

i.i. 端点层:socket-proxy

中间层用的是 wollomatic/socket-proxy,它的职责是把 Docker API 端点白名单化。项目中大约允许了 30 多个端点,覆盖容器、网络、卷、镜像、compose 所需操作:

// docker-agent/src/sidecar.ts:82-131
function proxyAllowListArgs(projectDir: string): string[] {
  return [
    "-loglevel=info",
    "-listenip=0.0.0.0",
    "-proxyport=2375",
    "-shutdowngracetime=5",
    // 仅允许 filter shim 连接
    `-allowfrom=${FILTER_HOSTNAME}`,
    // 限制 bind mount 源路径必须在项目目录内
    `-allowbindmountfrom=${projectDir}`,

    // GET
    "-allowGET=/_ping",
    "-allowHEAD=/_ping",
    "-allowGET=/version",
    "-allowGET=/v1\\..*/version",
    "-allowGET=/info",
    "-allowGET=/v1\\..*/info",
    "-allowGET=/v1\\..*/containers/json",
    "-allowGET=/v1\\..*/containers/.*/json",
    "-allowGET=/v1\\..*/containers/.*/logs",
    "-allowGET=/v1\\..*/exec/.*/json",
    "-allowGET=/v1\\..*/networks.*",
    "-allowGET=/v1\\..*/volumes.*",
    "-allowGET=/v1\\..*/images.*",
    "-allowGET=/v1\\..*/events.*",

    // POST
    "-allowPOST=/v1\\..*/containers/create.*",
    "-allowPOST=/v1\\..*/containers/.*/start.*",
    "-allowPOST=/v1\\..*/containers/.*/stop.*",
    "-allowPOST=/v1\\..*/containers/.*/kill.*",
    "-allowPOST=/v1\\..*/containers/.*/wait.*",
    "-allowPOST=/v1\\..*/containers/.*/attach.*",
    "-allowPOST=/v1\\..*/containers/.*/exec",
    "-allowPOST=/v1\\..*/containers/.*/restart.*",
    "-allowPOST=/v1\\..*/exec/.*/start",
    "-allowPOST=/v1\\..*/networks/create",
    "-allowPOST=/v1\\..*/networks/.*/connect",
    "-allowPOST=/v1\\..*/networks/.*/disconnect",
    "-allowPOST=/v1\\..*/volumes/create",
    "-allowPOST=/v1\\..*/images/create",
    "-allowPOST=/v1\\..*/build.*",

    // DELETE
    "-allowDELETE=/v1\\..*/containers/.*",
    "-allowDELETE=/v1\\..*/networks/.*",
    "-allowDELETE=/v1\\..*/volumes/.*",
    "-allowDELETE=/v1\\..*/images/.*",
  ];
}

ii.ii. 载荷层:filter-shim

Agent Docker MCP 通过 HTTP filter-shim 来实现载荷层校验,检查 POST /containers/create 的请求体:

// docker-agent/src/proxy/body-filter.ts:101-218
function validateContainerCreate(
  body: unknown,
  projectDir?: string,
): FilterResult {
  // ...
  if (config.Privileged === true) {
    return { allowed: false, reason: "Privileged containers are not allowed" };
  }
  if (config.NetworkMode === "host") {
    return { allowed: false, reason: "Host network mode is not allowed" };
  }
  if (config.PidMode === "host") {
    return { allowed: false, reason: "Host PID namespace is not allowed" };
  }
  // 检查 Devices、Runtime、CapAdd、SecurityOpt、Binds/Mounts 路径逃逸 ...
}

危险能力列表有 25 个,包括 SYS_ADMIN、SYS_PTRACE、NET_ADMIN、AUDIT_CONTROL、MAC_ADMIN 等;敏感路径前缀包括 /var/run/docker.sock、/proc、/sys、/dev、/etc、/root。

c.c. identity-mount 与 host 网络

DooD 还有一个隐藏的问题:容器内的相对路径 ./data,在 daemon 看来是在宿主机上解析的。如果 Agent 在沙箱里跑 docker run -v ./data:/data,daemon 会按宿主机的当前工作目录解析 ./data,而不是按沙箱内的项目目录。这会导致路径错位。

Agent Docker MCP 对这个问题采用了 identity-mount 的解决方法:项目目录在宿主机和容器内保持相同的绝对路径:

// docker-agent/src/sandbox.ts:100-101
const binds: string[] = [`${config.workDir}:${config.workDir}`];

filter-shim 再用 resolveBindSource 把相对路径在项目目录下解析成绝对路径,防止 ../ 逃逸:

// docker-agent/src/proxy/body-filter.ts:83-99
function resolveBindSource(source: string, projectDir?: string): string {
  const normalized = source.replace(/\\/g, "/");
  if (normalized.startsWith("/")) return path.normalize(normalized);
  if (!projectDir) return normalized;
  const base = projectDir.replace(/\\/g, "/").replace(/\/$/, "");
  if (normalized === ".") return path.normalize(base);
  if (normalized.startsWith("./")) {
    return path.normalize(`${base}/${normalized.slice(2)}`);
  }
  return path.normalize(`${base}/${normalized}`);
}

另一个取舍是默认 host 网络。Linux 上 docker-agent 默认把沙箱配成 host 网络,原因是 Agent 经常需要访问宿主机的本地开发服务,比如 curl localhost:3306。strict 模式则强制切到 bridge。

4. 基于 SRT 的 Agent Sandbox

前面的讨论解决的是“Agent 在容器里跑命令”的隔离问题,Agent 是在容器内执行的。Agent sandbox 还有下面的设计思路:Agent 进程树本身就在宿主机上跑,如何把它对文件系统和网络的访问面缩到最小?

容器沙箱的问题是它假设 Agent 能被放进一个容器。但交互式 CLI(比如 OpenCode TUI)本身需要访问终端、用户配置、IDE 扩展,很难整个塞进 Docker。对此,我们可以用 Anthropic 的 Sandbox Runtime(SRT)把 TUI 之类应用的整个进程树包起来。

a.a. SRT 配置

class SafetyConfig(BaseModel):
  """Runtime command and filesystem restrictions."""

  enabled: bool = True
  sandbox: str = "docker"  # auto | srt | docker | disabled
  required: bool = False
  allowed_roots: list[Path] = Field(default_factory=list)
  allow_read: list[Path] = Field(default_factory=list)
  allow_write: list[Path] = Field(default_factory=list)
  deny_read: list[Path] = Field(default_factory=list)
  deny_write: list[Path] = Field(
    default_factory=lambda: [Path(".env"), Path(".git/hooks")]
  )
  network_profile: Literal["strict", "package-registries"] = "strict"
  allowed_domains: list[str] = Field(default_factory=list)
  command_timeout_seconds: int = Field(default=120, ge=1)
  max_output_bytes: int = Field(default=1_000_000, ge=1024)
  docker_image: str = "alpine:3.20"

几个字段的含义如下:

  • allow_read / allow_write / deny_read / deny_write:文件系统白名单/黑名单。
  • network_profile + allowed_domains:网络出口策略。

这个配置模型让沙箱行为可以按项目、按用户甚至按命令来定制,而不是写死在代码里。

b.b. 从配置到 SRT settings:文件系统 + 网络边界

SrtSandbox.settings(workspace) 根据 SafetyConfig 生成一个临时 JSON 文件,作为 bubblewrap/sandbox-exec 的策略输入。它主要声明两件事:文件系统边界和网络边界。

# nonoka-cli/src/nonoka_cli/safety/sandbox.py:50-77
def settings(self, workspace: Path) -> Path:
  credential_names = [
    name for name in ("OPENAI_API_KEY", "DEEPSEEK_API_KEY", "ANTHROPIC_API_KEY")
    if os.environ.get(name)
  ]
  network: dict[str, object] = {
    "allowedDomains": self.allowed_domains,
    "deniedDomains": [],
  }
  payload: dict[str, object] = {
    "filesystem": {
      "denyRead": [str(Path.home() / ".ssh")],
      "allowRead": [str(workspace)],
      "allowWrite": [
        str(workspace),
        "/tmp",
        str(Path.home() / ".local" / "share" / "opencode"),
        str(Path.home() / ".local" / "share" / "nonoka"),
      ],
      "denyWrite": [str(workspace / ".env"), str(workspace / ".git/hooks")],
    },
    "network": network,
  }
  if credential_names and self.allowed_domains:
    network["tlsTerminate"] = {}
    payload["credentials"] = {
      "envVars": [
        {"name": name, "mode": "mask", "injectHosts": self.allowed_domains}
        for name in credential_names
      ],
    }
  path.write_text(json.dumps(payload), encoding="utf-8")
  return path

最终生成下面的 SRT settings:

{
  "filesystem": {
    "denyRead": ["/home/user/.ssh"],
    "allowRead": ["/home/user/project"],
    "allowWrite": [
      "/home/user/project",
      "/tmp",
      "/home/user/.local/share/opencode",
      "/home/user/.local/share/nonoka"
    ],
    "denyWrite": ["/home/user/project/.env", "/home/user/project/.git/hooks"]
  },
  "network": {
    "allowedDomains": ["api.anthropic.com"],
    "deniedDomains": [],
    "tlsTerminate": {}
  },
  "credentials": {
    "envVars": [
      {
        "name": "ANTHROPIC_API_KEY",
        "mode": "mask",
        "injectHosts": ["api.anthropic.com"]
      }
    ]
  }
}

根据配置文件,Agent 默认读不到 ~/.ssh、其他项目目录、系统敏感文件;默认连不上任何外网域名。只有显式声明的路径和域名才放行。

c.c. SRT 的具体工作流程

进程启动时。nonoka-cli 的 nonoka run 做了下面的事情来完成 sandbox 隔离:

# nonoka-cli/src/nonoka_cli/commands/run_cmd.py:165-187
cmd = ["opencode", str(cwd)]
settings = None
allowed_domains = resolved_srt_allowed_domains(config.safety)
if config.safety.enabled and config.safety.sandbox in {"auto", "srt"}:
    srt = SrtSandbox(allowed_domains)
    executable = srt.executable()
    if executable:
        settings = srt.settings(cwd)        # 生成临时 settings JSON
        cmd = [executable, "--settings", str(settings), *cmd]

# subprocess.run(cmd, cwd=cwd, env=launch_env)

实际执行的命令类似:

srt --settings /tmp/nonoka-srt-xxx.json opencode /home/user/project

SRT 可执行文件(Linux 下就是 bubblewrap)读取这个 JSON,然后:

  1. 创建新的 namespaces:PID、mount、network 等,让子进程看不到宿主机的完整视图。
  2. 挂载文件系统:按 allowRead / allowWrite / denyRead / denyWrite 设置只读/可写/禁止访问的目录。
  3. 启动网络代理:在沙箱内拦截所有出站连接,只允许 allowedDomains 列表里的域名。
  4. 启动目标进程:在这里是 opencode,以及它后续 fork 出的所有子进程。

SRT 不是先启动进程再限制,而是在进程还没跑起来之前就把笼子搭好。所以 TUI 进程以及其他乱七八糟的全部出生在这个笼子里,继承同一套边界。

5. 把沙箱接入 Agent 的两种模式

a.a. Sandbox-as-MCP-Server

把沙箱做成 MCP server 是最直接的接入方式:Agent 通过 stdio MCP 调用一组 tool,由 server 把请求转进沙箱执行。

暴露的工具围绕“沙箱能替 Agent 干什么”来设计。以 Agent Docker MCP 为例,每个工具都对应一个明确的沙箱语义:

工具名用途设计意图
exec_bash在沙箱内执行 bash 命令沙箱的核心入口
get_job_output获取后台任务输出长生命周期命令的异步轮询
install_system_dependency以 root 权限 apt 安装系统包受控提权,只用于系统级依赖
rebuild_sandbox从 .agent-docker/Dockerfile 重建沙箱破坏性操作,需要用户确认
get_env读取沙箱内环境变量只读 introspection

实现 MCP 时有下面一些细节需要注意:

  1. 生命周期隐藏在 server 启动时。Agent 不应该传 containerId。server 启动时根据 projectDir 查找、恢复或创建沙箱,之后 Agent 调用 exec_bash 只传 command 等业务参数。

    // docker-agent/src/mcp-server.ts:614-657
    let existing = await manager.findForProject(projectDir);
    if (existing && existing.state === "active") {
      // 复用已有沙箱
    } else if (existing && existing.state !== "active") {
      existing = await manager.resume(existing.id);
    } else {
      existing = await manager.create(config); // identity-mount、cap drop、non-root
    }
    // 然后才连接 MCP transport
  2. 用 annotations 表达安全语义。exec_bash 标 destructiveHint: true,get_env 标 readOnlyHint: true,让模型知道每个 tool 的副作用。

  3. 使用 instructions 以及 AGENTS.md 约束 Agent 行为。server 的 instructions 明确告诉 Agent:所有命令必须走 exec_bash,禁止 fallback 到 host shell。

  4. 沙箱策略在协议外注入。是否 strict、网络模式、资源限额通过 .agent-docker/config.json 或 CLI flags 配置,不出现在 tool schema 里,防止 Agent 通过构造参数绕过。

    npx agent-docker-mcp-server serve --project-dir /path/to/project --strict

b.b. 用 SRT 包裹 Agent 进程树

如果 Agent 本身是交互式 TUI,没法整个塞进容器,可以用前面说的 SRT 把整个进程树包起来:

  1. 启动前生成策略文件。例如前面的 nonoka run 根据 SafetyConfig 生成 SRT settings JSON,包含 allowRead / allowWrite / allowedDomains 等。可以自己给 Agent 写对应的配置。

  2. 用 srt 包裹目标进程:

    srt --settings /tmp/nonoka-srt-xxx.json opencode /home/user/project

    SRT 在 opencode 启动前就搭好 namespaces、挂载策略和网络代理,后续 Node 主进程、provider、bridge、用户 MCP servers 全部继承同一套边界。