workflow-engine
n8nのCVEを構造的に持ち得ないワークフローエンジンをlodosで作った (1/5)
n8nは式evalを経由してCVSS 9.9のRCE脆弱性を出荷してしまった。lodosのワークフローエンジンにはこのバグが構造的に存在し得ない、 インジェクションの対象となるevaluatorがそもそもどこにも存在せず、ビルド時のgrepがその状態を維持し続けているからだ。
n8nは先月、CVE-2025-68613を出した、CVSS 9.9、式evalを介したRCE。だが、面白いのはそこではない。
面白いのは、この脆弱性クラス全体が、私がlodos向けに設計したワークフローエンジンには構造的に存在し得ないという点だ。サンドボックスが優れているからでも、パッチを速く当てたからでもない。パイプラインのどこにも、インジェクションの対象となる式evaluatorが存在しないからだ。ビルドスクリプトがそれをgrepで検出する。将来の自分がワークフロー周りのコードにevalやnew Function、vm.runInContextを追加したら、コミットが着地する前にビルドが赤くなる。
この投稿はそのアーキテクチャの解説だ。YAMLスキーマのレベルで「宣言的に境界づけられている(declarative-bounded)」とは実際に何を意味するのか、なぜ私は意図的にチューリング完全性を捨てたのか、そしてその一線を守っている具体的なgrepとは何か。
もしあなたがワークフローツールを構築しているなら、あるいはユーザー提供の式を受け取って実行する何らかのシステムを作っているなら、このトレードオフは正直に検討する価値がある。n8nは別の選択をし、その結果CVSS 9.9のRCEを抱えることになった。私はこちらを選び、その結果、n8nより厳密に表現力の劣るワークフローエンジンを手に入れた。どちらのトレードオフを取るかは、自覚的に選んでほしい。
ここでの「宣言的」が実際に意味すること
lodosのワークフローエンジンが公開しているプリミティブは5つだけだ。それで全部だ。
http_request:メソッド、URLテンプレート、ヘッダー、ボディ、allowedEgressホストリスト、timeoutMsdb_query:ローカルSQLiteに対するSELECT専用。vault/課金テーブルは静的なdenylistで除外ai_call:api.anthropic.comへのegressをハードコード、noSecrets: true(送信前にトランスクリプトを除去)tool_call:read-only-autoなMCPツール。guardでゲート済み、optional-degradeに対応- 制御フロー:
wait、if_else、loop。比較子はクローズドな列挙型(eq、neq、gt、lt、changed-since-last-run)のみ
最後の一行こそ、私が擁護したいポイントだ。私がこれまで見てきたほとんどのワークフローエンジン、n8n、Zapier、Temporal、は、条件分岐のスロットにJSの式をそのまま書かせてくれる。この数値が5より大きければ、左に分岐する。 式evaluatorはユーザーにとっては便利だが、セキュリティチームにとっては崖っぷちだ。だから私たちはそれを持たない。比較子は列挙型であり、比較対象はデータであり、ユーザーが編集したYAMLのフィールドから関数呼び出しへ至る経路はどこにも存在しない。
失うものはある。if step.result.users.filter(u => u.active).length > 5のようには書けない。書けるのはif step.result.activeUserCount gt 5であり、activeUserCountは上流のdb_queryやai_callステップで生成しておく必要がある。計算は既存のプリミティブ側に押し出され、ワークフロー定義そのものは宣言的なままに保たれる。

スキーマの規律
ワークフローYAMLは、再帰(loopとif_elseはネスト可能)のためにz.lazyを使ったZodスキーマを通してパースされる。境界のないz.lazyはevalの別名にすぎない、悪意あるYAMLは、どのハンドラーが動く前にもスタックやヒープを吹き飛ばせてしまう。そのためこのスキーマは二重に境界づけられている。
const StepSchema: z.ZodType<Step> = z.lazy(() =>
z.discriminatedUnion('type', [
HttpRequestStepSchema,
DbQueryStepSchema,
AiCallStepSchema,
ToolCallStepSchema,
WaitStepSchema,
IfElseStepSchema, // contains: steps[] (z.lazy, max 16)
LoopStepSchema, // contains: body[] (z.lazy, max 16)
])
);
// Walk-time enforced in addition to per-array caps:
const MAX_TOTAL_NODES = 100;
const MAX_DEPTH = 5;

