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
+2
View File
@@ -6,6 +6,8 @@ dist/
# 运行时数据(状态文件、缓存、聊天记录本地副本等) # 运行时数据(状态文件、缓存、聊天记录本地副本等)
.data/ .data/
.pi .pi
# tool_manager 审计日志
tools/.tool_audit.log
# 环境配置(含密钥,不入库) # 环境配置(含密钥,不入库)
.env .env
+8
View File
@@ -0,0 +1,8 @@
# ias-network-runner — 允许联网的工具运行环境
# 供需要自行发起 HTTP 的工具使用(当前工具均通过宿主 http 消息代理联网,
# 此镜像为将来工具直接联网预留)。运行时仍可按需 --network none。
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
ENTRYPOINT []
+7
View File
@@ -0,0 +1,7 @@
# ias-stdio-runner — 运行普通 stdio 工具的极简隔离环境
# 无网络、只读根、低权限。工具二进制通过 bind mount 提供,镜像本身不含工具。
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
ENTRYPOINT []
+10
View File
@@ -0,0 +1,10 @@
# ias-tool-builder — tool_manager 编译 Rust 工具的隔离环境
# 预缓存 serde_jsontool_manager 生成工具的唯一依赖),支持 --network none 离线编译。
# 运行时由 tool_manager 挂载 tool_dir 到 /workcargo 在 /work 内编译,产物写回宿主。
FROM rust:1.88-slim
RUN cargo new /tmp/seed && cd /tmp/seed \
&& printf 'serde_json = "1.0"\n' >> Cargo.toml \
&& cargo build --release \
&& rm -rf /tmp/seed
WORKDIR /work
ENTRYPOINT []
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# 构建三类沙箱基础镜像。无需为每个工具单独写 Dockerfile。
set -euo pipefail
cd "$(dirname "$0")" # docker/
echo "=== 构建 ias-stdio-runner(无网络只读容器)==="
docker build -t ias-stdio-runner -f Dockerfile.stdio-runner .
echo "=== 构建 ias-network-runner(联网容器)==="
docker build -t ias-network-runner -f Dockerfile.network-runner .
echo "=== 构建 ias-tool-builderRust 编译容器)==="
docker build -t ias-tool-builder -f Dockerfile.tool-builder .
echo "=== 完成 ==="
docker images | grep -E '^ias-(stdio|network|tool)'
+90 -21
View File
@@ -145,27 +145,14 @@ pub async fn execute_loop(
} }
// ── spawn ── // ── spawn ──
let binary = std::path::Path::new(binary_path); // 执行隔离边界:启用沙箱时按 sandbox.profile 选择一次性 Docker 容器,
let mut cmd = Command::new(&binary); // 否则本地 spawnenv_clear + 白名单注入)
if let Some(c) = &spec.command { let use_docker = sandbox_enabled() && spec.sandbox.profile != "local";
cmd.arg("--command").arg(c); let mut cmd = if use_docker {
} build_docker_cmd(spec, binary_path)
// 安全:清空继承环境,仅注入 PATH(子进程定位可执行)+ spec 声明的白名单变量, } else {
// 避免工具读取宿主全部 API key、数据库密码等敏感变量 build_local_cmd(spec, binary_path)
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.stdin(std::process::Stdio::piped()) cmd.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped())
.stderr(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
}
+2
View File
@@ -340,6 +340,7 @@ path: /target/release/mytool
required: true, required: true,
desc: "城市".into(), desc: "城市".into(),
}], }],
sandbox: Default::default(),
}; };
let err = validate_required_params(&spec, &serde_json::json!({})).unwrap(); let err = validate_required_params(&spec, &serde_json::json!({})).unwrap();
@@ -363,6 +364,7 @@ path: /target/release/mytool
required: true, required: true,
desc: "城市".into(), desc: "城市".into(),
}], }],
sandbox: Default::default(),
}; };
assert!( assert!(
+46
View File
@@ -13,6 +13,48 @@ pub struct ParamSpec {
pub desc: String, pub desc: String,
} }
/// 沙箱配置(Docker 一次性容器隔离)
///
/// ```yaml
/// sandbox:
/// profile: stdio # local | stdio | network | builder
/// network: none # none | default
/// memory: 256m
/// cpus: "0.5"
/// pids_limit: 64
/// read_only: true
/// ```
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct SandboxSpec {
/// 沙箱画像:`local` 本地执行(默认,不启用容器);`stdio` 无网络只读容器;
/// `network` 联网容器;`builder` 构建容器(供 tool_manager
pub profile: String,
/// 网络:`none`(默认)或 `default`
pub network: String,
/// 内存限制,如 `256m`
pub memory: String,
/// CPU 限制,如 `0.5`
pub cpus: String,
/// PID 数量上限
pub pids_limit: u32,
/// 只读根文件系统
pub read_only: bool,
}
impl Default for SandboxSpec {
fn default() -> Self {
Self {
profile: "local".into(),
network: "none".into(),
memory: "256m".into(),
cpus: "0.5".into(),
pids_limit: 64,
read_only: true,
}
}
}
/// 工具规范(`*.tool.yaml` 的完整映射) /// 工具规范(`*.tool.yaml` 的完整映射)
/// ///
/// ```yaml /// ```yaml
@@ -56,6 +98,10 @@ pub struct ToolSpec {
#[serde(default)] #[serde(default)]
pub params: Vec<ParamSpec>, pub params: Vec<ParamSpec>,
/// 沙箱配置(Docker 隔离);默认 profile=local 不启用容器
#[serde(default)]
pub sandbox: SandboxSpec,
} }
fn default_risk_level() -> String { fn default_risk_level() -> String {
+3
View File
@@ -15,3 +15,6 @@ params:
- name: city - name: city
required: false required: false
desc: 城市名称,可选。限定地理编码的城市范围 desc: 城市名称,可选。限定地理编码的城市范围
sandbox:
profile: stdio
+3
View File
@@ -12,3 +12,6 @@ params:
- name: center - name: center
required: false required: false
desc: 地图中心点经纬度坐标,格式"经度,纬度",默认天安门坐标 desc: 地图中心点经纬度坐标,格式"经度,纬度",默认天安门坐标
sandbox:
profile: stdio
@@ -18,3 +18,6 @@ params:
- name: radius - name: radius
required: false required: false
desc: 搜索半径(米),如 500、1000、3000。默认 1000 米 desc: 搜索半径(米),如 500、1000、3000。默认 1000 米
sandbox:
profile: stdio
@@ -12,3 +12,6 @@ params:
- name: location - name: location
required: true required: true
desc: 经纬度坐标,格式为"经度,纬度",如"116.397,39.908" desc: 经纬度坐标,格式为"经度,纬度",如"116.397,39.908"
sandbox:
profile: stdio
@@ -18,3 +18,6 @@ params:
- name: type - name: type
required: false required: false
desc: 出行方式:driving(驾车)、walking(步行)、riding(骑行)、transit(公交)。默认 driving desc: 出行方式:driving(驾车)、walking(步行)、riding(骑行)、transit(公交)。默认 driving
sandbox:
profile: stdio
@@ -15,3 +15,6 @@ params:
- name: interests - name: interests
required: false required: false
desc: 感兴趣的关键词,逗号分隔,如"景点,美食,酒店"。默认"景点,美食" desc: 感兴趣的关键词,逗号分隔,如"景点,美食,酒店"。默认"景点,美食"
sandbox:
profile: stdio
@@ -6,3 +6,6 @@ path: /target/release/datetime
risk_level: low risk_level: low
timeout_secs: 3 timeout_secs: 3
params: [] params: []
sandbox:
profile: stdio
@@ -9,3 +9,6 @@ params:
- name: url - name: url
required: true required: true
desc: 网页URL desc: 网页URL
sandbox:
profile: stdio
@@ -12,3 +12,6 @@ params:
- name: memory_id - name: memory_id
required: true required: true
desc: 记忆ID desc: 记忆ID
sandbox:
profile: stdio
@@ -12,3 +12,6 @@ params:
- name: memory_id - name: memory_id
required: true required: true
desc: 记忆的数字ID,从 read_memories 返回结果中获取 desc: 记忆的数字ID,从 read_memories 返回结果中获取
sandbox:
profile: stdio
@@ -9,3 +9,6 @@ timeout_secs: 3
env: env:
- IAS_MEMORY_DIR - IAS_MEMORY_DIR
params: params:
sandbox:
profile: stdio
@@ -15,3 +15,6 @@ params:
- name: content - name: content
required: true required: true
desc: 更新后的新内容,覆盖原记忆 desc: 更新后的新内容,覆盖原记忆
sandbox:
profile: stdio
@@ -12,3 +12,6 @@ params:
- name: content - name: content
required: true required: true
desc: 要记录的记忆内容,用自然语言描述。如"用户住在合肥包河区联投新安里A区" desc: 要记录的记忆内容,用自然语言描述。如"用户住在合肥包河区联投新安里A区"
sandbox:
profile: stdio
+25 -3
View File
@@ -122,16 +122,38 @@ fn update_tool_project(tool_dir: &Path, tool_name: &str, params: &Value) -> Resu
fn build_tool(tool_dir: &Path, tool_name: &str) -> Result<(), String> { fn build_tool(tool_dir: &Path, tool_name: &str) -> Result<(), String> {
let binary = tool_dir.join("target/release").join(tool_name); let binary = tool_dir.join("target/release").join(tool_name);
let use_builder = std::env::var("IAS_DOCKER_BUILDER")
.ok()
.as_deref()
== Some("1");
// 用 `timeout` 限制构建时长,超时返回 124;cargo 及其子进程都会被信号终止 // builder 容器时在隔离环境内离线编译,挂载 tool_dir 到 /work,产物写回宿主;
let output = Command::new("timeout") // 否则本地 cargo(受 timeout 限制)
let output = if use_builder {
Command::new("docker")
.arg("run").arg("--rm")
.arg("--network").arg("none")
.arg("--memory").arg("1g")
.arg("--cpus").arg("1")
.arg("--pids-limit").arg("128")
.arg("--cap-drop").arg("ALL")
.arg("--security-opt").arg("no-new-privileges")
.arg("-v").arg(format!("{}:/work", tool_dir.display()))
.arg("-w").arg("/work")
.arg("ias-tool-builder")
.arg("timeout").arg(BUILD_TIMEOUT_SECS.to_string())
.arg("cargo").arg("build").arg("--release")
.output()
} else {
Command::new("timeout")
.arg(BUILD_TIMEOUT_SECS.to_string()) .arg(BUILD_TIMEOUT_SECS.to_string())
.arg("cargo") .arg("cargo")
.arg("build") .arg("build")
.arg("--release") .arg("--release")
.current_dir(tool_dir) .current_dir(tool_dir)
.output() .output()
.map_err(|e| format!("启动 cargo 失败: {e}"))?; }
.map_err(|e| format!("启动构建失败: {e}"))?;
if !output.status.success() { if !output.status.success() {
// 构建失败:删除可能残留的旧二进制,避免 reload 后仍调用旧版本造成"假成功" // 构建失败:删除可能残留的旧二进制,避免 reload 后仍调用旧版本造成"假成功"
@@ -12,3 +12,6 @@ params:
- name: location - name: location
required: true required: true
desc: 城市名称,如"北京"、"上海"、"合肥"。支持地级市和县级市 desc: 城市名称,如"北京"、"上海"、"合肥"。支持地级市和县级市
sandbox:
profile: stdio
@@ -12,3 +12,6 @@ params:
- name: location - name: location
required: true required: true
desc: 城市名称,如"北京"、"上海"、"合肥"。支持地级市和县级市 desc: 城市名称,如"北京"、"上海"、"合肥"。支持地级市和县级市
sandbox:
profile: stdio
@@ -12,3 +12,6 @@ params:
- name: location - name: location
required: true required: true
desc: 城市名称,如"北京"、"上海"、"合肥"。支持地级市和县级市 desc: 城市名称,如"北京"、"上海"、"合肥"。支持地级市和县级市
sandbox:
profile: stdio
@@ -13,3 +13,6 @@ params:
desc: 搜索关键词,如"Rust 2024 edition 新特性"、"今天比特币价格" desc: 搜索关键词,如"Rust 2024 edition 新特性"、"今天比特币价格"
- name: max_results - name: max_results
desc: 返回的最大搜索结果数量(1-20),默认5条 desc: 返回的最大搜索结果数量(1-20),默认5条
sandbox:
profile: stdio