/** * Confined expression + template engine for the workflow runner. * * WHY ITS OWN MODULE: `{{ ... }}` strings ride inside workflows — including workflows a * user IMPORTED from someone else. Evaluating them is the one place the runner touches * attacker-influenced text, so the eval surface is deliberately quarantined to this file * and kept auditable. THE HARD RULE: no `eval`, no `new Function`, no `with`, no dynamic * property CALL — this is a hand-written recursive-descent interpreter over a whitelisted * grammar. An expression can only READ data already in the run context and run a fixed set * of pure `| filters`; it can never reach a function, a global, or the prototype chain. * * Grammar (Jinja-ish subset, precedence low→high): * or → and → not → comparison(== != >= <= > < , in, not in) * → additive(+ -) → multiplicative(* / %) → unary(-) → postfix(| filter) → primary * primary: literal | ( expr ) | path path: ident (.ident | [expr])* * literal: 'str' | "str" | number | true | false | null | none * * Everything is fail-closed and bounded: over-long input, too many nodes, or too-deep * nesting is rejected (a malformed condition routes as `false`, a malformed template throws * a `TemplateError` the runner surfaces). Path lookups resolve OWN enumerable properties * only and refuse `__proto__` / `constructor` / `prototype`. */ // ── Guards (DoS backstops for hostile input) ───────────────────────────────────── const MAX_INPUT = 4_000; // chars per expression const MAX_NODES = 512; // AST nodes per expression const MAX_DEPTH = 64; // parser recursion depth const BLOCKED_KEYS = new Set(["__proto__", "constructor", "prototype"]); export class TemplateError extends Error { constructor(message: string) { super(message); this.name = "TemplateError"; } } // ── Tokenizer ──────────────────────────────────────────────────────────────────── type Tok = | { t: "num"; v: number } | { t: "str"; v: string } | { t: "id"; v: string } | { t: "op"; v: string } | { t: "punc"; v: string }; const OPS3: string[] = []; const OPS2 = ["==", "!=", ">=", "<="]; const OPS1 = ["+", "-", "*", "/", "%", ">", "<"]; const PUNC = ["(", ")", "[", "]", ".", ",", "|"]; function tokenize(src: string): Tok[] { const toks: Tok[] = []; let i = 0; const n = src.length; while (i < n) { const c = src[i]; if (c === " " || c === "\t" || c === "\n" || c === "\r") { i++; continue; } // String literal with simple \\ and \' \" escapes. if (c === "'" || c === '"') { const quote = c; let out = ""; i++; while (i < n && src[i] !== quote) { if (src[i] === "\\" && i + 1 < n) { const nx = src[i + 1]; out += nx === "n" ? "\n" : nx === "t" ? "\t" : nx; i += 2; } else { out += src[i++]; } } if (i >= n) throw new TemplateError("unterminated string literal"); i++; // closing quote toks.push({ t: "str", v: out }); continue; } // Number (integer or decimal). if (c >= "0" && c <= "9") { let j = i + 1; while (j < n && ((src[j] >= "0" && src[j] <= "9") || src[j] === ".")) j++; const raw = src.slice(i, j); if (raw.split(".").length > 2) throw new TemplateError(`bad number "${raw}"`); toks.push({ t: "num", v: Number(raw) }); i = j; continue; } // Identifier / keyword. if (/[A-Za-z_]/.test(c)) { let j = i + 1; while (j < n && /[A-Za-z0-9_]/.test(src[j])) j++; toks.push({ t: "id", v: src.slice(i, j) }); i = j; continue; } // Multi/single-char operators. const two = src.slice(i, i + 2); if (OPS2.includes(two)) { toks.push({ t: "op", v: two }); i += 2; continue; } if (OPS1.includes(c)) { toks.push({ t: "op", v: c }); i++; continue; } if (c === "!") { // Only valid as part of `!=` (handled above) — a bare `!` means logical not. toks.push({ t: "op", v: "!" }); i++; continue; } if (PUNC.includes(c)) { toks.push({ t: "punc", v: c }); i++; continue; } throw new TemplateError(`unexpected character "${c}"`); } return toks; } // ── Parser (→ AST) ─────────────────────────────────────────────────────────────── type Node = | { k: "lit"; v: unknown } | { k: "path"; base: string; steps: Array<{ dot: string } | { idx: Node }> } | { k: "unary"; op: string; e: Node } | { k: "bin"; op: string; a: Node; b: Node } | { k: "logic"; op: "and" | "or"; a: Node; b: Node } | { k: "in"; neg: boolean; a: Node; b: Node } | { k: "filter"; name: string; e: Node; args: Node[] }; class Parser { private p = 0; private nodes = 0; private depth = 0; constructor(private toks: Tok[]) {} private peek(): Tok | undefined { return this.toks[this.p]; } private next(): Tok | undefined { return this.toks[this.p++]; } private isId(v: string): boolean { const t = this.peek(); return !!t && t.t === "id" && t.v === v; } private isOp(v: string): boolean { const t = this.peek(); return !!t && t.t === "op" && t.v === v; } private isPunc(v: string): boolean { const t = this.peek(); return !!t && t.t === "punc" && t.v === v; } private mk(node: T): T { if (++this.nodes > MAX_NODES) throw new TemplateError("expression too large"); return node; } private enter() { if (++this.depth > MAX_DEPTH) throw new TemplateError("expression too deep"); } private leave() { this.depth--; } parse(): Node { const e = this.parseOr(); if (this.p < this.toks.length) throw new TemplateError("trailing tokens in expression"); return e; } private parseOr(): Node { this.enter(); let a = this.parseAnd(); while (this.isId("or")) { this.next(); a = this.mk({ k: "logic", op: "or", a, b: this.parseAnd() }); } this.leave(); return a; } private parseAnd(): Node { let a = this.parseNot(); while (this.isId("and")) { this.next(); a = this.mk({ k: "logic", op: "and", a, b: this.parseNot() }); } return a; } private parseNot(): Node { if (this.isId("not") || this.isOp("!")) { this.next(); return this.mk({ k: "unary", op: "not", e: this.parseNot() }); } return this.parseComparison(); } private parseComparison(): Node { let a = this.parseAdd(); // `in` / `not in` if (this.isId("in")) { this.next(); return this.mk({ k: "in", neg: false, a, b: this.parseAdd() }); } if (this.isId("not")) { // lookahead for "not in" const save = this.p; this.next(); if (this.isId("in")) { this.next(); return this.mk({ k: "in", neg: true, a, b: this.parseAdd() }); } this.p = save; // not part of a comparison; let a stand return a; } const t = this.peek(); if (t && t.t === "op" && ["==", "!=", ">=", "<=", ">", "<"].includes(t.v)) { this.next(); a = this.mk({ k: "bin", op: t.v, a, b: this.parseAdd() }); } return a; } private parseAdd(): Node { let a = this.parseMul(); while (this.isOp("+") || this.isOp("-")) { const op = this.next()!.v as string; a = this.mk({ k: "bin", op, a, b: this.parseMul() }); } return a; } private parseMul(): Node { let a = this.parseUnary(); while (this.isOp("*") || this.isOp("/") || this.isOp("%")) { const op = this.next()!.v as string; a = this.mk({ k: "bin", op, a, b: this.parseUnary() }); } return a; } private parseUnary(): Node { if (this.isOp("-")) { this.next(); return this.mk({ k: "unary", op: "neg", e: this.parseUnary() }); } return this.parsePostfix(); } private parsePostfix(): Node { let e = this.parsePrimary(); // Jinja pipe filters: `expr | name` or `expr | name(a, b)`. while (this.isPunc("|")) { this.next(); const nameTok = this.next(); if (!nameTok || nameTok.t !== "id") throw new TemplateError("expected filter name after |"); const args: Node[] = []; if (this.isPunc("(")) { this.next(); if (!this.isPunc(")")) { args.push(this.parseOr()); while (this.isPunc(",")) { this.next(); args.push(this.parseOr()); } } if (!this.isPunc(")")) throw new TemplateError("expected ) after filter args"); this.next(); } e = this.mk({ k: "filter", name: nameTok.v, e, args }); } return e; } private parsePrimary(): Node { this.enter(); const t = this.peek(); if (!t) throw new TemplateError("unexpected end of expression"); if (t.t === "punc" && t.v === "(") { this.next(); const e = this.parseOr(); if (!this.isPunc(")")) throw new TemplateError("expected )"); this.next(); this.leave(); return e; } if (t.t === "num") { this.next(); this.leave(); return this.mk({ k: "lit", v: t.v }); } if (t.t === "str") { this.next(); this.leave(); return this.mk({ k: "lit", v: t.v }); } if (t.t === "id") { if (t.v === "true") { this.next(); this.leave(); return this.mk({ k: "lit", v: true }); } if (t.v === "false") { this.next(); this.leave(); return this.mk({ k: "lit", v: false }); } if (t.v === "null" || t.v === "none") { this.next(); this.leave(); return this.mk({ k: "lit", v: null }); } // A path: base ident then .ident / [expr] accessors. this.next(); const steps: Array<{ dot: string } | { idx: Node }> = []; // eslint-disable-next-line no-constant-condition while (true) { if (this.isPunc(".")) { this.next(); const idt = this.next(); if (!idt || idt.t !== "id") throw new TemplateError("expected property name after ."); steps.push({ dot: idt.v }); } else if (this.isPunc("[")) { this.next(); const idx = this.parseOr(); if (!this.isPunc("]")) throw new TemplateError("expected ]"); this.next(); steps.push({ idx }); } else break; } this.leave(); return this.mk({ k: "path", base: t.v, steps }); } throw new TemplateError(`unexpected token "${(t as any).v}"`); } } // ── Evaluator ──────────────────────────────────────────────────────────────────── // Read a single property WITHOUT crossing the prototype chain or the blocked keys — the // core sandbox invariant. `constructor`/`__proto__`/`prototype` and any inherited member // resolve to undefined, so an expression can never climb out of the plain data graph. function readProp(obj: unknown, key: string | number): unknown { if (obj == null) return undefined; if (typeof key === "string" && BLOCKED_KEYS.has(key)) return undefined; if (Array.isArray(obj)) { if (typeof key === "number") return obj[key]; if (key === "length") return obj.length; const asNum = Number(key); return Number.isInteger(asNum) ? obj[asNum] : undefined; } if (typeof obj === "string") { if (key === "length") return obj.length; const asNum = Number(key); return Number.isInteger(asNum) ? obj[asNum] : undefined; } if (typeof obj === "object") { return Object.prototype.hasOwnProperty.call(obj, key) ? (obj as Record)[key as string] : undefined; } return undefined; } const FILTERS: Record unknown> = { default: (v, a) => (v === undefined || v === null || v === "" ? a[0] : v), length: (v) => (Array.isArray(v) || typeof v === "string" ? (v as { length: number }).length : v && typeof v === "object" ? Object.keys(v).length : 0), upper: (v) => String(v ?? "").toUpperCase(), lower: (v) => String(v ?? "").toLowerCase(), trim: (v) => String(v ?? "").trim(), json: (v) => JSON.stringify(v ?? null), join: (v, a) => (Array.isArray(v) ? v.map((x) => String(x)).join(a[0] === undefined ? "," : String(a[0])) : String(v ?? "")), first: (v) => (Array.isArray(v) ? v[0] : typeof v === "string" ? v[0] : undefined), last: (v) => (Array.isArray(v) ? v[v.length - 1] : typeof v === "string" ? v[v.length - 1] : undefined), int: (v) => { const n = parseInt(String(v), 10); return Number.isNaN(n) ? 0 : n; }, float: (v) => { const n = parseFloat(String(v)); return Number.isNaN(n) ? 0 : n; }, string: (v) => String(v ?? ""), abs: (v) => Math.abs(Number(v)), round: (v, a) => { const d = a[0] === undefined ? 0 : Number(a[0]); const f = 10 ** d; return Math.round(Number(v) * f) / f; }, keys: (v) => (v && typeof v === "object" && !Array.isArray(v) ? Object.keys(v) : []), values: (v) => (v && typeof v === "object" && !Array.isArray(v) ? Object.values(v as Record) : []), }; function truthy(v: unknown): boolean { if (Array.isArray(v)) return v.length > 0; if (v && typeof v === "object") return Object.keys(v).length > 0; return Boolean(v); } function looseEq(a: unknown, b: unknown): boolean { if (a === b) return true; // Numeric coercion so `{{ score }} == 8` matches whether score is 8 or "8". if ((typeof a === "number" || typeof b === "number") && a != null && b != null && a !== "" && b !== "") { const na = Number(a), nb = Number(b); if (!Number.isNaN(na) && !Number.isNaN(nb)) return na === nb; } return false; } function evalNode(node: Node, ctx: unknown): unknown { switch (node.k) { case "lit": return node.v; case "path": { let cur = readProp(ctx, node.base); for (const s of node.steps) { if ("dot" in s) cur = readProp(cur, s.dot); else { const idx = evalNode(s.idx, ctx); cur = readProp(cur, typeof idx === "number" ? idx : String(idx)); } if (cur === undefined) break; } return cur; } case "unary": return node.op === "not" ? !truthy(evalNode(node.e, ctx)) : -Number(evalNode(node.e, ctx)); case "logic": { const a = evalNode(node.a, ctx); if (node.op === "and") return truthy(a) ? evalNode(node.b, ctx) : a; return truthy(a) ? a : evalNode(node.b, ctx); } case "in": { const a = evalNode(node.a, ctx); const b = evalNode(node.b, ctx); let has = false; if (Array.isArray(b)) has = b.some((x) => looseEq(x, a)); else if (typeof b === "string") has = b.includes(String(a)); else if (b && typeof b === "object") has = Object.prototype.hasOwnProperty.call(b, String(a)); return node.neg ? !has : has; } case "filter": { const fn = FILTERS[node.name]; if (!fn) throw new TemplateError(`unknown filter "${node.name}"`); return fn(evalNode(node.e, ctx), node.args.map((a) => evalNode(a, ctx))); } case "bin": { const a = evalNode(node.a, ctx); const b = evalNode(node.b, ctx); switch (node.op) { case "==": return looseEq(a, b); case "!=": return !looseEq(a, b); case ">=": return Number(a) >= Number(b); case "<=": return Number(a) <= Number(b); case ">": return Number(a) > Number(b); case "<": return Number(a) < Number(b); case "+": return typeof a === "string" || typeof b === "string" ? `${stringify(a)}${stringify(b)}` : Number(a) + Number(b); case "-": return Number(a) - Number(b); case "*": return Number(a) * Number(b); case "/": return Number(a) / Number(b); case "%": return Number(a) % Number(b); default: throw new TemplateError(`unknown operator "${node.op}"`); } } } } const parseCache = new Map(); function parseExpr(src: string): Node { if (src.length > MAX_INPUT) throw new TemplateError("expression too long"); const cached = parseCache.get(src); if (cached) return cached; const ast = new Parser(tokenize(src)).parse(); if (parseCache.size > 500) parseCache.clear(); // bounded memo parseCache.set(src, ast); return ast; } // ── Public API ─────────────────────────────────────────────────────────────────── /** Evaluate a bare expression (no `{{ }}`) to its raw value. Throws `TemplateError` on a * malformed expression. Used internally + for `for_each` source resolution. */ export function evaluate(expr: string, ctx: unknown): unknown { return evalNode(parseExpr(expr), ctx); } /** Evaluate a route `when` (with or without surrounding `{{ }}`) to a boolean via * truthiness. FAIL-CLOSED: a malformed condition returns `false` (the route isn't taken) * rather than throwing — a bad `when` must never silently pick a branch. */ export function evalCondition(cond: string, ctx: unknown): boolean { try { const inner = cond.trim().replace(/^\{\{\s*|\s*\}\}$/g, "").trim(); if (!inner) return false; return truthy(evaluate(inner, ctx)); } catch { return false; } } function stringify(v: unknown): string { if (v === undefined || v === null) return ""; if (typeof v === "string") return v; if (typeof v === "number" || typeof v === "boolean") return String(v); return JSON.stringify(v); } /** Render a template: replace each `{{ expr }}` with its evaluated, stringified value. * Arrays/objects stringify as JSON; null/undefined become "". Throws `TemplateError` if a * substitution is malformed (the runner surfaces it — a broken prompt should fail loudly, * unlike a route condition which fails closed). */ export function renderTemplate(tpl: string, ctx: unknown): string { if (tpl.length > MAX_INPUT * 8) throw new TemplateError("template too long"); return tpl.replace(/\{\{\s*([\s\S]*?)\s*\}\}/g, (_m, expr) => stringify(evaluate(String(expr).trim(), ctx))); }