feat(sandbox): Docker 一次性容器执行隔离边界(步骤 6-7)

引入两类安全边界:统一审批=授权边界(上一提交),一次性 Docker
容器=执行隔离边界(本提交)。LLM 即使执行恶意代码也只能在受限容器
内运行。

spec.rs: 新增 SandboxSpec(profile/network/memory/cpus/pids_limit/
  read_only),默认 profile=local 不启用容器。
executor.rs: 按 sandbox.profile 选择执行后端
  - local: 本地 spawn(env_clear + 白名单注入)
  - stdio: ias-stdio-runner,--network none --read-only --cap-drop ALL
    --security-opt no-new-privileges,仅 bind mount 工具二进制(ro)+tmpfs /tmp
  - network: ias-network-runner(预留自行联网工具)
  - builder: ias-tool-builder(供 tool_manager 编译)
  全局开关 IAS_DOCKER_SANDBOX=1,未启用时行为不变(向后兼容)。
tool_manager: build_tool 支持 IAS_DOCKER_BUILDER=1 在 ias-tool-builder
  容器内离线编译(--network none,挂载 tool_dir 到 /work)。
docker/: 三类 Dockerfile + build-images.sh 构建脚本。
  - stdio-runner: debian-slim + ca-certificates,无网络只读
  - network-runner: 预留联网工具
  - tool-builder: rust:1.88-slim 预缓存 serde_json,支持离线编译
spec 迁移: 17 个普通工具追加 sandbox.profile: stdio;tool_manager
  保持 local(自身在宿主操作文件系统)。

端到端验证: datetime 工具本地模式 +08:00,Docker 模式 +00:00(UTC),
  --rm 无容器残留。
This commit is contained in:
2026-06-22 11:38:42 +08:00
parent 1e33f527e0
commit 20f7c3ba9c
26 changed files with 263 additions and 30 deletions
+90 -21
View File
@@ -145,27 +145,14 @@ pub async fn execute_loop(
}
// ── spawn ──
let binary = std::path::Path::new(binary_path);
let mut cmd = Command::new(&binary);
if let Some(c) = &spec.command {
cmd.arg("--command").arg(c);
}
// 安全:清空继承环境,仅注入 PATH(子进程定位可执行)+ spec 声明的白名单变量,
// 避免工具读取宿主全部 API key、数据库密码等敏感变量
cmd.env_clear();
if let Ok(path) = std::env::var("PATH") {
cmd.env("PATH", path);
}
for key in &spec.env {
match std::env::var(key) {
Ok(v) => {
cmd.env(key, &v);
}
Err(_) => {
warn!(target: "ias::err", "[env] 工具 '{}' 需要的环境变量 '{}' 未设置", spec.name, key);
}
}
}
// 执行隔离边界:启用沙箱时按 sandbox.profile 选择一次性 Docker 容器,
// 否则本地 spawnenv_clear + 白名单注入)
let use_docker = sandbox_enabled() && spec.sandbox.profile != "local";
let mut cmd = if use_docker {
build_docker_cmd(spec, binary_path)
} else {
build_local_cmd(spec, binary_path)
};
cmd.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
@@ -359,3 +346,85 @@ pub async fn execute_loop(
}
}
}
/// 全局沙箱开关:`IAS_DOCKER_SANDBOX=1` 启用一次性容器隔离
fn sandbox_enabled() -> bool {
std::env::var("IAS_DOCKER_SANDBOX")
.ok()
.as_deref()
== Some("1")
}
/// 本地执行:env_clear + PATH + spec.env 白名单注入
fn build_local_cmd(spec: &ToolSpec, binary_path: &str) -> Command {
let mut cmd = Command::new(std::path::Path::new(binary_path));
if let Some(c) = &spec.command {
cmd.arg("--command").arg(c);
}
cmd.env_clear();
if let Ok(path) = std::env::var("PATH") {
cmd.env("PATH", path);
}
for key in &spec.env {
match std::env::var(key) {
Ok(v) => {
cmd.env(key, &v);
}
Err(_) => {
warn!(target: "ias::err", "[env] 工具 '{}' 需要的环境变量 '{}' 未设置", spec.name, key);
}
}
}
cmd
}
/// Docker 隔离执行:一次性容器,默认无网络、只读根、cap-drop ALL、
/// no-new-privileges,仅挂载工具二进制(ro+ tmpfs /tmp
fn build_docker_cmd(spec: &ToolSpec, binary_path: &str) -> Command {
let image = match spec.sandbox.profile.as_str() {
"network" => "ias-network-runner",
"builder" => "ias-tool-builder",
_ => "ias-stdio-runner",
};
let mut cmd = Command::new("docker");
cmd.arg("run")
.arg("--rm")
.arg("--network")
.arg(if spec.sandbox.network == "default" {
"default"
} else {
"none"
})
.arg("--memory")
.arg(&spec.sandbox.memory)
.arg("--cpus")
.arg(&spec.sandbox.cpus)
.arg("--pids-limit")
.arg(spec.sandbox.pids_limit.to_string())
.arg("--cap-drop")
.arg("ALL")
.arg("--security-opt")
.arg("no-new-privileges");
if spec.sandbox.read_only {
cmd.arg("--read-only");
}
// 工具二进制只读挂载到 /tool
cmd.arg("--mount")
.arg(format!("type=bind,source={binary_path},target=/tool,readonly"));
// 可写临时目录
cmd.arg("--mount")
.arg("type=tmpfs,target=/tmp");
// env:容器内固定 PATH + spec 声明的白名单
cmd.arg("--env")
.arg("PATH=/usr/local/bin:/usr/bin:/bin");
for key in &spec.env {
if let Ok(v) = std::env::var(key) {
cmd.arg("--env").arg(format!("{key}={v}"));
}
}
cmd.arg(image).arg("/tool");
if let Some(c) = &spec.command {
cmd.arg("--command").arg(c);
}
cmd
}