From d8d2704e78936053ea4ce89d2fd77a16f889c4a1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 11:01:14 +0100 Subject: [PATCH] feat(evaluator): cap total node visits and recursion depth per evaluation call A single self-contained tree with no treeReference nodes had no bound on ordinary recursive descent, so a sufficiently deep and/or/fold/ conditional/quantifier nesting could exhaust the call stack rather than resolve to an Evaluation. Add a per-call EvaluationBudget, threaded through every recursive predicate/expression call site, that counts total nodes visited and current nesting depth and returns indeterminate with a resource-exhausted domain-error once either exceeds its cap. Both caps are configurable via createEvaluator's new maxNodes and maxNestingDepth options, defaulting to 10,000 nodes and 500 levels so the bare evaluatePredicate/evaluateValue exports stay safe without any caller opting in. The nesting-depth cap is independent of MAX_TREE_REFERENCE_DEPTH, which only advances on an actual treeReference hop and never bounded ordinary node recursion. --- packages/trilean/src/evaluator.test.ts | 144 +++++++++++++++++++++++++ packages/trilean/src/evaluator.ts | 134 ++++++++++++++++++++++- 2 files changed, 277 insertions(+), 1 deletion(-) diff --git a/packages/trilean/src/evaluator.test.ts b/packages/trilean/src/evaluator.test.ts index ca65ed5..a66558b 100644 --- a/packages/trilean/src/evaluator.test.ts +++ b/packages/trilean/src/evaluator.test.ts @@ -3160,6 +3160,150 @@ describe("call", () => { }); }); +describe("evaluation resource limits", () => { + /** A `depth`-deep chain of `not` wrapping a single leaf `exists` node, built iteratively (never recursively) so constructing the fixture itself never taxes the test runner's own call stack. No `treeReference` node appears anywhere in this tree, so `MAX_TREE_REFERENCE_DEPTH`'s chain-depth counter never advances while evaluating it -- this is deliberately the shape that guard cannot see, since it only checks depth across `treeReference` hops between separately-stored trees, never ordinary recursive descent within one self-contained tree. */ + function deeplyNestedNot(depth: number): PredicateNode { + let node: PredicateNode = { + kind: "exists", + operand: { kind: "numberLiteral", value: 1 }, + }; + for (let level = 0; level < depth; level += 1) { + node = { kind: "not", operand: node }; + } + return node; + } + + /** A wide-but-shallow `allOf` of `count` trivial `exists` leaves -- every leaf contributes to the total node-visit count while adding only two levels of nesting depth regardless of `count`, isolating the node-count cap from the nesting-depth cap below. */ + function wideAllOf(count: number): PredicateNode { + return { + kind: "allOf", + operands: Array.from({ length: count }, () => ({ + kind: "exists" as const, + operand: { kind: "numberLiteral" as const, value: 1 }, + })), + }; + } + + /** The explicit `maxNodes` configured for the node-count-cap tests below, chosen only to be comfortably smaller than `wideOperandCountExceedingNodeCap`'s resulting node count -- its exact value carries no other significance. */ + const explicitNodeCap = 50; + + /** The explicit `maxNestingDepth` configured for the nesting-depth-cap tests below, chosen only to be comfortably smaller than `notDepthExceedingNestingCap` -- its exact value carries no other significance. */ + const explicitNestingDepthCap = 10; + + /** `allOf` operand count for the node-count-cap test: the allOf node itself plus this many two-node (`exists` + `numberLiteral`) operands comfortably exceeds `explicitNodeCap`, while the tree stays only two levels deep -- nowhere near any nesting-depth cap, isolating that assertion to the node-count cap alone. */ + const wideOperandCountExceedingNodeCap = 60; + + /** `not`-chain depth for the nesting-depth-cap test: comfortably exceeds `explicitNestingDepthCap`, while the resulting total node count stays nowhere near any default or explicit node-count cap, isolating that assertion to the nesting-depth cap alone. */ + const notDepthExceedingNestingCap = 50; + + /** `not`-chain depth for the "well within both caps" test: an odd number of negations flips a definitely-true `exists` leaf to false, comfortably inside both `explicitNodeCap` and `explicitNestingDepthCap`. */ + const notDepthWithinBothCaps = 5; + + /** `not`-chain depth used to prove `createEvaluator({})`'s own defaults catch an oversized tree: comfortably past `DEFAULT_MAX_NESTING_DEPTH` while staying well short of the depth at which this shape has been observed to exhaust the real call stack (see the issue this addresses for how that was measured). */ + const notDepthExceedingDefaultNestingCap = 1000; + + it("a node-count cap configured via createEvaluator rejects a wide-but-shallow tree that exceeds it, as indeterminate", async () => { + const { evaluatePredicate: evaluate } = createEvaluator({ + maxNodes: explicitNodeCap, + }); + const result = await evaluate( + wideAllOf(wideOperandCountExceedingNodeCap), + undefined, + resolvers, + ); + expect(result.status).toBe("indeterminate"); + if (result.status === "indeterminate") { + expect(result.reason.code).toBe("domain-error"); + expect(result.reason.message).toMatch(/node/i); + } + }); + + it("a nesting-depth cap configured via createEvaluator rejects a narrow-but-deep tree that exceeds it, as indeterminate", async () => { + const { evaluatePredicate: evaluate } = createEvaluator({ + maxNestingDepth: explicitNestingDepthCap, + }); + const result = await evaluate( + deeplyNestedNot(notDepthExceedingNestingCap), + undefined, + resolvers, + ); + expect(result.status).toBe("indeterminate"); + if (result.status === "indeterminate") { + expect(result.reason.code).toBe("domain-error"); + expect(result.reason.message).toMatch(/depth/i); + } + }); + + it("a tree well within both caps evaluates normally and correctly", async () => { + const { evaluatePredicate: evaluate } = createEvaluator({ + maxNodes: explicitNodeCap, + maxNestingDepth: explicitNestingDepthCap, + }); + const result = await evaluate( + deeplyNestedNot(notDepthWithinBothCaps), + undefined, + resolvers, + ); + expectDefinite(result, false); + }); + + it("MAX_TREE_REFERENCE_DEPTH's own chain-depth guard is unaffected by the nesting-depth cap", async () => { + const treeResolvers: Resolvers = { + ...resolvers, + resolveTree: async (key) => + Promise.resolve({ + found: true, + node: { + kind: "treeReference", + key: `${typeof key === "string" ? key : "chain"}-next`, + }, + }), + }; + const result = await evaluatePredicate( + { kind: "treeReference", key: "chain-0" }, + undefined, + treeResolvers, + ); + expectIndeterminate(result, "domain-error"); + if (result.status === "indeterminate") { + expect(result.reason.message).toBe( + "treeReference chain exceeds the maximum depth of 100", + ); + } + }); + + it("createEvaluator({})'s defaults catch a real oversized tree without any explicit cap configuration", async () => { + // A correct default catches this deterministically as indeterminate; the pre-fix evaluator instead resolved it to a (wrong, but not crashing) definite value at this particular depth. + const result = await evaluatePredicate( + deeplyNestedNot(notDepthExceedingDefaultNestingCap), + undefined, + resolvers, + ); + expect(result.status).toBe("indeterminate"); + if (result.status === "indeterminate") { + expect(result.reason.code).toBe("domain-error"); + expect(result.reason.message).toMatch(/depth/i); + } + }); + + it("createEvaluator({})'s defaults catch a real oversized tree via evaluateValue too, not only evaluatePredicate", async () => { + let node: ExpressionNode = { kind: "numberLiteral", value: 1 }; + for ( + let level = 0; + level < notDepthExceedingDefaultNestingCap; + level += 1 + ) { + node = { kind: "negate", operand: node }; + } + const result = await evaluateValue(node, undefined, resolvers); + expect(result.status).toBe("indeterminate"); + if (result.status === "indeterminate") { + expect(result.reason.code).toBe("domain-error"); + expect(result.reason.message).toMatch(/depth/i); + } + }); +}); + /** README.md's own "Worked example" (ยง Worked example), verbatim: `isActive equals 1` AND `sum(items.amount) > x + y`, where the sum is exactly the `fold` tree the `sum` derived-aggregate builder assembles. Both variations from the README are included, exercising the propagation rules the worked example is there to demonstrate -- absorption via `and`'s definitely-false right operand, versus a missing reference surfacing all the way to the top because `true` is not absorbing for `and`. */ describe("golden example (README Worked example)", () => { const goldenExampleResolvers: Resolvers = { diff --git a/packages/trilean/src/evaluator.ts b/packages/trilean/src/evaluator.ts index 1157577..8d5ed3d 100644 --- a/packages/trilean/src/evaluator.ts +++ b/packages/trilean/src/evaluator.ts @@ -709,6 +709,50 @@ function applyArithmetic( /** A defense-in-depth guard against a long acyclic `treeReference` chain exhausting the call stack -- distinct from, and layered on top of, the cycle detector below (`visitedTreeKeys`), which catches an actual repeat immediately and more precisely. */ const MAX_TREE_REFERENCE_DEPTH = 100; +/** Default cap on the total number of predicate/expression nodes a single `evaluatePredicate`/`evaluateValue` call may visit -- see `createEvaluator`'s `maxNodes` option, threaded through to `createEvaluationBudget` below. Chosen generously above any realistic hand-authored business-rule, eligibility, or formula tree (see README.md's own consumer use cases), while still bounding an untrusted, adversarially-authored tree's total evaluation cost to a fixed, small amount of work regardless of how it is shaped. */ +const DEFAULT_MAX_EVALUATION_NODES = 10_000; + +/** Default cap on ordinary recursive-descent nesting depth -- see `createEvaluator`'s `maxNestingDepth` option, threaded through to `createEvaluationBudget` below. Set comfortably below the depth at which this evaluator's own recursive descent has been observed to exhaust the host call stack (a chain of plain `not` nodes wrapped around a single leaf, evaluated directly against this file under Node.js, failed consistently somewhere in the low thousands of nesting levels, with some run-to-run variance from whatever else already occupied the stack), while remaining far deeper than any legitimate hand-authored tree is ever likely to nest. Kept well clear of that measured failure point because the exact threshold varies by host runtime (a Cloudflare Workers isolate's own stack is smaller than Node's) and by how much of the stack the rest of the call chain has already used. */ +const DEFAULT_MAX_NESTING_DEPTH = 500; + +/** + * A single `evaluatePredicate`/`evaluateValue` call's resource limits, independent of and layered underneath `MAX_TREE_REFERENCE_DEPTH`'s own cross-tree chain guard above: that guard only advances at an actual `treeReference` resolution and says nothing about a plain, self-contained tree built from ordinary `and`/`or`/`fold`/`conditional`/quantifier nesting, with no `treeReference` node anywhere in it. This closes that gap for a consumer evaluating a tree it did not author and cannot fully trust before evaluation (e.g. a tree embedded in a signed but otherwise attacker-controlled payload). + * + * Exposed as a single `checkNode` method rather than a raw mutable counter, following the same all-callback shape `Resolvers` and `FunctionRegistry` already use elsewhere in this file: every recursive call site threads this object through as `Readonly`, and that wrapper genuinely prevents tampering because the running node count lives in `createEvaluationBudget`'s own closure, never as an assignable property on the object itself. + */ +interface EvaluationBudget { + /** Charges one node visit and checks both caps, returning the `Evaluation` to return immediately if either is now exceeded, or `undefined` if evaluation of this node may proceed. Called as the very first action inside `evaluatePredicateInternal`/`evaluateValueInternal`, before any of that node's own work or further recursion, so an exceeded budget is discovered before it can be spent on additional descent. */ + checkNode: (nestingDepth: number) => Evaluation | undefined; +} + +/** Constructs a fresh `EvaluationBudget` for one top-level `evaluatePredicate`/`evaluateValue` call -- see `createEvaluator`'s `maxNodes`/`maxNestingDepth` options, which supply `maxNodes`/`maxNestingDepth` here. A fresh closure per call is what keeps `nodesVisited` from leaking between unrelated evaluations. */ +function createEvaluationBudget( + maxNodes: number, + maxNestingDepth: number, +): EvaluationBudget { + /** Total predicate/expression nodes visited so far across the whole call -- shared by every branch of an `and`/`or`/`allOf`/`anyOf`/fold/quantifier through the closure below, so it accumulates across the whole traversal rather than resetting per branch. */ + let nodesVisited = 0; + return { + checkNode(nestingDepth) { + nodesVisited += 1; + if (nodesVisited > maxNodes) { + return indeterminate( + "domain-error", + `evaluation exceeded the maximum of ${maxNodes.toString()} nodes visited in a single call (resource exhausted)`, + ); + } + // Distinct from `MAX_TREE_REFERENCE_DEPTH`'s own chain-depth counter: `nestingDepth` advances on every recursive descent into a child predicate/expression node, not only at `treeReference` resolution. + if (nestingDepth >= maxNestingDepth) { + return indeterminate( + "domain-error", + `evaluation nesting depth exceeds the maximum of ${maxNestingDepth.toString()} (resource exhausted)`, + ); + } + return undefined; + }, + }; +} + /** A collection candidate paired with its own pre-filter outcome: `"include"`/`"exclude"` when `filter` resolved definitely, or the filter's own indeterminate `Evaluation` when it did not (there is no third, definite-but-neither branch -- see `resolveParticipatingItems` below). */ interface ResolvedCollectionItem { readonly item: unknown; @@ -726,6 +770,8 @@ async function resolveParticipatingItems( functions: Readonly, visitedTreeKeys: ReadonlySet, treeReferenceDepth: number, + budget: Readonly, + nestingDepth: number, ): Promise { const candidates = await resolvers.resolveCollection(collection, context); return Promise.all( @@ -739,6 +785,8 @@ async function resolveParticipatingItems( functions, visitedTreeKeys, treeReferenceDepth, + budget, + nestingDepth + 1, ); if (filterResult.status === "indeterminate") { return { item, filterOutcome: filterResult }; @@ -770,7 +818,11 @@ async function evaluatePredicateInternal( functions: Readonly, visitedTreeKeys: ReadonlySet, treeReferenceDepth: number, + budget: Readonly, + nestingDepth: number, ): Promise> { + const budgetExceeded = budget.checkNode(nestingDepth); + if (budgetExceeded !== undefined) return budgetExceeded; switch (node.kind) { case "not": { const operand = await evaluatePredicateInternal( @@ -781,6 +833,8 @@ async function evaluatePredicateInternal( functions, visitedTreeKeys, treeReferenceDepth, + budget, + nestingDepth + 1, ); if (operand.status === "indeterminate") return operand; return definite(!operand.value); @@ -795,6 +849,8 @@ async function evaluatePredicateInternal( functions, visitedTreeKeys, treeReferenceDepth, + budget, + nestingDepth + 1, ), evaluatePredicateInternal( node.right, @@ -804,6 +860,8 @@ async function evaluatePredicateInternal( functions, visitedTreeKeys, treeReferenceDepth, + budget, + nestingDepth + 1, ), ]); return combineAnd(left, right); @@ -818,6 +876,8 @@ async function evaluatePredicateInternal( functions, visitedTreeKeys, treeReferenceDepth, + budget, + nestingDepth + 1, ), evaluatePredicateInternal( node.right, @@ -827,6 +887,8 @@ async function evaluatePredicateInternal( functions, visitedTreeKeys, treeReferenceDepth, + budget, + nestingDepth + 1, ), ]); return combineOr(left, right); @@ -842,6 +904,8 @@ async function evaluatePredicateInternal( functions, visitedTreeKeys, treeReferenceDepth, + budget, + nestingDepth + 1, ), ), ); @@ -861,6 +925,8 @@ async function evaluatePredicateInternal( functions, visitedTreeKeys, treeReferenceDepth, + budget, + nestingDepth + 1, ), ), ); @@ -879,6 +945,8 @@ async function evaluatePredicateInternal( functions, visitedTreeKeys, treeReferenceDepth, + budget, + nestingDepth + 1, ), evaluateValueInternal( node.right, @@ -888,6 +956,8 @@ async function evaluatePredicateInternal( functions, visitedTreeKeys, treeReferenceDepth, + budget, + nestingDepth + 1, ), ]); if (left.status === "indeterminate") return left; @@ -904,6 +974,8 @@ async function evaluatePredicateInternal( functions, visitedTreeKeys, treeReferenceDepth, + budget, + nestingDepth + 1, ), evaluateValueInternal( node.right, @@ -913,6 +985,8 @@ async function evaluatePredicateInternal( functions, visitedTreeKeys, treeReferenceDepth, + budget, + nestingDepth + 1, ), ]); if (left.status === "indeterminate") return left; @@ -928,6 +1002,8 @@ async function evaluatePredicateInternal( functions, visitedTreeKeys, treeReferenceDepth, + budget, + nestingDepth + 1, ); if (operandResult.status === "indeterminate") return operandResult; @@ -942,6 +1018,8 @@ async function evaluatePredicateInternal( functions, visitedTreeKeys, treeReferenceDepth, + budget, + nestingDepth + 1, ); if (candidateResult.status === "indeterminate") { return candidateResult; @@ -972,6 +1050,8 @@ async function evaluatePredicateInternal( functions, visitedTreeKeys, treeReferenceDepth, + budget, + nestingDepth + 1, ); // The data point resolved to *something* unless it was flatly not-found; a resolved-but-unusable value (wrong-type/domain-error) still counts as existing. `exists` is never itself indeterminate. if ( @@ -992,6 +1072,8 @@ async function evaluatePredicateInternal( functions, visitedTreeKeys, treeReferenceDepth, + budget, + nestingDepth + 1, ); // A filter-excluded item contributes no vote at all (as if never in the collection); a filter-indeterminate item contributes its own indeterminate vote, letting a different item's clean match still absorb it -- contrast with `fold`, which has no absorbing value and goes indeterminate outright on the same condition. const votes = ( @@ -1011,6 +1093,8 @@ async function evaluatePredicateInternal( functions, visitedTreeKeys, treeReferenceDepth, + budget, + nestingDepth + 1, ); }, ), @@ -1060,6 +1144,8 @@ async function evaluatePredicateInternal( functions, new Set([...visitedTreeKeys, keyString]), treeReferenceDepth + 1, + budget, + nestingDepth + 1, ); } default: @@ -1075,7 +1161,11 @@ async function evaluateValueInternal( functions: Readonly, visitedTreeKeys: ReadonlySet, treeReferenceDepth: number, + budget: Readonly, + nestingDepth: number, ): Promise> { + const budgetExceeded = budget.checkNode(nestingDepth); + if (budgetExceeded !== undefined) return budgetExceeded; switch (node.kind) { case "reference": { const resolution = await resolvers.resolveValue(node.key, context); @@ -1125,6 +1215,8 @@ async function evaluateValueInternal( functions, visitedTreeKeys, treeReferenceDepth, + budget, + nestingDepth + 1, ), ), ); @@ -1184,6 +1276,8 @@ async function evaluateValueInternal( functions, visitedTreeKeys, treeReferenceDepth, + budget, + nestingDepth + 1, ), evaluateValueInternal( node.right, @@ -1193,6 +1287,8 @@ async function evaluateValueInternal( functions, visitedTreeKeys, treeReferenceDepth, + budget, + nestingDepth + 1, ), ]); // Unlike `and`/`or` (see `combineAnd`/`combineOr` above), arithmetic has no absorbing value: any indeterminate operand always makes the whole node indeterminate, regardless of what the other operand would have been, tie-broken left before right per the tie-break rule. @@ -1209,6 +1305,8 @@ async function evaluateValueInternal( functions, visitedTreeKeys, treeReferenceDepth, + budget, + nestingDepth + 1, ); if (operand.status === "indeterminate") return operand; return applyNegate(operand.value); @@ -1225,6 +1323,8 @@ async function evaluateValueInternal( functions, visitedTreeKeys, treeReferenceDepth, + budget, + nestingDepth + 1, ), ), ); @@ -1259,6 +1359,8 @@ async function evaluateValueInternal( functions, visitedTreeKeys, treeReferenceDepth, + budget, + nestingDepth + 1, ); if (whenResult.status === "indeterminate") return whenResult; if (whenResult.value) { @@ -1270,6 +1372,8 @@ async function evaluateValueInternal( functions, visitedTreeKeys, treeReferenceDepth, + budget, + nestingDepth + 1, ); } } @@ -1281,6 +1385,8 @@ async function evaluateValueInternal( functions, visitedTreeKeys, treeReferenceDepth, + budget, + nestingDepth + 1, ); } @@ -1296,6 +1402,8 @@ async function evaluateValueInternal( functions, visitedTreeKeys, treeReferenceDepth, + budget, + nestingDepth + 1, ), })), ); @@ -1323,6 +1431,8 @@ async function evaluateValueInternal( functions, visitedTreeKeys, treeReferenceDepth, + budget, + nestingDepth + 1, ); } return evaluateValueInternal( @@ -1333,6 +1443,8 @@ async function evaluateValueInternal( functions, visitedTreeKeys, treeReferenceDepth, + budget, + nestingDepth + 1, ); } case "fold": { @@ -1344,6 +1456,8 @@ async function evaluateValueInternal( functions, visitedTreeKeys, treeReferenceDepth, + budget, + nestingDepth + 1, ); const filterIndeterminateReason = firstFilterIndeterminate(participating); if (filterIndeterminateReason !== undefined) { @@ -1363,6 +1477,8 @@ async function evaluateValueInternal( functions, visitedTreeKeys, treeReferenceDepth, + budget, + nestingDepth + 1, ); if (initialResult.status === "indeterminate") return initialResult; let runningAccumulator = initialResult.value; @@ -1375,6 +1491,8 @@ async function evaluateValueInternal( functions, visitedTreeKeys, treeReferenceDepth, + budget, + nestingDepth + 1, ); if (stepResult.status === "indeterminate") return stepResult; runningAccumulator = stepResult.value; @@ -1401,6 +1519,8 @@ async function evaluateValueInternal( functions, visitedTreeKeys, treeReferenceDepth, + budget, + nestingDepth + 1, ); if (itemResult.status === "indeterminate") return itemResult; if (runningExtremum === undefined) { @@ -1481,6 +1601,8 @@ async function evaluateValueInternal( functions, new Set([...visitedTreeKeys, keyString]), treeReferenceDepth + 1, + budget, + nestingDepth + 1, ); } default: @@ -1489,12 +1611,18 @@ async function evaluateValueInternal( } /** - * Builds a bound `{ evaluatePredicate, evaluateValue }` pair over a caller-supplied function registry for `call` nodes -- the registry is bound once, at construction time, unlike `resolvers`, which are supplied fresh to every call. The bare module-level `evaluatePredicate`/`evaluateValue` exports below are `createEvaluator({})`'s output. + * Builds a bound `{ evaluatePredicate, evaluateValue }` pair over a caller-supplied function registry for `call` nodes -- the registry is bound once, at construction time, unlike `resolvers`, which are supplied fresh to every call. `maxNodes` and `maxNestingDepth` configure that same pair's own `EvaluationBudget` (see its doc comment above), created fresh for every top-level `evaluatePredicate`/`evaluateValue` invocation so budgets never leak between unrelated calls. The bare module-level `evaluatePredicate`/`evaluateValue` exports below are `createEvaluator({})`'s output, so both caps default to `DEFAULT_MAX_EVALUATION_NODES`/`DEFAULT_MAX_NESTING_DEPTH` for every caller that does not explicitly configure them. */ export function createEvaluator({ functions = emptyFunctionRegistry, + maxNodes = DEFAULT_MAX_EVALUATION_NODES, + maxNestingDepth = DEFAULT_MAX_NESTING_DEPTH, }: { functions?: FunctionRegistry; + /** Caps the total number of predicate/expression nodes a single `evaluatePredicate`/`evaluateValue` call may visit -- see `createEvaluationBudget`. Defaults to `DEFAULT_MAX_EVALUATION_NODES`. */ + maxNodes?: number; + /** Caps ordinary recursive-descent nesting depth, independent of `MAX_TREE_REFERENCE_DEPTH`'s own `treeReference` chain-depth cap -- see `createEvaluationBudget`. Defaults to `DEFAULT_MAX_NESTING_DEPTH`. */ + maxNestingDepth?: number; }): { evaluatePredicate: ( node: PredicateNode, @@ -1517,6 +1645,8 @@ export function createEvaluator({ functions, new Set(), 0, + createEvaluationBudget(maxNodes, maxNestingDepth), + 0, ), evaluateValue: async (node, context, resolvers) => evaluateValueInternal( @@ -1527,6 +1657,8 @@ export function createEvaluator({ functions, new Set(), 0, + createEvaluationBudget(maxNodes, maxNestingDepth), + 0, ), }; }