diff --git a/.gitignore b/.gitignore index 7c5c52c..641289e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ dist/ # 运行时数据(状态文件、缓存、聊天记录本地副本等) .data/ .pi +# tool_manager 审计日志 +tools/.tool_audit.log # 环境配置(含密钥,不入库) .env diff --git a/docker/Dockerfile.network-runner b/docker/Dockerfile.network-runner new file mode 100644 index 0000000..89f3935 --- /dev/null +++ b/docker/Dockerfile.network-runner @@ -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 [] diff --git a/docker/Dockerfile.stdio-runner b/docker/Dockerfile.stdio-runner new file mode 100644 index 0000000..9baab1e --- /dev/null +++ b/docker/Dockerfile.stdio-runner @@ -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 [] diff --git a/docker/Dockerfile.tool-builder b/docker/Dockerfile.tool-builder new file mode 100644 index 0000000..577138d --- /dev/null +++ b/docker/Dockerfile.tool-builder @@ -0,0 +1,10 @@ +# ias-tool-builder — tool_manager 编译 Rust 工具的隔离环境 +# 预缓存 serde_json(tool_manager 生成工具的唯一依赖),支持 --network none 离线编译。 +# 运行时由 tool_manager 挂载 tool_dir 到 /work,cargo 在 /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 [] diff --git a/docker/build-images.sh b/docker/build-images.sh new file mode 100755 index 0000000..de206fb --- /dev/null +++ b/docker/build-images.sh @@ -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-builder(Rust 编译容器)===" +docker build -t ias-tool-builder -f Dockerfile.tool-builder . + +echo "=== 完成 ===" +docker images | grep -E '^ias-(stdio|network|tool)' diff --git a/src/tools/executor.rs b/src/tools/executor.rs index 6c0d0df..025ff98 100644 --- a/src/tools/executor.rs +++ b/src/tools/executor.rs @@ -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 容器, + // 否则本地 spawn(env_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 +} diff --git a/src/tools/registry.rs b/src/tools/registry.rs index 9ad7cf4..4d69a61 100644 --- a/src/tools/registry.rs +++ b/src/tools/registry.rs @@ -340,6 +340,7 @@ path: /target/release/mytool required: true, desc: "城市".into(), }], + sandbox: Default::default(), }; let err = validate_required_params(&spec, &serde_json::json!({})).unwrap(); @@ -363,6 +364,7 @@ path: /target/release/mytool required: true, desc: "城市".into(), }], + sandbox: Default::default(), }; assert!( diff --git a/src/tools/spec.rs b/src/tools/spec.rs index c4c5eab..2c6aca1 100644 --- a/src/tools/spec.rs +++ b/src/tools/spec.rs @@ -13,6 +13,48 @@ pub struct ParamSpec { 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` 的完整映射) /// /// ```yaml @@ -56,6 +98,10 @@ pub struct ToolSpec { #[serde(default)] pub params: Vec, + + /// 沙箱配置(Docker 隔离);默认 profile=local 不启用容器 + #[serde(default)] + pub sandbox: SandboxSpec, } fn default_risk_level() -> String { diff --git a/tools/amap/specs/amap_geocode.tool.yaml b/tools/amap/specs/amap_geocode.tool.yaml index 3e516ff..0fcec20 100644 --- a/tools/amap/specs/amap_geocode.tool.yaml +++ b/tools/amap/specs/amap_geocode.tool.yaml @@ -15,3 +15,6 @@ params: - name: city required: false desc: 城市名称,可选。限定地理编码的城市范围 + +sandbox: + profile: stdio diff --git a/tools/amap/specs/amap_map_link.tool.yaml b/tools/amap/specs/amap_map_link.tool.yaml index ade1812..50a7b13 100644 --- a/tools/amap/specs/amap_map_link.tool.yaml +++ b/tools/amap/specs/amap_map_link.tool.yaml @@ -12,3 +12,6 @@ params: - name: center required: false desc: 地图中心点经纬度坐标,格式"经度,纬度",默认天安门坐标 + +sandbox: + profile: stdio diff --git a/tools/amap/specs/amap_poi_search.tool.yaml b/tools/amap/specs/amap_poi_search.tool.yaml index 57781b3..d2058bb 100644 --- a/tools/amap/specs/amap_poi_search.tool.yaml +++ b/tools/amap/specs/amap_poi_search.tool.yaml @@ -18,3 +18,6 @@ params: - name: radius required: false desc: 搜索半径(米),如 500、1000、3000。默认 1000 米 + +sandbox: + profile: stdio diff --git a/tools/amap/specs/amap_reverse_geocode.tool.yaml b/tools/amap/specs/amap_reverse_geocode.tool.yaml index 5fdd89b..62f066e 100644 --- a/tools/amap/specs/amap_reverse_geocode.tool.yaml +++ b/tools/amap/specs/amap_reverse_geocode.tool.yaml @@ -12,3 +12,6 @@ params: - name: location required: true desc: 经纬度坐标,格式为"经度,纬度",如"116.397,39.908" + +sandbox: + profile: stdio diff --git a/tools/amap/specs/amap_route_plan.tool.yaml b/tools/amap/specs/amap_route_plan.tool.yaml index c41f351..a8db3b9 100644 --- a/tools/amap/specs/amap_route_plan.tool.yaml +++ b/tools/amap/specs/amap_route_plan.tool.yaml @@ -18,3 +18,6 @@ params: - name: type required: false desc: 出行方式:driving(驾车)、walking(步行)、riding(骑行)、transit(公交)。默认 driving + +sandbox: + profile: stdio diff --git a/tools/amap/specs/amap_travel_plan.tool.yaml b/tools/amap/specs/amap_travel_plan.tool.yaml index ab69515..62d1874 100644 --- a/tools/amap/specs/amap_travel_plan.tool.yaml +++ b/tools/amap/specs/amap_travel_plan.tool.yaml @@ -15,3 +15,6 @@ params: - name: interests required: false desc: 感兴趣的关键词,逗号分隔,如"景点,美食,酒店"。默认"景点,美食" + +sandbox: + profile: stdio diff --git a/tools/datetime/specs/get_current_datetime.tool.yaml b/tools/datetime/specs/get_current_datetime.tool.yaml index afd8b1f..6181c8d 100644 --- a/tools/datetime/specs/get_current_datetime.tool.yaml +++ b/tools/datetime/specs/get_current_datetime.tool.yaml @@ -6,3 +6,6 @@ path: /target/release/datetime risk_level: low timeout_secs: 3 params: [] + +sandbox: + profile: stdio diff --git a/tools/fetch_page/specs/fetch_page.tool.yaml b/tools/fetch_page/specs/fetch_page.tool.yaml index 00c115c..309dc2a 100644 --- a/tools/fetch_page/specs/fetch_page.tool.yaml +++ b/tools/fetch_page/specs/fetch_page.tool.yaml @@ -9,3 +9,6 @@ params: - name: url required: true desc: 网页URL + +sandbox: + profile: stdio diff --git a/tools/memories/specs/delete_memory.tool.yaml b/tools/memories/specs/delete_memory.tool.yaml index 6b7499e..c56dfd0 100644 --- a/tools/memories/specs/delete_memory.tool.yaml +++ b/tools/memories/specs/delete_memory.tool.yaml @@ -12,3 +12,6 @@ params: - name: memory_id required: true desc: 记忆ID + +sandbox: + profile: stdio diff --git a/tools/memories/specs/get_memory.tool.yaml b/tools/memories/specs/get_memory.tool.yaml index cf1a93e..fcfc75a 100644 --- a/tools/memories/specs/get_memory.tool.yaml +++ b/tools/memories/specs/get_memory.tool.yaml @@ -12,3 +12,6 @@ params: - name: memory_id required: true desc: 记忆的数字ID,从 read_memories 返回结果中获取 + +sandbox: + profile: stdio diff --git a/tools/memories/specs/read_memories.tool.yaml b/tools/memories/specs/read_memories.tool.yaml index 30d0827..482a204 100644 --- a/tools/memories/specs/read_memories.tool.yaml +++ b/tools/memories/specs/read_memories.tool.yaml @@ -9,3 +9,6 @@ timeout_secs: 3 env: - IAS_MEMORY_DIR params: + +sandbox: + profile: stdio diff --git a/tools/memories/specs/update_memory.tool.yaml b/tools/memories/specs/update_memory.tool.yaml index 0038a6f..b92b2ee 100644 --- a/tools/memories/specs/update_memory.tool.yaml +++ b/tools/memories/specs/update_memory.tool.yaml @@ -15,3 +15,6 @@ params: - name: content required: true desc: 更新后的新内容,覆盖原记忆 + +sandbox: + profile: stdio diff --git a/tools/memories/specs/write_memory.tool.yaml b/tools/memories/specs/write_memory.tool.yaml index 2a7497c..318ad56 100644 --- a/tools/memories/specs/write_memory.tool.yaml +++ b/tools/memories/specs/write_memory.tool.yaml @@ -12,3 +12,6 @@ params: - name: content required: true desc: 要记录的记忆内容,用自然语言描述。如"用户住在合肥包河区联投新安里A区" + +sandbox: + profile: stdio diff --git a/tools/tool_manager/src/main.rs b/tools/tool_manager/src/main.rs index bb69e60..2df91c6 100644 --- a/tools/tool_manager/src/main.rs +++ b/tools/tool_manager/src/main.rs @@ -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> { 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 及其子进程都会被信号终止 - let output = Command::new("timeout") - .arg(BUILD_TIMEOUT_SECS.to_string()) - .arg("cargo") - .arg("build") - .arg("--release") - .current_dir(tool_dir) - .output() - .map_err(|e| format!("启动 cargo 失败: {e}"))?; + // 启用 builder 容器时在隔离环境内离线编译,挂载 tool_dir 到 /work,产物写回宿主; + // 否则本地 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("cargo") + .arg("build") + .arg("--release") + .current_dir(tool_dir) + .output() + } + .map_err(|e| format!("启动构建失败: {e}"))?; if !output.status.success() { // 构建失败:删除可能残留的旧二进制,避免 reload 后仍调用旧版本造成"假成功" diff --git a/tools/weather/specs/weather_forecast.tool.yaml b/tools/weather/specs/weather_forecast.tool.yaml index 59bd20f..dc9b7fb 100644 --- a/tools/weather/specs/weather_forecast.tool.yaml +++ b/tools/weather/specs/weather_forecast.tool.yaml @@ -12,3 +12,6 @@ params: - name: location required: true desc: 城市名称,如"北京"、"上海"、"合肥"。支持地级市和县级市 + +sandbox: + profile: stdio diff --git a/tools/weather/specs/weather_hourly.tool.yaml b/tools/weather/specs/weather_hourly.tool.yaml index 9356910..556b82a 100644 --- a/tools/weather/specs/weather_hourly.tool.yaml +++ b/tools/weather/specs/weather_hourly.tool.yaml @@ -12,3 +12,6 @@ params: - name: location required: true desc: 城市名称,如"北京"、"上海"、"合肥"。支持地级市和县级市 + +sandbox: + profile: stdio diff --git a/tools/weather/specs/weather_now.tool.yaml b/tools/weather/specs/weather_now.tool.yaml index 8dd1aa8..0c93562 100644 --- a/tools/weather/specs/weather_now.tool.yaml +++ b/tools/weather/specs/weather_now.tool.yaml @@ -12,3 +12,6 @@ params: - name: location required: true desc: 城市名称,如"北京"、"上海"、"合肥"。支持地级市和县级市 + +sandbox: + profile: stdio diff --git a/tools/web_search/specs/web_search.tool.yaml b/tools/web_search/specs/web_search.tool.yaml index 4cca1da..2f7e722 100644 --- a/tools/web_search/specs/web_search.tool.yaml +++ b/tools/web_search/specs/web_search.tool.yaml @@ -13,3 +13,6 @@ params: desc: 搜索关键词,如"Rust 2024 edition 新特性"、"今天比特币价格" - name: max_results desc: 返回的最大搜索结果数量(1-20),默认5条 + +sandbox: + profile: stdio