Hey, I'm Samet Samyeli. I have been actively developing software for 12 years. For the past six months, I have been building "lodos.md" a "Founder OS" featuring bank-grade security where AI never sees your secrets (eliminating leakage risks) and utilizing smart workflows that integrate the "Karpathy LLM Wiki" directly into the engine's core to reduce hallucinations to near-zero levels; it employs a continuous memory system based on Markdown files rather than sessions. I am sharing the first article of our five-part series with you.
n8n had CVE-2025-68613 last month — CVSS 9.9, RCE via expression eval. That's not the interesting part.
The interesting part is that the entire class of vulnerability is structurally absent from the workflow engine I designed for lodos — not because we sandbox better, not because we patched faster, but because there is no expression evaluator anywhere in the pipeline to inject into. The build script greps for it. If a future me adds eval or new Function or vm.runInContext to the workflow surface, the build turns red before the commit lands.
This post is the architecture walkthrough: what "declarative-bounded" actually means at the level of the YAML schema, why I chose to lose Turing-completeness on purpose, and the specific grep that holds the line.
If you build workflow tooling — or any system that takes user-provided expressions and runs them — the trade is worth examining honestly. n8n made a different one, and they had a CVSS 9.9 RCE for it. I made this one, and I have a workflow engine that's strictly less expressive than theirs. Pick your tradeoff knowingly.
What "declarative" actually means here
The lodos workflow engine exposes five primitives. That's it:
-
http_request— method, URL template, headers, body,allowedEgresshost list,timeoutMs -
db_query— SELECT-only against the local SQLite, with a static denylist for vault/billing tables -
ai_call— hardcodedapi.anthropic.comegress,noSecrets: true(transcript stripped before send) -
tool_call— read-only-auto MCP tools, guard-gated, supports optional-degrade - Control flow —
wait,if_else,loopwith closed-enum comparators (eq,neq,gt,lt,changed-since-last-run)
That last line is the one I want to defend. Most workflow engines I've seen — n8n, Zapier, Temporal even — let you write a JS expression in the condition slot. If this number is greater than 5, branch left. The expression evaluator is convenient for users and a cliff for security teams. So we don't have one. The comparator is an enum; the comparison is data; there is no path from a user-edited YAML field to a function call.
You lose things. You can't write if step.result.users.filter(u => u.active).length > 5. You can write if step.result.activeUserCount gt 5 and produce activeUserCount upstream in a db_query or ai_call. Computation moves to the primitives that already exist; the workflow definition stays declarative.
The schema discipline
The workflow YAML parses through a Zod schema with z.lazy for recursion (loops and if_else can nest). Unbounded z.lazy is a different name for eval — a malicious YAML can blow the stack or the heap before any handler runs. So the schema is double-bounded:
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;
Three bounds, every one of them necessary. per-array .max(16) keeps any single block from being a megabyte of nested branches. total-nodes 100 bounds the whole graph (you can't smuggle complexity past the per-array cap by spreading thin). depth 5 keeps the recursive walk constant-time relative to the input. None of the three is paranoia; each closes a class of resource attack that a real workflow user would never write but a hostile YAML would.
Why secret_value_get doesn't exist
There is no MCP tool in the system whose contract is give me the plaintext of a secret. There's a six-layer secret_inject_and_run that takes a secret reference and an argv, sets the value in a subprocess env, runs the command, and never returns the value to the calling AI. That's it.
This isn't a policy ("don't add such a tool"). The build script asserts the absence:
// 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);
}
}
If some future me — or a future AI editor working on this codebase — convinces themselves they need a value-returning secret tool, the build refuses before the merge.
INV-M5 — the eval tripwire
The corresponding check for the workflow engine is even simpler:
// 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);
}
}
}
}
Fifty lines of Node, zero dependencies, runs in the standard pnpm verify step. The whole class of CVE that n8n's CVSS-9.9 expression-RCE lives in is closed by a grep.
The discipline that makes this real is the negative proof: the script ships with its own fake violation. A throwaway __probe.ts file with a literal eval( in it. CI runs the script twice — first with the probe injected (must exit 1), then with it removed (must exit 0). A refusal-detector that never fails is indistinguishable from no detector at all; the negative proof is what gives this script teeth.
Feature growth through narrowing, not relaxation
The skeptical reader's question: doesn't this break when you need more features?
When I shipped web_fetch for L2 web egress, I didn't add an evaluator. I added: narrow-host allowlist + per-source byte budget + redirect-reauth + SSRF-against-self + render-firewall + tainted-content propagation. Twenty-six new test cases, zero new eval surface. The engine got more powerful by adding bounds, not by adding expression power.
You can do this. Most growth in workflow tools is interpreted as "give the user more eval surface." It can be interpreted instead as "give the user more bounded primitives." The latter is harder to design and impossible to grow into a CVSS 9.9.
What this costs and why I paid it
You give up: arbitrary in-step computation, JS expression conditions, dynamic field projection, whatever the user can do in n8n's {{ $json.foo.map(x => x * 2) }}. Real workflow users do reach for these, and when they do in lodos they reach for them by writing one more ai_call or db_query step. The workflow definition stays declarative; the eval surface stays empty.
You get: a workflow engine where CVE-2025-68613 is structurally impossible, where the schema enforces bounded resource consumption, and where every refusal is enforced by a grep, not a docstring.
The next post in the series goes one layer down into the vault. “AI never see sk_live” the AI cannot see your Stripe live key is the architectural claim, and the schema fragment that backs it up is the one I’m most proud of. Zero-Knowledge as Architectural Blindness explains why our Prisma schema literally cannot hold a plaintext field path, and how that buys us the SOC2-grade audit story for free.
Join waitlist: https://lodos.md/


Top comments (0)