Merge branch 'security/sandbox-boundaries'

两层安全边界落地:
- 授权层:统一工具级审批,堵住 stdio→result 绕过
- 隔离层:长期沙箱容器 + docker exec,仅不受信任代码进容器
- 审计层:tool_manager 调用全留痕

9 个提交,28 测试通过,clippy 无新增警告。
This commit is contained in:
2026-06-22 17:51:58 +08:00
33 changed files with 1024 additions and 187 deletions
+3
View File
@@ -1,11 +1,14 @@
# Rust 构建产物
/target
tools/*/target/
tools/bin/
dist/
# 运行时数据(状态文件、缓存、聊天记录本地副本等)
.data/
.pi
# tool_manager 审计日志
tools/.tool_audit.log
# 环境配置(含密钥,不入库)
.env
+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 []
+13
View File
@@ -0,0 +1,13 @@
# ias-tool-builder — tool_manager 编译 Rust 工具的隔离环境
# 预缓存 serde_jsontool_manager 生成工具的唯一依赖),支持 --network none 离线编译。
# CARGO_HOME=/tmp/cargo 并 chmod 777,使任意 uid--user 运行)都能复用缓存。
# 运行时由 tool_manager 挂载 tool_dir 到 /workcargo 在 /work 内编译,产物写回宿主。
FROM rust:1.88-slim
ENV CARGO_HOME=/tmp/cargo
RUN cargo new /tmp/seed && cd /tmp/seed \
&& printf 'serde_json = "1.0"\n' >> Cargo.toml \
&& cargo build --release \
&& rm -rf /tmp/seed \
&& chmod -R 777 /tmp/cargo
WORKDIR /work
ENTRYPOINT []
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# 构建沙箱基础镜像。
# - ias-stdio-runner : 运行 tool_manager 产出的不受信任工具(无网络只读)
# - ias-tool-builder : tool_manager 编译 Rust 工具(离线)
# 无需为每个工具单独写 Dockerfile。
set -euo pipefail
cd "$(dirname "$0")" # docker/
echo "=== 构建 ias-stdio-runner(无网络只读容器)==="
docker build -t ias-stdio-runner -f Dockerfile.stdio-runner .
echo "=== 构建 ias-tool-builderRust 编译容器)==="
docker build -t ias-tool-builder -f Dockerfile.tool-builder .
echo "=== 完成 ==="
docker images | grep -E '^ias-(stdio|tool)'
+186
View File
@@ -0,0 +1,186 @@
# 两层安全边界设计
> 审批决定"能不能做"Docker 限制"做坏了能坏到哪里",审计回答"谁让系统新增或修改了什么能力"。
## 架构
```
LLM 调用工具
→ registry.dispatch(name, params, user_id, approved)
→ load spec + validate params
→ 【授权边界】if high_risk && !approved: 统一审批(不依赖消息类型)
→ 【隔离边界】按 sandbox.profile 选择执行方式:
- local : 本地 spawnenv_clear + 白名单注入)
- stdio : docker run ias-stdio-runner (无网络、只读根)
- network : docker run ias-network-runner(联网、资源限制)
- builder : docker run ias-tool-builder (离线编译,仅 tool_manager
→ stdin 传 envelope → stdout 读 JSON → 超时 kill → 容器销毁
```
## 第一层:统一工具级审批(授权边界)
**位置**`src/tools/executor.rs` `execute_loop()` 入口
高风险工具在 **spawn 之前** 必须获得用户确认,与工具后续输出的消息类型(`result`/`http`/`db`)无关。
```rust
if spec.is_high_risk() && !approved {
// 发送确认码 → 等待用户回复 → Approved/Rejected/Expired
}
```
**修复的漏洞**:原实现审批只在 `http`/`db` 消息分支触发,`stdio → result` 可绕过。`tool_manager`high risk)直接输出 result 即可零确认执行。
## 第二层:长期沙箱容器 + docker exec(隔离边界)
### 适用范围(按威胁模型精确化)
http/db 类工具的子进程是"瘦壳"——危险操作(联网、DB 读写)全在受信任的主进程里,
给它们套容器是纯开销,**没有隔离收益**。因此:
| 场景 | 是否容器隔离 | 原因 |
|------|------------|------|
| 开发者预写工具(weather/amap/... | ❌ 本地执行 | 子进程纯计算,危险操作在主进程 |
| tool_manager 产出的工具 | ✅ stdio 容器 | 含 LLM 提供的 rust_code,不受信任 |
| tool_manager 编译 | ✅ builder 容器 | cargo 执行任意 build script/proc macro |
### 统一产物目录
所有工具二进制统一收集到 `tools/bin/``build.sh` 编译后复制,`tool_manager` 编译后也复制)。
registry 解析路径时优先用 `tools/bin/<tool>`
### 长期容器模式
daemon 启动时创建常驻容器 `ias-sandbox``tools/bin/` 只读挂载到 `/tools`
```
docker run -d --rm --name ias-sandbox \
--network none --cap-drop ALL --security-opt no-new-privileges \
--read-only --memory 512m --cpus 1 --pids-limit 128 \
--mount type=bind,source=tools/bin,target=/tools,readonly \
--mount type=tmpfs,target=/tmp \
ias-stdio-runner sleep infinity
```
工具调用通过 `docker exec` 在容器内执行(省去每次 `docker run` 的创建开销):
```
docker exec -i -e KEY=VAL ias-sandbox timeout -k 5 <secs> /tools/<name> --command <cmd>
```
- 容器已运行则复用,daemon 重启不重建
- `timeout -k 5` 超时 SIGTERM、5s 后 SIGKILL,避免孤儿进程
- env 按 spec 白名单 `-e` 注入(容器启动时仅有 PATH
- 新工具由 tool_manager 编译后复制到 `tools/bin/`,bind mount 自动可见,无需重启容器
### 两类基础镜像
| 镜像 | 用途 | 网络 | 文件系统 |
|------|------|------|----------|
| `ias-stdio-runner` | tool_manager 产出的不受信任工具(长期容器) | none | 只读根 + tmpfs /tmp |
| `ias-tool-builder` | tool_manager 编译 Rust 工具(一次性容器) | none | 可写 /workbind mount |
构建:`docker/build-images.sh`
### 容器安全基线
所有容器统一:
- `--rm` 一次性,用完销毁
- `--cap-drop ALL` 丢弃所有 Linux capability
- `--security-opt no-new-privileges` 禁止提权
- `--memory` / `--cpus` / `--pids-limit` 资源限制
- 只挂载工具二进制(readonly+ tmpfs /tmp
- 不挂载项目根目录、不挂载 docker.sock
- env 按白名单注入(`env_clear` + spec.env
### 沙箱配置(spec
```yaml
sandbox:
profile: stdio # local | stdio | builder
network: none # none | default
memory: 256m
cpus: "0.5"
pids_limit: 64
read_only: true
```
默认 `profile: local`(不启用容器)。开发者预写工具保持默认。
**tool_manager 产出的工具自动写入 `sandbox: profile: stdio`**(见 tool_manager `spec_yaml`),
不受信任代码默认进容器。
### 启用沙箱
```bash
export IAS_DOCKER_SANDBOX=1 # 工具执行走长期容器 docker exec
export IAS_DOCKER_BUILDER=1 # tool_manager 编译走 builder 容器
```
未设置时退回本地执行(仍保留 env_clear + 白名单注入)。
daemon 启动时自动 ensure `ias-sandbox` 容器,失败则回退本地执行。
### builder 容器特殊处理
`--cap-drop ALL` 丢掉 `CAP_DAC_OVERRIDE`root 无法写 bind mount 的非 root 目录。
解法:builder 容器以宿主 `uid:gid` 运行(`--user`),自然能写自己的目录。
镜像内 `CARGO_HOME=/tmp/cargo` 预缓存 serde_json 并 `chmod 777`,任意 uid 可用。
编译加 `--offline`,确保 `--network none` 下不尝试联网。
## tool_manager 加固
| 措施 | 实现 |
|------|------|
| 禁止自改 | `RESERVED_NAMES` 包含 `tool_manager`,禁止 create/update/build |
| 强制 high risk | `spec_yaml` 忽略调用方 risk_level,一律写 `high` |
| 审计日志 | 每次调用追加 `tools/.tool_audit.log`ts/uid/action/tool/result |
| 构建超时 | `timeout 180s` 包裹 cargo build |
| 失败清理 | 构建失败删除残留旧二进制,避免 reload 后调用旧版本"假成功" |
| 离线编译 | `IAS_DOCKER_BUILDER=1` 时在 builder 容器内 `--offline` 编译 |
## 结构化结果
`executor` 返回 `ToolRunOutcome` 枚举替代裸字符串:
```rust
enum ToolRunOutcome {
Ok(String),
Err { kind: ToolErrorKind, message: String },
}
```
`daemon` 据此判断是否 reloadtool_manager 成功后重载注册表),
替代原 `is_tool_error()` 的中文前缀字符串匹配。
## 审批并发修复
`ApprovalManager::clean_expired()` 两阶段加锁:
收集过期 `code_hash` → 更新 DB → 重新加锁按 `code_hash` 定位删除。
不依赖快照 index,避免并发回复导致 index 错位误删未过期审批。
## 验证
| 场景 | 结果 |
|------|------|
| tool_manager update 自身 | ❌ 拒绝(保留名) |
| create_tool risk_level=low | spec 强制 high |
| tool_manager 产出工具 spec | 自动含 `sandbox: profile: stdio` + 产物复制到 `tools/bin/` |
| datetime 本地执行 | +08:00(宿主时区,开发者工具默认不套容器) |
| tool_manager 产出工具 docker exec | 容器内 hostname=容器ID,证明确实走长期容器 exec |
| 容器常驻 | docker ps 可见 ias-sandboxexec 完无孤儿进程 |
| builder 容器编译 serde_json | ✅ 离线成功 |
| cargo test | 25 passed |
## 实施状态
| # | 步骤 | 状态 |
|---|------|------|
| 1 | 统一工具级审批 | ✅ |
| 2 | 禁止 tool_manager 自改 | ✅ |
| 3 | 强制生成工具 high risk | ✅ |
| 4 | env_clear 环境白名单 | ✅ |
| 5 | 修 clean_expired 并发 | ✅ |
| 6 | 三类 Docker 镜像 + sandbox spec | ✅ |
| 7 | stdio 工具迁移到一次性容器 | ✅ |
| 8 | network 工具迁移 | ➖ 不需要:工具走宿主代理联网,子进程纯计算,套容器无隔离收益 |
| 9 | tool_manager build 迁移到 builder 容器 | ✅ |
| 10 | 结构化结果替换 is_tool_error | ✅ |
+30 -27
View File
@@ -49,6 +49,7 @@ use crate::llm::{
use crate::queue::{ConsumerChannels, EnqueueHandle, MessageKind, PipelineMessage, QueueRunner};
use crate::tools::approval::ApprovalManager;
use crate::tools::executor::ToolRunOutcome;
use crate::tools::registry::RegistryManager;
use crate::wechat::client::WeChatClient;
use std::collections::HashMap;
@@ -111,6 +112,15 @@ pub async fn run(database: &Arc<Database>, memory_store: &Arc<MemoryStore>) {
database.pool().clone(),
))));
let registry = Arc::new(RegistryManager::load("tools"));
// 沙箱容器(如果启用):daemon 启动时创建长期容器,失败则设置降级标志
// executor 会据此回退本地执行,避免不受信任工具持续 SpawnFailed
if crate::tools::sandbox::enabled() {
if let Err(e) = crate::tools::sandbox::ensure_container("tools").await {
tracing::warn!(target: "ias::sandbox", "沙箱容器启动失败: {e}");
}
}
let system_prompt = std::env::var("WEIXIN_LLM_SYSTEM_PROMPT").unwrap_or_else(|_| String::new());
let model = std::env::var("DEEPSEEK_MODEL").unwrap_or_else(|_| "deepseek-v4-flash".to_string());
@@ -692,7 +702,7 @@ async fn handle_tool_call(
eq.enqueue(msg).await;
})
};
let output = ctx
let outcome = ctx
.registry
.dispatch(
target,
@@ -704,11 +714,17 @@ async fn handle_tool_call(
&wechat_cb,
)
.await;
if target == "tool_manager" && !is_tool_error(&output) {
if target == "tool_manager" && !outcome.is_err() {
let count = ctx.registry.reload().await;
format!("{output}\n\n工具注册表已重载,当前可用工具数: {count}")
format!(
"{}\n\n工具注册表已重载,当前可用工具数: {count}",
outcome.into_message()
)
} else {
output
if let ToolRunOutcome::Err { ref kind, .. } = outcome {
tracing::warn!(target:"ias::err","[tool] {target} 执行失败: {kind:?}");
}
outcome.into_message()
}
} else {
let params: serde_json::Value = serde_json::from_str(&arguments).unwrap_or_default();
@@ -734,7 +750,7 @@ async fn handle_tool_call(
eq.enqueue(msg).await;
})
};
let output = ctx
let outcome = ctx
.registry
.dispatch(
&tool_name,
@@ -746,11 +762,17 @@ async fn handle_tool_call(
&wechat_cb,
)
.await;
if tool_name == "tool_manager" && !is_tool_error(&output) {
if tool_name == "tool_manager" && !outcome.is_err() {
let count = ctx.registry.reload().await;
format!("{output}\n\n工具注册表已重载,当前可用工具数: {count}")
format!(
"{}\n\n工具注册表已重载,当前可用工具数: {count}",
outcome.into_message()
)
} else {
output
if let ToolRunOutcome::Err { ref kind, .. } = outcome {
tracing::warn!(target:"ias::err","[tool] {tool_name} 执行失败: {kind:?}");
}
outcome.into_message()
}
};
@@ -767,25 +789,6 @@ async fn handle_tool_call(
.await;
}
fn is_tool_error(result: &str) -> bool {
result.starts_with("异常退出")
|| result.starts_with("启动失败")
|| result.starts_with("工具输出非JSON")
|| result.starts_with("HTTP失败")
|| result.starts_with("读取stdout失败")
|| result.starts_with("stdout超时")
|| result.starts_with("进程超时")
|| result.starts_with("进程异常")
|| result.starts_with("未知消息类型")
|| result.starts_with("未知工具")
|| result.starts_with("不支持的方法")
|| result.starts_with("审批失败")
|| result.starts_with("审批超时")
|| result.starts_with("操作已被用户取消")
|| result.starts_with("缺少必填参数")
|| result.starts_with("工具 '")
}
fn unpack_call_capability_params(cp: &serde_json::Value) -> (&str, serde_json::Value) {
let target = cp["name"].as_str().unwrap_or("");
let mut params = serde_json::Map::new();
+7 -24
View File
@@ -529,7 +529,7 @@ async fn cmd_tool_dynamic(name: &str, args: &[String]) {
+ Send
+ Sync
) = &|_: &str, _: &str| Box::pin(async {});
let result = registry
let outcome = registry
.dispatch(
name,
&serde_json::Value::Object(params),
@@ -541,28 +541,11 @@ async fn cmd_tool_dynamic(name: &str, args: &[String]) {
)
.await;
// 检测是否执行出错(匹配 execute_loop 返回的所有错误前缀)
let is_error = result.starts_with("异常退出")
|| result.starts_with("启动失败")
|| result.starts_with("工具输出非JSON")
|| result.starts_with("HTTP失败")
|| result.starts_with("读取stdout失败")
|| result.starts_with("stdout超时")
|| result.starts_with("进程超时")
|| result.starts_with("进程异常")
|| result.starts_with("未知消息类型")
|| result.starts_with("未知工具")
|| result.starts_with("不支持的方法")
|| result.starts_with("审批失败")
|| result.starts_with("审批超时")
|| result.starts_with("操作已被用户取消")
|| result.starts_with("缺少必填参数")
|| result.starts_with("工具 '");
if is_error {
eprintln!("{result}");
std::process::exit(1);
} else {
println!("{result}");
match outcome {
crate::tools::executor::ToolRunOutcome::Ok(s) => println!("{s}"),
crate::tools::executor::ToolRunOutcome::Err { message, .. } => {
eprintln!("{message}");
std::process::exit(1);
}
}
}
+113 -11
View File
@@ -129,6 +129,33 @@ impl ApprovalManager {
Ok((code_str, rx))
}
/// 测试专用:创建指定 TTL(秒)的审批请求
#[cfg(test)]
async fn create_with_ttl(
&self,
user_id: &str,
skill_name: &str,
ttl_secs: u64,
) -> Result<(String, oneshot::Receiver<ApprovalDecision>), String> {
let code: u32 = rand::rng().random_range(100000..999999);
let code_str = code.to_string();
let code_hash = format!("{:x}", Sha256::digest(code_str.as_bytes()));
let (tx, rx) = oneshot::channel();
let mut map = self.pending.lock().await;
map.entry(user_id.to_string())
.or_default()
.push(PendingEntry {
code_hash,
skill_name: skill_name.to_string(),
attempts_left: 3,
expires_at: Instant::now() + std::time::Duration::from_secs(ttl_secs),
sender: tx,
});
Ok((code_str, rx))
}
/// 处理用户回复(由消息循环调用)
/// 返回 (技能名, 决策);匹配失败返回 None
pub async fn handle_reply(
@@ -192,22 +219,32 @@ impl ApprovalManager {
}
/// 清理过期审批(按时间判定,通知等待方 + 更新 DB)
///
/// 两阶段加锁:收集过期 code_hash → 更新 DB → 重新加锁按 code_hash 定位删除。
/// 不依赖快照 index,避免并发回复导致 index 错位误删。
pub async fn clean_expired(&self) {
let now = Instant::now();
let mut expired = Vec::new();
// 第一阶段:持锁收集过期条目的 code_hash
let mut expired_hashes: Vec<String> = Vec::new();
{
let map = self.pending.lock().await;
for (uid, entries) in map.iter() {
for (i, e) in entries.iter().enumerate() {
for entries in map.values() {
for e in entries {
if now > e.expires_at {
expired.push((uid.clone(), i, e.code_hash.clone()));
expired_hashes.push(e.code_hash.clone());
}
}
}
}
if expired_hashes.is_empty() {
return;
}
// DB 更新(按 code_hash 精确匹配,无错位问题)
if let Some(ref pool) = self.pool {
for (_uid, _i, code_hash) in &expired {
for code_hash in &expired_hashes {
let _ = sqlx::query(
"UPDATE pending_approvals SET status = 'expired' WHERE code_hash = $1 AND status = 'pending'",
)
@@ -217,15 +254,80 @@ impl ApprovalManager {
}
}
// 第二阶段:重新持锁,按 code_hash 定位并移除
// (即使两阶段间 handle_reply 增删了条目,按 hash 查找也能安全跳过)
let mut map = self.pending.lock().await;
for (uid, i, _) in expired.iter().rev() {
if let Some(entries) = map.get_mut(uid)
&& *i < entries.len()
{
let entry = entries.remove(*i);
let _ = entry.sender.send(ApprovalDecision::Expired);
for code_hash in &expired_hashes {
for entries in map.values_mut() {
if let Some(idx) = entries.iter().position(|e| &e.code_hash == code_hash) {
let entry = entries.remove(idx);
let _ = entry.sender.send(ApprovalDecision::Expired);
}
}
}
map.retain(|_, v| !v.is_empty());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn clean_expired_only_removes_expired_entries() {
// 无 DB,纯内存
let mgr = ApprovalManager::new(None);
// 两条审批:一条立即过期(ttl=0),一条 300s 不过期
let (_expired_code, expired_rx) = mgr.create_with_ttl("u1", "tool_a", 0).await.unwrap();
let (live_code, live_rx) = mgr.create_with_ttl("u1", "tool_b", 300).await.unwrap();
// 立即过期的条目 expires_at 已成过去,sleep 确保时间推进
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
mgr.clean_expired().await;
// 过期条目应收到 Expired
assert_eq!(expired_rx.await.unwrap(), ApprovalDecision::Expired);
// 未过期条目不应被误删:handle_reply 用正确确认码应能匹配
let (name, decision) = mgr.handle_reply("u1", &live_code).await.unwrap();
assert_eq!(name, "tool_b");
assert_eq!(decision, ApprovalDecision::Approved);
// live_rx 应收到 Approved(未被 clean_expired 误标记)
assert_eq!(live_rx.await.unwrap(), ApprovalDecision::Approved);
}
#[tokio::test]
async fn clean_expired_safe_when_no_pending() {
// 空 pending 不应 panic
let mgr = ApprovalManager::new(None);
mgr.clean_expired().await;
// 重复调用也安全
mgr.clean_expired().await;
}
#[tokio::test]
async fn clean_expired_skips_entries_already_removed_by_reply() {
// 回归保护:clean_expired 第一阶段收集过期 hash 后、第二阶段删除前,
// 若该条目已被 handle_reply 提前处理,clean_expired 应安全跳过,不误删其他条目。
// (原 bug:按快照 index 删除会导致误删;现按 code_hash 删除应安全跳过)
let mgr = ApprovalManager::new(None);
// 两条同用户审批,都设为立即过期
let (_code_a, rx_a) = mgr.create_with_ttl("u1", "tool_a", 0).await.unwrap();
let (_code_b, rx_b) = mgr.create_with_ttl("u1", "tool_b", 0).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
// 先用 handle_reply “0” 取消最早的条目(tool_a),模拟并发回复
let (name, decision) = mgr.handle_reply("u1", "0").await.unwrap();
assert_eq!(name, "tool_a");
assert_eq!(decision, ApprovalDecision::Rejected);
assert_eq!(rx_a.await.unwrap(), ApprovalDecision::Rejected);
// 再 clean_expiredtool_a 已被 remove,按 hash 查找应跳过;
// tool_b 仍在且过期,应被正确标记 Expired(不会被误当成 tool_a 跳过)
mgr.clean_expired().await;
assert_eq!(rx_b.await.unwrap(), ApprovalDecision::Expired);
}
}
+193 -78
View File
@@ -5,7 +5,11 @@
//! - `{"type":"db",...}` — 请求数据库操作
//! - `{"type":"result","content":"..."}` — 最终结果
//!
//! 所有消息必须是合法 JSON。审批在 http/db 操作前处理。
//! 所有消息必须是合法 JSON。
//!
//! ## 安全边界(授权层)
//! **工具级审批在入口统一执行**:高风险工具在 spawn 之前必须获得用户确认,
//! 与工具后续输出的消息类型(result/http/db)无关 —— 杜绝 stdio→result 绕过。
use std::sync::Arc;
use std::time::Duration;
@@ -24,6 +28,59 @@ struct HttpResponse {
body: Value,
}
// ─── 结构化结果 ───
/// 工具执行错误类别(供调用方区分,替代字符串前缀匹配)
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToolErrorKind {
SpawnFailed,
StdoutReadFailed,
StdoutTimeout,
ProcessTimeout,
ProcessAbnormal,
NonJsonOutput,
UnknownMessageType,
UnsupportedMethod,
HttpFailed,
ApprovalFailed,
ApprovalTimeout,
ApprovalRejected,
MaxRoundsExceeded,
UnknownTool,
MissingParams,
}
/// 工具执行返回值:成功带内容,失败带类别与消息
#[derive(Debug, Clone)]
pub enum ToolRunOutcome {
Ok(String),
Err {
kind: ToolErrorKind,
message: String,
},
}
impl ToolRunOutcome {
pub fn err(kind: ToolErrorKind, message: impl Into<String>) -> Self {
ToolRunOutcome::Err {
kind,
message: message.into(),
}
}
pub fn is_err(&self) -> bool {
matches!(self, ToolRunOutcome::Err { .. })
}
/// 取出最终展示给 LLM 的消息文本
pub fn into_message(self) -> String {
match self {
ToolRunOutcome::Ok(s) => s,
ToolRunOutcome::Err { message, .. } => message,
}
}
}
pub async fn execute_loop(
spec: &ToolSpec,
params: &Value,
@@ -36,11 +93,32 @@ pub async fn execute_loop(
+ Send
+ Sync
),
) -> String {
) -> ToolRunOutcome {
// ── 统一授权边界:高风险工具在 spawn 前必须通过用户审批 ──
// 不依赖后续消息类型,覆盖 stdio/http/db 所有路径
if spec.is_high_risk() && !approved {
let (code, rx) = match approval.create(user_id, &spec.name).await {
Ok((c, r)) => (c, r),
Err(e) => return ToolRunOutcome::err(ToolErrorKind::ApprovalFailed, format!("审批失败: {e}")),
};
wechat_send(
user_id,
&format!("⚠️ {}\n确认码: {}\n回复确认码继续,回复0取消", spec.desc, code),
)
.await;
match rx.await.unwrap_or(ApprovalDecision::Expired) {
ApprovalDecision::Approved => { /* 授权通过,继续执行 */ }
ApprovalDecision::Rejected => {
return ToolRunOutcome::err(ToolErrorKind::ApprovalRejected, "操作已被用户取消")
}
ApprovalDecision::Expired => {
return ToolRunOutcome::err(ToolErrorKind::ApprovalTimeout, "审批超时,操作已取消")
}
}
}
let start = std::time::Instant::now();
let binary_path = spec.path.as_deref().unwrap_or(&spec.tool);
const MAX_ROUNDS: u32 = 20;
let mut response: Option<HttpResponse> = None;
@@ -51,7 +129,10 @@ pub async fn execute_loop(
round += 1;
if round > MAX_ROUNDS {
error!(target: "ias::err", "[loop] 工具 '{}' 超过最大轮次 {}", spec.name, MAX_ROUNDS);
return format!("工具 '{}' 执行轮次过多(>{})", spec.name, MAX_ROUNDS);
return ToolRunOutcome::err(
ToolErrorKind::MaxRoundsExceeded,
format!("工具 '{}' 执行轮次过多(>{})", spec.name, MAX_ROUNDS),
);
}
// ── 信封 ──
@@ -64,32 +145,20 @@ 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);
}
// 按工具 spec 定义的 env 列表,从当前进程环境推送对应变量
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())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
let mut child = match cmd.spawn() {
// 执行隔离边界:启用沙箱且工具 profile 非 local 时,通过 docker exec
// 在长期容器内执行;否则本地 spawn(env_clear + 白名单注入)。
// 沙箱降级(ensure 失败或容器崩溃未恢复)时自动回退本地执行。
let use_docker = if crate::tools::sandbox::should_use_docker(&spec.sandbox.profile) {
// 执行前确保容器就绪:未运行则重建,重建失败则降级
crate::tools::sandbox::ensure_ready_or_degrade("tools").await
} else {
false
};
let mut child = match spawn_child(spec, binary_path, use_docker).await {
Ok(c) => c,
Err(e) => {
error!(target: "ias::err","[spawn] {e}");
return format!("启动失败: {e}");
return ToolRunOutcome::err(ToolErrorKind::SpawnFailed, format!("启动失败: {e}"));
}
};
@@ -129,11 +198,14 @@ pub async fn execute_loop(
Ok(Ok(l)) => l,
Ok(Err(e)) => {
let _ = child.start_kill();
return format!("读取stdout失败: {e}");
return ToolRunOutcome::err(ToolErrorKind::StdoutReadFailed, format!("读取stdout失败: {e}"));
}
Err(_) => {
let _ = child.start_kill();
return format!("stdout超时({}s)", spec.timeout_secs);
return ToolRunOutcome::err(
ToolErrorKind::StdoutTimeout,
format!("stdout超时({}s)", spec.timeout_secs),
);
}
};
@@ -152,12 +224,15 @@ pub async fn execute_loop(
Ok(Ok(s)) => s,
Ok(Err(e)) => {
error!(target: "ias::err","[process] {e}");
return format!("进程异常: {e}");
return ToolRunOutcome::err(ToolErrorKind::ProcessAbnormal, format!("进程异常: {e}"));
}
Err(_) => {
let _ = child.start_kill();
error!(target: "ias::err","[timeout] {}s", spec.timeout_secs);
return format!("进程超时({}s)", spec.timeout_secs);
return ToolRunOutcome::err(
ToolErrorKind::ProcessTimeout,
format!("进程超时({}s)", spec.timeout_secs),
);
}
};
@@ -168,7 +243,10 @@ pub async fn execute_loop(
} else {
stderr_trimmed.to_string()
};
return format!("异常退出({}): {}", status.code().unwrap_or(-1), reason);
return ToolRunOutcome::err(
ToolErrorKind::ProcessAbnormal,
format!("异常退出({}): {}", status.code().unwrap_or(-1), reason),
);
}
// ── 解析 ──
@@ -177,7 +255,10 @@ pub async fn execute_loop(
Ok(v) => v,
Err(_) => {
warn!(target: "ias::err","[non-json] {:.100}", trimmed);
return format!("工具输出非JSON: {:.100}", trimmed);
return ToolRunOutcome::err(
ToolErrorKind::NonJsonOutput,
format!("工具输出非JSON: {:.100}", trimmed),
);
}
};
@@ -186,30 +267,11 @@ pub async fn execute_loop(
let content = msg["content"].as_str().unwrap_or("");
let elapsed = start.elapsed();
info!(target: "ias::tool", "[done] {} 完成, 耗时 {:?}, {} 轮", spec.name, elapsed, round);
return content.to_string();
return ToolRunOutcome::Ok(content.to_string());
}
Some("http") => {
let method = msg["method"].as_str().unwrap_or("GET");
let url = msg["url"].as_str().unwrap_or("");
let desc = msg["desc"].as_str().unwrap_or("");
if spec.is_high_risk() && !approved {
let (code, rx) = match approval.create(user_id, &spec.name).await {
Ok((c, r)) => (c, r),
Err(e) => return format!("审批失败: {e}"),
};
wechat_send(
user_id,
&format!("⚠️ {}\n确认码: {}\n回复确认码继续,回复0取消", desc, code),
)
.await;
let d = rx.await.unwrap_or(ApprovalDecision::Expired);
match d {
ApprovalDecision::Approved => { /* 继续执行 */ }
ApprovalDecision::Rejected => return "操作已被用户取消".to_string(),
ApprovalDecision::Expired => return "审批超时,操作已取消".to_string(),
}
}
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(25))
@@ -227,7 +289,9 @@ pub async fn execute_loop(
.await
}
("POST", None) => client.post(url).send().await,
(o, _) => return format!("不支持的方法: {o}"),
(o, _) => {
return ToolRunOutcome::err(ToolErrorKind::UnsupportedMethod, format!("不支持的方法: {o}"))
}
};
match resp {
Ok(r) => {
@@ -237,32 +301,13 @@ pub async fn execute_loop(
}
Err(e) => {
error!(target: "ias::err","[http] {e}");
return format!("HTTP失败: {e}");
return ToolRunOutcome::err(ToolErrorKind::HttpFailed, format!("HTTP失败: {e}"));
}
}
}
Some("db") => {
let op = msg["operation"].as_str().unwrap_or("");
let op_params = msg.get("params").cloned().unwrap_or(Value::Null);
let desc = msg["desc"].as_str().unwrap_or(op);
if spec.is_high_risk() && !approved {
let (code, rx) = match approval.create(user_id, &spec.name).await {
Ok((c, r)) => (c, r),
Err(e) => return format!("审批失败: {e}"),
};
wechat_send(
user_id,
&format!("⚠️ {}\n确认码: {}\n回复确认码继续,回复0取消", desc, code),
)
.await;
let d = rx.await.unwrap_or(ApprovalDecision::Expired);
match d {
ApprovalDecision::Approved => { /* 继续执行 */ }
ApprovalDecision::Rejected => return "操作已被用户取消".to_string(),
ApprovalDecision::Expired => return "审批超时,操作已取消".to_string(),
}
}
let result = match op {
"read_memories" => memory.read_for(user_id, None).await,
@@ -271,8 +316,7 @@ pub async fn execute_loop(
if c.is_empty() {
"缺少 content".into()
} else {
let write_result = memory.write_for(user_id, c).await;
write_result
memory.write_for(user_id, c).await
}
}
"delete_memory" => {
@@ -290,8 +334,79 @@ pub async fn execute_loop(
}
_ => {
warn!(target: "ias::err","[unknown-type] {}", msg["type"]);
return format!("未知消息类型: {}", msg["type"]);
return ToolRunOutcome::err(
ToolErrorKind::UnknownMessageType,
format!("未知消息类型: {}", msg["type"]),
);
}
}
}
}
/// 本地执行: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
}
/// spawn 工具进程。docker exec 模式下若首次 spawn 失败,尝试检测容器崩溃并重建后重试一次;
/// 重建失败则降级为本地执行(保留可用性,但仍受审批约束)。
async fn spawn_child(
spec: &ToolSpec,
binary_path: &str,
use_docker: bool,
) -> Result<tokio::process::Child, std::io::Error> {
let make_cmd = || {
let mut cmd = if use_docker {
crate::tools::sandbox::build_exec_cmd(spec)
} else {
build_local_cmd(spec, binary_path)
};
cmd.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
cmd
};
if let Ok(child) = make_cmd().spawn() {
return Ok(child);
}
// 首次 spawn 失败。docker 模式下可能是容器崩溃,尝试重建后重试一次
if use_docker && crate::tools::sandbox::recover_if_crashed("tools").await {
tracing::info!(target: "ias::sandbox", "沙箱容器已重建,重试执行");
if let Ok(child) = make_cmd().spawn() {
return Ok(child);
}
// 重建后仍失败,降级本地执行
tracing::warn!(target: "ias::sandbox", "重建后 exec 仍失败,降级本地执行");
} else if use_docker {
tracing::warn!(target: "ias::sandbox", "容器不可用,降级本地执行");
}
// 降级:本地 spawn(审批已在入口完成,安全边界不受影响)
build_local_cmd(spec, binary_path)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true)
.spawn()
}
+1
View File
@@ -6,4 +6,5 @@ pub mod approval;
pub mod executor;
pub mod parser;
pub mod registry;
pub mod sandbox;
pub mod spec;
+26 -17
View File
@@ -8,7 +8,7 @@ use std::sync::Arc;
use crate::context::MemoryStore;
use crate::tools::approval::ApprovalManager;
use crate::tools::executor;
use crate::tools::executor::{self, ToolErrorKind, ToolRunOutcome};
use crate::tools::parser;
use crate::tools::spec::ToolSpec;
use serde_json::Value;
@@ -29,7 +29,7 @@ pub struct ParamSummary {
}
/// 解析工具目录。相对路径基于二进制所在目录。
fn resolve_dir(dir: &str) -> PathBuf {
pub(crate) fn resolve_dir(dir: &str) -> PathBuf {
let path = Path::new(dir);
if path.is_absolute() {
return path.to_path_buf();
@@ -89,15 +89,22 @@ impl Registry {
}
for mut spec in result.specs {
// 解析二进制路径
let binary = match &spec.path {
Some(p) => {
let p = p.trim_start_matches('/');
path.join(p)
}
None => {
// 默认: {tool_dir}/target/release/{tool}
path.join("target/release").join(&spec.tool)
// 优先使用统一产物目录 tools/bin/<tool>,不存在则 fallback 到工具子目录
let binary = {
let unified = root.join("bin").join(&spec.tool);
if unified.exists() {
unified
} else {
match &spec.path {
Some(p) => {
let p = p.trim_start_matches('/');
path.join(p)
}
None => {
// 默认: {tool_dir}/target/release/{tool}
path.join("target/release").join(&spec.tool)
}
}
}
};
@@ -153,16 +160,16 @@ impl Registry {
+ Send
+ Sync
),
) -> String {
) -> ToolRunOutcome {
let spec = match self.get(name) {
Some(s) => s,
None => {
tracing::warn!(target:"ias::registry","未知: {name}");
return format!("未知工具: {name}");
return ToolRunOutcome::err(ToolErrorKind::UnknownTool, format!("未知工具: {name}"));
}
};
if let Some(err) = validate_required_params(spec, params) {
return err;
return ToolRunOutcome::err(ToolErrorKind::MissingParams, err);
}
executor::execute_loop(
spec,
@@ -215,20 +222,20 @@ impl RegistryManager {
+ Send
+ Sync
),
) -> String {
) -> ToolRunOutcome {
let spec = {
let registry = self.registry.read().await;
match registry.get(name) {
Some(spec) => spec.clone(),
None => {
tracing::warn!(target:"ias::registry","未知: {name}");
return format!("未知工具: {name}");
return ToolRunOutcome::err(ToolErrorKind::UnknownTool, format!("未知工具: {name}"));
}
}
};
if let Some(err) = validate_required_params(&spec, params) {
return err;
return ToolRunOutcome::err(ToolErrorKind::MissingParams, err);
}
executor::execute_loop(
@@ -340,6 +347,7 @@ path: /target/release/mytool
required: true,
desc: "城市".into(),
}],
sandbox: Default::default(),
};
let err = validate_required_params(&spec, &serde_json::json!({})).unwrap();
@@ -363,6 +371,7 @@ path: /target/release/mytool
required: true,
desc: "城市".into(),
}],
sandbox: Default::default(),
};
assert!(
+216
View File
@@ -0,0 +1,216 @@
//! # 沙箱容器管理
//!
//! 长期容器模式:daemon 启动时创建 `ias-sandbox` 容器(无网络、只读根、cap-drop ALL),
//! 统一产物目录 `tools/bin/` 只读挂载到容器 `/tools`。工具调用通过 `docker exec`
//! 在容器内执行,相比每次 `docker run` 省去容器创建/销毁开销。
//!
//! ## 超时与清理
//! `docker exec` 内用 `timeout -k 5 <secs>` 包裹工具进程:超时发 SIGTERM,
//! 5 秒后仍不退出发 SIGKILL,确保容器内不累积孤儿进程。executor 的 tokio timeout
//! 作为外层保险,kill `docker exec` 客户端释放宿主资源。
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::process::Command;
use crate::tools::spec::ToolSpec;
/// 长期沙箱容器名
pub const CONTAINER_NAME: &str = "ias-sandbox";
/// 容器内产物挂载点
const MOUNT_TARGET: &str = "/tools";
/// 降级标志:ensure_container 失败或容器崩溃后置 true,
/// executor 据此回退本地执行,避免不受信任工具持续 SpawnFailed
static DEGRADED: AtomicBool = AtomicBool::new(false);
/// 全局沙箱开关:`IAS_DOCKER_SANDBOX=1` 启用
pub fn enabled() -> bool {
std::env::var("IAS_DOCKER_SANDBOX")
.ok()
.as_deref()
== Some("1")
}
/// 当前是否处于降级状态(ensure 失败或容器崩溃)
pub fn degraded() -> bool {
DEGRADED.load(Ordering::SeqCst)
}
/// 执行前检查:沙箱启用且未降级时才走 docker exec
pub fn should_use_docker(profile: &str) -> bool {
enabled() && profile != "local" && !degraded()
}
/// 确保 ias-sandbox 长期容器在运行。daemon 启动时调用。
///
/// - 容器已运行:直接复用
/// - 容器不存在/已停止:清理后重新创建
///
/// 失败时设置降级标志,executor 据此回退本地执行。
pub async fn ensure_container(dir: &str) -> Result<(), String> {
match ensure_container_inner(dir).await {
Ok(()) => {
DEGRADED.store(false, Ordering::SeqCst);
Ok(())
}
Err(e) => {
DEGRADED.store(true, Ordering::SeqCst);
tracing::warn!(target: "ias::sandbox", "沙箱降级为本地执行: {e}");
Err(e)
}
}
}
async fn ensure_container_inner(dir: &str) -> Result<(), String> {
let root = locate_tools_root(dir)?;
let bin_dir = root.join("bin");
std::fs::create_dir_all(&bin_dir).map_err(|e| format!("创建产物目录失败: {e}"))?;
// 检查是否已在运行
let running = Command::new("docker")
.args(["inspect", "-f", "{{.State.Running}}", CONTAINER_NAME])
.output()
.await
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "true")
.unwrap_or(false);
if running {
tracing::info!(target: "ias::sandbox", "沙箱容器已运行,复用");
return Ok(());
}
// 清理旧的已停止容器
let _ = Command::new("docker")
.args(["rm", "-f", CONTAINER_NAME])
.output()
.await;
// 启动长期容器
let output = Command::new("docker")
.arg("run")
.arg("-d")
.arg("--rm")
.arg("--name")
.arg(CONTAINER_NAME)
.arg("--network")
.arg("none")
.arg("--cap-drop")
.arg("ALL")
.arg("--security-opt")
.arg("no-new-privileges")
.arg("--read-only")
.arg("--memory")
.arg("512m")
.arg("--cpus")
.arg("1")
.arg("--pids-limit")
.arg("128")
.arg("--mount")
.arg(format!(
"type=bind,source={},target={MOUNT_TARGET},readonly",
bin_dir.display()
))
.arg("--mount")
.arg("type=tmpfs,target=/tmp")
.arg("ias-stdio-runner")
.args(["sleep", "infinity"])
.output()
.await
.map_err(|e| format!("启动沙箱容器失败: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("沙箱容器启动失败: {}", stderr.trim()));
}
tracing::info!(
target: "ias::sandbox",
"沙箱容器已启动 ({} → {})",
bin_dir.display(),
MOUNT_TARGET
);
Ok(())
}
/// exec 失败时调用:检测容器是否崩溃,崩溃则尝试重建。
/// 重建成功返回 true(调用方可重试 exec);否则置降级标志返回 false。
pub async fn recover_if_crashed(dir: &str) -> bool {
let running = Command::new("docker")
.args(["inspect", "-f", "{{.State.Running}}", CONTAINER_NAME])
.output()
.await
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "true")
.unwrap_or(false);
if running {
// 容器在运行,exec 失败是其他原因(如工具二进制不存在),不降级
return false;
}
tracing::warn!(target: "ias::sandbox", "沙箱容器已崩溃,尝试重建");
ensure_container(dir).await.is_ok()
}
/// 执行前调用:沙箱启用且未降级时,确保容器在运行。
/// 容器不在运行则重建;重建失败置降级标志并返回 false(调用方降级本地执行)。
pub async fn ensure_ready_or_degrade(dir: &str) -> bool {
if !enabled() || degraded() {
return false;
}
let running = Command::new("docker")
.args(["inspect", "-f", "{{.State.Running}}", CONTAINER_NAME])
.output()
.await
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "true")
.unwrap_or(false);
if running {
return true;
}
// 容器不在运行,尝试重建
if ensure_container(dir).await.is_ok() {
true
} else {
tracing::warn!(target: "ias::sandbox", "容器不可用,降级本地执行");
false
}
}
/// 构建 `docker exec` 命令,在长期容器内执行工具。
///
/// 容器内路径 = MOUNT_TARGET + 二进制文件名。
/// 用 `timeout -k 5` 包裹:超时 SIGTERM5 秒后 SIGKILL,避免孤儿进程。
/// env 按 spec 白名单注入(`-e` 必须在容器名前)。
pub fn build_exec_cmd(spec: &ToolSpec) -> Command {
// 直接用 spec.tool 作为容器内文件名,语义清晰,不依赖 spec.path 格式
let container_path = format!("{MOUNT_TARGET}/{}", spec.tool);
let mut cmd = Command::new("docker");
cmd.arg("exec").arg("-i");
// env 白名单注入(OPTIONS,必须在容器名前)
for key in &spec.env {
if let Ok(v) = std::env::var(key) {
cmd.arg("-e").arg(format!("{key}={v}"));
}
}
cmd.arg(CONTAINER_NAME);
// timeout -k 5 <secs>:超时 SIGTERM5s 后 SIGKILL
cmd.arg("timeout").arg("-k").arg("5").arg(spec.timeout_secs.to_string());
cmd.arg(container_path);
if let Some(c) = &spec.command {
cmd.arg("--command").arg(c);
}
cmd
}
/// 复用 registry 的路径解析逻辑定位 tools 根目录并转绝对路径
fn locate_tools_root(dir: &str) -> Result<PathBuf, String> {
let resolved = crate::tools::registry::resolve_dir(dir);
resolved
.canonicalize()
.map_err(|e| format!("无法定位 tools 目录 {}: {e}", resolved.display()))
}
+46
View File
@@ -13,6 +13,48 @@ pub struct ParamSpec {
pub desc: String,
}
/// 沙箱配置(Docker 一次性容器隔离)
///
/// ```yaml
/// sandbox:
/// profile: stdio # local | stdio | 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` 无网络只读容器
/// (供 tool_manager 产出的不受信任工具);`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<ParamSpec>,
/// 沙箱配置(Docker 隔离);默认 profile=local 不启用容器
#[serde(default)]
pub sandbox: SandboxSpec,
}
fn default_risk_level() -> String {
+1
View File
@@ -15,3 +15,4 @@ params:
- name: city
required: false
desc: 城市名称,可选。限定地理编码的城市范围
+1
View File
@@ -12,3 +12,4 @@ params:
- name: center
required: false
desc: 地图中心点经纬度坐标,格式"经度,纬度",默认天安门坐标
@@ -18,3 +18,4 @@ params:
- name: radius
required: false
desc: 搜索半径(米),如 500、1000、3000。默认 1000 米
@@ -12,3 +12,4 @@ params:
- name: location
required: true
desc: 经纬度坐标,格式为"经度,纬度",如"116.397,39.908"
@@ -18,3 +18,4 @@ params:
- name: type
required: false
desc: 出行方式:driving(驾车)、walking(步行)、riding(骑行)、transit(公交)。默认 driving
@@ -15,3 +15,4 @@ params:
- name: interests
required: false
desc: 感兴趣的关键词,逗号分隔,如"景点,美食,酒店"。默认"景点,美食"
+5 -4
View File
@@ -1,17 +1,18 @@
#!/bin/bash
# 构建所有工具
# 构建所有工具,产物统一收集到 bin/ 供长期沙箱容器挂载
set -e
cd "$(dirname "$0")"
TOOLS=(datetime weather amap web_search fetch_page memories tool_manager)
mkdir -p bin
echo "=== 构建工具 ==="
for tool in "${TOOLS[@]}"; do
echo " $tool..."
(cd "$tool" && cargo build --release --quiet)
cp "$tool/target/release/$tool" "bin/$tool"
done
echo "=== 完成 ==="
for tool in "${TOOLS[@]}"; do
ls -lh "$tool/target/release/$tool" 2>/dev/null
done
ls -lh bin/*
@@ -6,3 +6,4 @@ path: /target/release/datetime
risk_level: low
timeout_secs: 3
params: []
@@ -9,3 +9,4 @@ params:
- name: url
required: true
desc: 网页URL
@@ -12,3 +12,4 @@ params:
- name: memory_id
required: true
desc: 记忆ID
@@ -12,3 +12,4 @@ params:
- name: memory_id
required: true
desc: 记忆的数字ID,从 read_memories 返回结果中获取
@@ -9,3 +9,4 @@ timeout_secs: 3
env:
- IAS_MEMORY_DIR
params:
@@ -15,3 +15,4 @@ params:
- name: content
required: true
desc: 更新后的新内容,覆盖原记忆
@@ -12,3 +12,4 @@ params:
- name: content
required: true
desc: 要记录的记忆内容,用自然语言描述。如"用户住在合肥包河区联投新安里A区"
@@ -4,7 +4,11 @@ type: stdio
tool: tool_manager
path: /target/release/tool_manager
risk_level: high
timeout_secs: 120
timeout_secs: 300
env:
- HOME
- CARGO_HOME
- RUSTUP_HOME
params:
- name: action
required: true
@@ -23,7 +27,7 @@ params:
desc: 参数列表数组,每项包含 name、required、desc
- name: risk_level
required: false
desc: 新工具风险级别,low 或 high,默认 low
desc: 已弃用,tool_manager 创建/更新的工具一律强制 high risk,传入值将被忽略
- name: timeout_secs
required: false
desc: 新工具超时时间秒数,默认 30
+139 -24
View File
@@ -1,10 +1,22 @@
//! tool_manager — 受控创建、修改、构建本地工具
//!
//! 安全约束:
//! - 不允许操作保留工具名(含 `tool_manager` 自身),防止自改后门
//! - 由本工具创建/更新的工具一律 `risk_level: high`,忽略调用方传入值
//! - 所有调用追加写入 `tools/.tool_audit.log` 审计日志
//! - 构建使用 `timeout` 限制时长,失败时清理残留二进制,避免调用旧版本"假成功"
use serde_json::{Value, json};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
/// 保留工具名 —— 禁止 create/update/build
const RESERVED_NAMES: &[&str] = &["target", "specs", "src", ".", "..", "tool_manager"];
/// 构建超时(秒)。需小于 tool_manager 自身的 timeout_secs。
const BUILD_TIMEOUT_SECS: u64 = 180;
fn main() {
let input: Value = match serde_json::from_reader(std::io::stdin()) {
Ok(v) => v,
@@ -12,13 +24,20 @@ fn main() {
};
let params = &input["params"];
let user_id = input["user_id"].as_str().unwrap_or("unknown");
let action = params["action"].as_str().unwrap_or("");
let tool_name = params["tool_name"].as_str().unwrap_or("");
let output = match run(action, tool_name, params) {
Ok(msg) => msg,
Err(e) => format!("工具管理失败: {e}"),
let (output, success) = match run(action, tool_name, params) {
Ok(msg) => (msg, true),
Err(e) => (format!("工具管理失败: {e}"), false),
};
// 审计日志(best effort,不影响主流程)
if let Ok(tools_root) = locate_tools_root() {
audit(&tools_root, user_id, action, tool_name, success, &output);
}
result(&output);
}
@@ -38,7 +57,7 @@ fn run(action: &str, tool_name: &str, params: &Value) -> Result<String, String>
write_tool_project(&tool_dir, tool_name, params)?;
build_tool(&tool_dir, tool_name)?;
Ok(format!(
"已创建并构建工具 {tool_name}。可通过 query_capabilities 查看并用 call_capability 调用。"
"已创建并构建工具 {tool_name}risk_level 强制为 high。可通过 query_capabilities 查看并用 call_capability 调用。"
))
}
"update_tool" => {
@@ -48,7 +67,7 @@ fn run(action: &str, tool_name: &str, params: &Value) -> Result<String, String>
update_tool_project(&tool_dir, tool_name, params)?;
build_tool(&tool_dir, tool_name)?;
Ok(format!(
"已修改并构建工具 {tool_name}。注册表重载后即可使用最新版本。"
"已修改并构建工具 {tool_name}risk_level 强制为 high。注册表重载后即可使用最新版本。"
))
}
"build_tool" => {
@@ -83,6 +102,7 @@ fn update_tool_project(tool_dir: &Path, tool_name: &str, params: &Value) -> Resu
fs::write(tool_dir.join("src/main.rs"), code).map_err(|e| e.to_string())?;
}
// spec 一旦由 tool_manager 管理,risk_level 恒为 high
let should_update_spec = params.get("description").is_some()
|| params.get("params").is_some()
|| params.get("risk_level").is_some()
@@ -101,31 +121,80 @@ 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 output = Command::new("cargo")
.arg("build")
.arg("--release")
.current_dir(tool_dir)
.output()
.map_err(|e| format!("启动 cargo 失败: {e}"))?;
let binary = tool_dir.join("target/release").join(tool_name);
let use_builder = std::env::var("IAS_DOCKER_BUILDER")
.ok()
.as_deref()
== Some("1");
let (uid, gid) = current_uid_gid();
// 启用 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("--user").arg(format!("{}:{}", uid, gid))
.arg("-v").arg(format!("{}:/work", tool_dir.display()))
.arg("-w").arg("/work")
.arg("-e").arg("CARGO_NET_OFFLINE=true")
.arg("-e").arg("CARGO_HOME=/tmp/cargo")
.arg("ias-tool-builder")
.arg("timeout").arg(BUILD_TIMEOUT_SECS.to_string())
.arg("cargo").arg("build").arg("--release").arg("--offline")
.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 后仍调用旧版本造成"假成功"
let _ = fs::remove_file(&binary);
let code = output.status.code().unwrap_or(-1);
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("cargo build 失败: {}", stderr.trim()));
let reason = if code == 124 {
format!("构建超时({BUILD_TIMEOUT_SECS}s")
} else {
stderr.trim().to_string()
};
return Err(format!("cargo build 失败(退出码 {code}): {reason}"));
}
let binary = tool_dir.join("target/release").join(tool_name);
if !binary.exists() {
return Err(format!("构建后未找到二进制: {}", binary.display()));
}
// 复制到统一产物目录 tools/bin/,供长期沙箱容器挂载使用
let tools_root = tool_dir
.parent()
.ok_or_else(|| "无法定位 tools 根目录".to_string())?;
let bin_dir = tools_root.join("bin");
fs::create_dir_all(&bin_dir).map_err(|e| format!("创建产物目录失败: {e}"))?;
fs::copy(&binary, bin_dir.join(tool_name))
.map_err(|e| format!("复制产物到 bin 失败: {e}"))?;
Ok(())
}
fn locate_tools_root() -> Result<PathBuf, String> {
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
// exe = tools/tool_manager/target/release/tool_manager
let tool_dir = exe
.parent()
.and_then(Path::parent)
.and_then(Path::parent)
.parent() // .../target/release
.and_then(Path::parent) // .../target
.and_then(Path::parent) // .../tool_manager
.ok_or_else(|| "无法定位 tool_manager 目录".to_string())?;
tool_dir
.parent()
@@ -171,14 +240,13 @@ fn main() {{
}
fn spec_yaml(tool_name: &str, params: &Value) -> String {
// 安全约束:tool_manager 产出的工具一律高风险,忽略调用方传入的 risk_level
let risk_level = "high";
let timeout_secs = params["timeout_secs"].as_u64().unwrap_or(30).clamp(1, 300);
let desc = params["description"]
.as_str()
.unwrap_or("由 tool_manager 创建的本地工具");
let risk_level = match params["risk_level"].as_str() {
Some("high") => "high",
_ => "low",
};
let timeout_secs = params["timeout_secs"].as_u64().unwrap_or(30).clamp(1, 300);
.unwrap_or("由 tool_manager 创建的本地工具(自动标记 high risk");
let mut out = format!(
"name: {tool_name}\n\
@@ -211,6 +279,10 @@ fn spec_yaml(tool_name: &str, params: &Value) -> String {
out.push_str("params: []\n");
}
// 安全约束:tool_manager 产出的工具不受信任(含 LLM 提供的 rust_code),
// 默认进 stdio 沙箱容器(无网络、只读根),防止数据外泄/越权文件读
out.push_str("sandbox:\n profile: stdio\n");
out
}
@@ -218,8 +290,10 @@ fn validate_tool_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("缺少 tool_name".to_string());
}
if matches!(name, "target" | "specs" | "src" | "." | "..") {
return Err("tool_name 使用了保留名称".to_string());
if RESERVED_NAMES.contains(&name) {
return Err(format!(
"tool_name '{name}' 是保留名称,禁止 create/update/build"
));
}
if !name
.chars()
@@ -253,6 +327,47 @@ fn truthy(value: Option<&Value>) -> bool {
}
}
/// 获取当前进程的 uid:gid,用于 builder 容器 --user,使容器内进程能写 bind mount 的宿主目录
fn current_uid_gid() -> (String, String) {
let uid = String::from_utf8_lossy(
&Command::new("id").arg("-u").output().map(|o| o.stdout).unwrap_or_default(),
)
.trim()
.to_string();
let gid = String::from_utf8_lossy(
&Command::new("id").arg("-g").output().map(|o| o.stdout).unwrap_or_default(),
)
.trim()
.to_string();
(uid, gid)
}
/// 追加审计日志到 tools 根目录下的 .tool_audit.log
fn audit(
tools_root: &Path,
user_id: &str,
action: &str,
tool_name: &str,
success: bool,
detail: &str,
) {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
// detail 可能很长,截断到 200 字节避免日志膨胀
let detail: String = detail.chars().take(200).collect();
let line = format!(
"{ts}\t{user_id}\t{action}\t{tool_name}\t{}\t{detail}\n",
if success { "ok" } else { "err" }
);
let path = tools_root.join(".tool_audit.log");
if let Ok(mut f) = fs::OpenOptions::new().create(true).append(true).open(&path) {
use std::io::Write;
let _ = f.write_all(line.as_bytes());
}
}
fn result(content: &str) {
println!("{}", json!({"type":"result","content":content}));
}
@@ -12,3 +12,4 @@ params:
- name: location
required: true
desc: 城市名称,如"北京"、"上海"、"合肥"。支持地级市和县级市
@@ -12,3 +12,4 @@ params:
- name: location
required: true
desc: 城市名称,如"北京"、"上海"、"合肥"。支持地级市和县级市
@@ -12,3 +12,4 @@ params:
- name: location
required: true
desc: 城市名称,如"北京"、"上海"、"合肥"。支持地级市和县级市
@@ -13,3 +13,4 @@ params:
desc: 搜索关键词,如"Rust 2024 edition 新特性"、"今天比特币价格"
- name: max_results
desc: 返回的最大搜索结果数量(1-20),默认5条