Merge branch 'security/sandbox-boundaries'
两层安全边界落地: - 授权层:统一工具级审批,堵住 stdio→result 绕过 - 隔离层:长期沙箱容器 + docker exec,仅不受信任代码进容器 - 审计层:tool_manager 调用全留痕 9 个提交,28 测试通过,clippy 无新增警告。
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
# Rust 构建产物
|
||||
/target
|
||||
tools/*/target/
|
||||
tools/bin/
|
||||
dist/
|
||||
|
||||
# 运行时数据(状态文件、缓存、聊天记录本地副本等)
|
||||
.data/
|
||||
.pi
|
||||
# tool_manager 审计日志
|
||||
tools/.tool_audit.log
|
||||
|
||||
# 环境配置(含密钥,不入库)
|
||||
.env
|
||||
|
||||
@@ -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 []
|
||||
@@ -0,0 +1,13 @@
|
||||
# ias-tool-builder — tool_manager 编译 Rust 工具的隔离环境
|
||||
# 预缓存 serde_json(tool_manager 生成工具的唯一依赖),支持 --network none 离线编译。
|
||||
# CARGO_HOME=/tmp/cargo 并 chmod 777,使任意 uid(--user 运行)都能复用缓存。
|
||||
# 运行时由 tool_manager 挂载 tool_dir 到 /work,cargo 在 /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 []
|
||||
Executable
+16
@@ -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-builder(Rust 编译容器)==="
|
||||
docker build -t ias-tool-builder -f Dockerfile.tool-builder .
|
||||
|
||||
echo "=== 完成 ==="
|
||||
docker images | grep -E '^ias-(stdio|tool)'
|
||||
@@ -0,0 +1,186 @@
|
||||
# 两层安全边界设计
|
||||
|
||||
> 审批决定"能不能做",Docker 限制"做坏了能坏到哪里",审计回答"谁让系统新增或修改了什么能力"。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
LLM 调用工具
|
||||
→ registry.dispatch(name, params, user_id, approved)
|
||||
→ load spec + validate params
|
||||
→ 【授权边界】if high_risk && !approved: 统一审批(不依赖消息类型)
|
||||
→ 【隔离边界】按 sandbox.profile 选择执行方式:
|
||||
- local : 本地 spawn(env_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 | 可写 /work(bind 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` 据此判断是否 reload(tool_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-sandbox,exec 完无孤儿进程 |
|
||||
| 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
@@ -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
@@ -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
@@ -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_expired:tool_a 已被 remove,按 hash 查找应跳过;
|
||||
// tool_b 仍在且过期,应被正确标记 Expired(不会被误当成 tool_a 跳过)
|
||||
mgr.clean_expired().await;
|
||||
assert_eq!(rx_b.await.unwrap(), ApprovalDecision::Expired);
|
||||
}
|
||||
}
|
||||
|
||||
+193
-78
@@ -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()
|
||||
}
|
||||
|
||||
@@ -6,4 +6,5 @@ pub mod approval;
|
||||
pub mod executor;
|
||||
pub mod parser;
|
||||
pub mod registry;
|
||||
pub mod sandbox;
|
||||
pub mod spec;
|
||||
|
||||
+26
-17
@@ -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!(
|
||||
|
||||
@@ -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` 包裹:超时 SIGTERM,5 秒后 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>:超时 SIGTERM,5s 后 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()))
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -15,3 +15,4 @@ params:
|
||||
- name: city
|
||||
required: false
|
||||
desc: 城市名称,可选。限定地理编码的城市范围
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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条
|
||||
|
||||
|
||||
Reference in New Issue
Block a user