3つの境界があり、そのどれもが必要不可欠だ。per-array .max(16)は、単一のブロックがネストした分岐だけでメガバイト級に膨れ上がるのを防ぐ。total-nodes 100はグラフ全体を境界づける(配列単位の上限を薄く広く分散させることで複雑さを密輸することはできない)。depth 5は再帰的な走査を入力サイズに対して定数時間に保つ。この3つのどれも過剰な用心ではない。それぞれが、まともなワークフローユーザーなら決して書かないが悪意あるYAMLなら書き得るリソース攻撃のクラスを一つずつ閉じている。
なぜsecret_value_getは存在しないのか
このシステムには、契約が「秘密情報の平文をよこせ」であるようなMCPツールは一つも存在しない。存在するのは6層構造のsecret_inject_and_runだけで、これは秘密情報への参照とargvを受け取り、その値をサブプロセスの環境変数にセットし、コマンドを実行し、その値を呼び出し元のAIには決して返さない。それで全部だ。
これはポリシー(「そういうツールを追加しないでください」)ではない。ビルドスクリプトが、その不在をアサートしている。
// scripts/verify-moat-invariants.mjs - INV-M1 (simplified)
const FORBIDDEN_TOOL_NAMES = [
/\bbash\b/,
/\bexec\b/,
/\bshell\b/,
/_value_get$/, // catches secret_value_get, dek_value_get, anything _value_get
/^secret_value_/,
];
const mcpIndexSrc = readFileSync('apps/mcp/src/index.ts', 'utf8');
for (const pattern of FORBIDDEN_TOOL_NAMES) {
if (pattern.test(mcpIndexSrc)) {
console.error(`INV-M1 VIOLATION: forbidden tool pattern ${pattern}`);
process.exit(1);
}
}
もし将来の私が、あるいはこのコードベースを触る将来のAIエディタが、値を返す秘密情報ツールが必要だと自分を納得させてしまったとしても、ビルドはマージの前にそれを拒絶する。
INV-M5:evalのトリップワイヤー
ワークフローエンジンに対応するチェックは、さらにシンプルだ。
// INV-M5: no eval-class primitive in workflow / skill / deck surfaces
const FORBIDDEN_EXEC_PATTERNS = [
/\beval\s*\(/,
/\bnew\s+Function\s*\(/,
/\bvm\.(runIn|compileFunction)/,
/\brequire\(['"]child_process['"]\)/,
/\bimport\s+.*\bfrom\s+['"]child_process['"]/,
];
for (const dir of ['electron/workflow/', 'electron/skills/', 'electron/deck/']) {
for (const file of walkTs(dir)) {
const src = readFileSync(file, 'utf8');
for (const pattern of FORBIDDEN_EXEC_PATTERNS) {
if (pattern.test(src)) {
console.error(`INV-M5 VIOLATION in ${file}: ${pattern}`);
process.exit(1);
}
}
}
}
50行のNode、依存ゼロ、標準のpnpm verifyステップの中で実行される。n8nのCVSS 9.9の式RCEが属していたCVEのクラス全体が、たった一つのgrepで閉じられている。
これを本物にしているのは**否定的証明(negative proof)**という規律だ。このスクリプトは、それ自体の偽の違反を同梱して出荷される。文字通りeval(を含む使い捨ての__probe.tsファイルだ。CIはこのスクリプトを2回走らせる、まずプローブを注入した状態で(終了コード1でなければならない)、次にそれを取り除いた状態で(終了コード0でなければならない)。一度も失敗しない拒絶検知器は、検知器が存在しないのと見分けがつかない。この否定的証明こそが、このスクリプトに実効力を与えている。
機能拡張は「緩和」ではなく「狭窄」によって行う
懐疑的な読者からの問い:機能をもっと増やす必要が出てきたら、この設計は破綻するのではないか?
L2のweb egress用にweb_fetchを出荷したとき、私はevaluatorを追加しなかった。追加したのは、狭いホストのallowlist・ソースごとのバイト予算・リダイレクト時の再認証・自分自身へのSSRF対策・レンダリングファイアウォール・汚染コンテンツの伝播、これらだ。新規テストケースは26件、新規evalサーフェスはゼロ。このエンジンは「式の実行力」を足すことによってではなく、「境界」を足すことによってより強力になった。
これは実現可能だ。ワークフローツールにおける機能拡張の多くは「ユーザーにもっとevalサーフェスを与える」と解釈されがちだ。だがそれは代わりに「ユーザーにもっと境界づけられたプリミティブを与える」と解釈することもできる。後者の方が設計は難しいが、CVSS 9.9へと育っていくことは不可能だ。
何を犠牲にし、なぜそれを払ったのか
犠牲にしたもの:任意のステップ内計算、JS式による条件分岐、動的なフィールド射影、n8nの{{ $json.foo.map(x => x * 2) }}でユーザーができることは何でも。実際のワークフローユーザーはこうした機能に手を伸ばすものだし、lodosでも彼らは手を伸ばす。ただしその手段は、もう一つai_callやdb_queryステップを書き足すことだ。ワークフロー定義は宣言的なままであり、evalサーフェスは空のままだ。
手に入れたもの:CVE-2025-68613が構造的に不可能であり、スキーマが境界づけられたリソース消費を強制し、あらゆる拒絶がdocstringではなくgrepによって強制されるワークフローエンジンだ。
このシリーズの次の投稿は、さらに一段深いところ、vaultへと進む。"AI sk_live'ı göremez"(AIはあなたのStripeライブキーを見ることができない)、というのがアーキテクチャ上の主張であり、それを裏付けるスキーマの断片こそ、私が最も誇りに思っている部分だ。Zero-Knowledge as Architectural Blindnessでは、私たちのPrismaスキーマがなぜ文字通り平文のフィールドパスを保持し得ないのか、そしてそれがどのようにしてSOC2グレードの監査ストーリーをタダで手に入れさせてくれるのかを説明する。