diff --git a/.changeset/bounded-ast-walk-for-author-source.md b/.changeset/bounded-ast-walk-for-author-source.md new file mode 100644 index 0000000000..342e6a9246 --- /dev/null +++ b/.changeset/bounded-ast-walk-for-author-source.md @@ -0,0 +1,45 @@ +--- +'@ifc-lite/extensions': patch +--- + +Bound the AST walks over extension-author source so a deeply nested script is +reported, not fatal. + +`validateCode` and `inferCapabilities` both fed an AST parsed from +author-supplied source to `acorn-walk`'s `walk.simple`, which recurses once per +AST level. A script nested a few hundred levels deep threw +`RangeError: Maximum call stack size exceeded` out of the middle of both +functions, escaping the result shape each one is declared to return. Measured +here, an 800-level script overflowed and a 700-level one did not, and which of +the two overflowed moved with test ordering — the failure point tracked whatever +stack the caller happened to have left. + +Both now traverse through a new internal `walkBounded` +(`src/ast/bounded-walk.ts`), which keeps its own stack on the heap and stops at +`MAX_AST_DEPTH = 1000` (~500 source levels of `if (1) { … }`, below acorn's own +parse floor of roughly 1200). It descends using `acorn-walk`'s `base` visitor +and reports nodes in `walk.simple`'s post-order, so which child positions count +as nodes — non-computed member properties and object keys stay unvisited — and +the order they arrive in are unchanged. Behaviour below the bound is identical. + +Catching the `RangeError` would have been the smaller change and is the wrong +one: it makes the accept/reject boundary depend on the remaining call stack, so +the same script passes on one code path and fails on another. The bound is a +reported result instead. + +What each site returns at the bound: + +- **`validateCode`** adds an `invalid_value` error naming the limit and returns + `ok: false`. A truncated walk has not proven the source clean; anything below + the cut-off went uninspected, so reporting `ok` would be a pass on a partial + inspection. +- **`inferCapabilities`** returns an empty capability set *and* a `parseErrors` + entry naming the limit. The capabilities found before the walk stopped are a + floor, not the answer. Returning them alone would fail open in both callers: + `migrateSavedScripts` treats an empty set as "grant `model.read` and migrate + anyway", and the promote dialog renders it as "no `bim.*` calls detected". + `parseErrors` is the channel both already use to refuse a script — the + migration now skips it and the dialog shows its warning. + +No public API change; `walkBounded` is not exported from the package entry +point. diff --git a/packages/extensions/src/ast/bounded-walk.test.ts b/packages/extensions/src/ast/bounded-walk.test.ts new file mode 100644 index 0000000000..d5780aab2e --- /dev/null +++ b/packages/extensions/src/ast/bounded-walk.test.ts @@ -0,0 +1,108 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import { describe, it, expect } from 'vitest'; +import * as acorn from 'acorn'; +import * as walk from 'acorn-walk'; +import { MAX_AST_DEPTH, walkBounded } from './bounded-walk.js'; + +function parse(src: string): acorn.Node { + return acorn.parse(src, { ecmaVersion: 'latest', sourceType: 'module' }); +} + +/** `levels` nested `if (1) { … }` blocks around `inner`. */ +function nestIf(levels: number, inner: string): string { + return 'if(1){'.repeat(levels) + inner + '}'.repeat(levels); +} + +describe('walkBounded', () => { + it('visits the same nodes, in the same order, as walk.simple', () => { + const src = ` + const a = { window: 1, ['self']: 2 }; + function f(x) { return x.process + eval('1'); } + label: for (const k of [1, 2]) { f(k); } + class C { document() { return new Function('a'); } } + import('./x.js'); + `; + const ast = parse(src); + + const viaAcorn: string[] = []; + const types = [ + 'Identifier', + 'CallExpression', + 'NewExpression', + 'ImportExpression', + 'MemberExpression', + 'Property', + 'BlockStatement', + ]; + const visitors: Record void> = {}; + for (const t of types) { + visitors[t] = (n) => viaAcorn.push(`${t}@${n.start}-${n.end}`); + } + walk.simple(ast as acorn.AnyNode, visitors as never); + + const viaBounded: string[] = []; + const res = walkBounded(ast, (node, type) => { + if (types.includes(type)) { + viaBounded.push(`${type}@${node.start as number}-${node.end as number}`); + } + }); + + expect(res.depthExceeded).toBe(false); + expect(viaBounded).toEqual(viaAcorn); + // Guard against the comparison being vacuous. + expect(viaAcorn.length).toBeGreaterThan(12); + }); + + it('does not visit a non-computed member property, matching acorn-walk', () => { + const names: string[] = []; + walkBounded(parse('foo.window;'), (node, type) => { + if (type === 'Identifier') names.push(node.name as string); + }); + expect(names).toEqual(['foo']); + }); + + it('does visit a computed member property, matching acorn-walk', () => { + const names: string[] = []; + walkBounded(parse('foo[window];'), (node, type) => { + if (type === 'Identifier') names.push(node.name as string); + }); + expect(names).toEqual(['foo', 'window']); + }); + + it('completes a deep-but-legal script without reporting depth', () => { + // 400 source levels -> ~800 AST levels, under the 1000 bound. + const res = walkBounded(parse(nestIf(400, 'x;')), () => {}); + expect(res.depthExceeded).toBe(false); + }); + + it('reports depth instead of throwing on a script past the bound', () => { + // 800 source levels -> ~1600 AST levels, over the bound. The + // recursive acorn-walk this replaced threw RangeError here. + const res = walkBounded(parse(nestIf(800, 'x;')), () => {}); + expect(res.depthExceeded).toBe(true); + }); + + it('bounds by AST depth, not source depth: two AST levels per if-block', () => { + // Pins the constant's meaning. A source nesting of just over + // MAX_AST_DEPTH/2 must trip the bound; just under must not. + expect(walkBounded(parse(nestIf(MAX_AST_DEPTH / 2 - 20, 'x;')), () => {}).depthExceeded) + .toBe(false); + expect(walkBounded(parse(nestIf(MAX_AST_DEPTH / 2 + 20, 'x;')), () => {}).depthExceeded) + .toBe(true); + }); + + it('a wide-but-shallow AST is never depth-limited', () => { + // Far more nodes than MAX_AST_DEPTH, all at depth ~3. The bound + // must be about nesting, not node count. + const src = Array.from({ length: MAX_AST_DEPTH * 3 }, (_, i) => `a${i};`).join('\n'); + let seen = 0; + const res = walkBounded(parse(src), (_n, type) => { + if (type === 'Identifier') seen++; + }); + expect(res.depthExceeded).toBe(false); + expect(seen).toBe(MAX_AST_DEPTH * 3); + }); +}); diff --git a/packages/extensions/src/ast/bounded-walk.ts b/packages/extensions/src/ast/bounded-walk.ts new file mode 100644 index 0000000000..92c2b3abe5 --- /dev/null +++ b/packages/extensions/src/ast/bounded-walk.ts @@ -0,0 +1,162 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +/** + * Depth-bounded, non-recursive AST traversal. + * + * Every AST walked in this package comes from source an extension + * author supplies, so the traversal is attacker-reachable and must not + * be able to exhaust the JS call stack. `acorn-walk`'s walkers recurse + * once per AST level; a script nested a few hundred levels deep throws + * `RangeError: Maximum call stack size exceeded` out of the middle of + * whatever function invoked the walk. + * + * This module is the single traversal used by every AST consumer here. + * It keeps its own stack on the heap and stops at {@link MAX_AST_DEPTH}, + * *reporting* that it stopped rather than throwing. Callers vary the + * visitor; they do not re-implement the traversal. + * + * It descends using `acorn-walk`'s own `base` visitor rather than + * enumerating object properties generically, so which child positions + * count as nodes is identical to what `walk.simple` would have visited: + * non-computed member properties, non-computed object keys and labels + * stay unvisited. Nodes are reported in `walk.simple`'s post-order + * (children before parent) for the same reason — swapping either would + * silently change what the call sites see. + * + * Deliberately NOT exported from the package entry point — internal + * utility, not public API. + */ + +import * as walk from 'acorn-walk'; + +/** + * Maximum AST nesting depth any walk in this package will inspect. + * + * Real scripts nest a few tens of levels deep; this bound is two orders + * of magnitude above that. It exists because the AST comes from + * author-controlled source: past this depth the walk stops and the + * caller reports a validation failure instead of continuing. + * + * One `if (1) { … }` source level costs two levels here + * (`IfStatement` -> `BlockStatement`), and acorn's own parser gives up + * at roughly 1200 *source* levels ("Not enough stack space to parse + * input"). That parser limit moves with however much stack the host + * happens to have left; this one does not, which is the entire point — + * the accept/reject boundary must not depend on the caller's remaining + * stack. + * + * Catching the `RangeError` instead would reintroduce exactly that + * dependency. Measured on this repo's suite before the fix, an + * unbounded walk over a 600-level script overflowed while a 700-level + * one did not, and which of the two overflowed moved with test order. + */ +export const MAX_AST_DEPTH = 1000; + +/** Minimal structural view of an ESTree node. */ +export interface AstNode { + type: string; + [key: string]: unknown; +} + +export interface BoundedWalkResult { + /** + * True if traversal stopped early because a node deeper than + * {@link MAX_AST_DEPTH} was reached. When true the visit is + * incomplete and the caller MUST treat the result as a failure — + * never as "nothing found". + */ + depthExceeded: boolean; +} + +function isAstNode(value: unknown): value is AstNode { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { type?: unknown }).type === 'string' + ); +} + +interface Frame { + node: AstNode; + depth: number; + /** + * `acorn-walk` re-dispatches some nodes under a synthetic type + * ("Statement", "Expression", "Function", "Pattern", …). The visitor + * key is `override || node.type`, exactly as in `walk.simple`. + */ + override?: string; + /** False on the descend pass, true on the report pass. */ + expanded: boolean; +} + +/** + * Visit every node `walk.simple` would have visited, in the same order, + * without recursing and without exceeding {@link MAX_AST_DEPTH}. + * + * The visitor is handed the raw node plus the type key `walk.simple` + * would have looked its visitor up under; call sites switch on that + * key. One traversal is therefore shared across sites that care about + * entirely different node types. + * + * Returns `{ depthExceeded: true }` if the bound stopped the walk. The + * traversal never throws for depth reasons. + */ +export function walkBounded( + root: unknown, + visit: (node: AstNode, type: string) => void, +): BoundedWalkResult { + if (!isAstNode(root)) return { depthExceeded: false }; + + const baseVisitor = walk.base as unknown as Record< + string, + ((node: unknown, state: unknown, c: (child: unknown, state: unknown, override?: string) => void) => void) | undefined + >; + + const stack: Frame[] = [{ node: root, depth: 0, expanded: false }]; + + while (stack.length > 0) { + const frame = stack.pop()!; + const type = frame.override ?? frame.node.type; + + if (frame.expanded) { + visit(frame.node, type); + continue; + } + + if (frame.depth > MAX_AST_DEPTH) return { depthExceeded: true }; + + const baseFn = baseVisitor[type]; + if (!baseFn) { + // Unknown node type: report it and stop descending, matching + // acorn-walk's own behaviour of throwing only on a missing base. + visit(frame.node, type); + continue; + } + + const children: Frame[] = []; + baseFn(frame.node, null, (child, _state, override) => { + if (!isAstNode(child)) return; + children.push({ + node: child, + // `skipThrough` bases re-dispatch the *same* node under a new + // key; that is not a step down the tree, so it must not consume + // a depth level. + depth: child === frame.node ? frame.depth : frame.depth + 1, + override, + expanded: false, + }); + }); + + // Report-after-children, matching walk.simple's post-order: push + // the report frame first so it pops last, then the children in + // reverse so the LIFO stack takes them in source order. + stack.push({ ...frame, expanded: true }); + for (let i = children.length - 1; i >= 0; i--) { + stack.push(children[i]!); + } + } + + return { depthExceeded: false }; +} diff --git a/packages/extensions/src/flavor/migrate-scripts.test.ts b/packages/extensions/src/flavor/migrate-scripts.test.ts index 16760e051d..3d7013dece 100644 --- a/packages/extensions/src/flavor/migrate-scripts.test.ts +++ b/packages/extensions/src/flavor/migrate-scripts.test.ts @@ -37,6 +37,19 @@ describe('migrateSavedScripts', () => { expect(r.skipped).toHaveLength(1); }); + it('skips a script too deeply nested to infer capabilities from', () => { + // Capability inference stops at its AST depth bound and reports the + // stop as a parse error. That must land in `skipped` — the + // alternative, an empty capability set, silently falls through to + // the `model.read` fallback above and migrates an uninspected + // script. + const code = `${'if(1){'.repeat(800)}bim.model.write();${'}'.repeat(800)}`; + const r = migrateSavedScripts([{ id: 'deep', name: 'Deep', code }]); + expect(r.extensions).toHaveLength(0); + expect(r.skipped).toHaveLength(1); + expect(r.skipped[0].reason).toMatch(/nested more than \d+ AST levels/); + }); + it('produces a slug-stable extension id', () => { const r = migrateSavedScripts([ { id: 'Count Walls!', name: 'Count Walls!', code: 'bim.query.byType("IfcWall");' }, diff --git a/packages/extensions/src/inference/capability.test.ts b/packages/extensions/src/inference/capability.test.ts index e5e0b4de69..e9887a5d12 100644 --- a/packages/extensions/src/inference/capability.test.ts +++ b/packages/extensions/src/inference/capability.test.ts @@ -3,7 +3,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import { describe, expect, it } from 'vitest'; -import { inferCapabilities } from './capability.js'; +import { inferCapabilities, type InferenceResult } from './capability.js'; describe('inferCapabilities — read-only patterns', () => { it('detects model.read for bim.query usage', () => { @@ -161,3 +161,61 @@ describe('inferCapabilities — non-string inputs', () => { expect(r.parseErrors.length).toBeGreaterThan(0); }); }); + +describe('inferCapabilities — deeply nested scripts fail closed', () => { + /** `levels` nested `if (1) { … }` blocks around `inner`. */ + function nestIf(levels: number, inner: string): string { + return 'if(1){'.repeat(levels) + inner + '}'.repeat(levels); + } + + it('still infers from a deep-but-legal script', () => { + // 400 source levels is ~800 AST levels — under the bound. + const r = inferCapabilities(nestIf(400, 'bim.viewer.colorize({});')); + expect(r.parseErrors).toEqual([]); + expect(r.capabilities.length).toBeGreaterThan(0); + expect(r.observations.map((o) => o.call)).toContain('bim.viewer.colorize'); + }); + + it('reports a parse error instead of throwing past the bound', () => { + // Before the bound this threw `RangeError: Maximum call stack size + // exceeded` out of acorn-walk. + let r!: InferenceResult; + expect(() => { + r = inferCapabilities(nestIf(800, 'bim.viewer.colorize({});')); + }).not.toThrow(); + expect(r.parseErrors.some((e) => /nested more than \d+ AST levels/.test(e.message))).toBe(true); + }); + + it('never reports a partial capability set for a too-deep script', () => { + // Fail-closed is the whole point. `migrateSavedScripts` skips on a + // parse error but treats an empty capability set as "grant + // model.read and migrate anyway", and PromoteToolDialog renders an + // empty set as "no bim.* calls detected". A truncated walk must + // therefore surface as a parse error, not as capabilities. + const r = inferCapabilities(nestIf(800, 'bim.viewer.colorize({});')); + expect(r.parseErrors.length).toBeGreaterThan(0); + expect(r.capabilities).toEqual([]); + expect(r.observations).toEqual([]); + }); + + it('reports the depth error even when bim.* calls sit above the cut-off', () => { + // The capabilities found before the walk stopped are a floor, not + // the answer — deeper calls may need more. Returning just the + // shallow ones would under-grant silently. + const r = inferCapabilities(`bim.viewer.colorize({});\n${nestIf(800, 'bim.model.write();')}`); + expect(r.parseErrors.some((e) => /nested more than \d+ AST levels/.test(e.message))).toBe(true); + expect(r.capabilities).toEqual([]); + }); + + it('gives the same verdict however much stack the caller has left', () => { + const source = nestIf(800, 'bim.viewer.colorize({});'); + const shallow = inferCapabilities(source); + const recurse = (n: number): InferenceResult => + n === 0 ? inferCapabilities(source) : recurse(n - 1); + const deep = recurse(2000); + expect(deep.capabilities).toEqual(shallow.capabilities); + expect(deep.parseErrors.map((e) => e.message)).toEqual( + shallow.parseErrors.map((e) => e.message), + ); + }); +}); diff --git a/packages/extensions/src/inference/capability.ts b/packages/extensions/src/inference/capability.ts index dffee4c8f0..2067c536b2 100644 --- a/packages/extensions/src/inference/capability.ts +++ b/packages/extensions/src/inference/capability.ts @@ -25,7 +25,7 @@ */ import * as acorn from 'acorn'; -import * as walk from 'acorn-walk'; +import { MAX_AST_DEPTH, walkBounded } from '../ast/bounded-walk.js'; import { lookupNamespaceMethod, isKnownNamespace } from './catalogue.js'; export interface InferenceResult { @@ -94,29 +94,45 @@ export function inferCapabilities(source: string): InferenceResult { } const observations: InferenceObservation[] = []; - walk.simple(ast as acorn.AnyNode, { - MemberExpression(node) { - const chain = readMemberChain(node); - if (!chain || chain[0] !== 'bim') return; - // Patterns we care about: - // bim. — at least 2 parts. Untargeted; default ns. - // bim.. — 3 parts; specific method. - // bim..(...) — same; we record at the chain stage. - const namespace = chain[1] ?? undefined; - const method = chain[2] ?? undefined; - if (!namespace) return; - const call = `bim.${namespace}${method ? `.${method}` : ''}`; - const caps = method - ? lookupNamespaceMethod(namespace, method) - : INFERENCE_FALLBACK_FOR(namespace); - observations.push({ - call, - capabilities: [...caps], - unknown: !isKnownNamespace(namespace), - }); - }, + const { depthExceeded } = walkBounded(ast, (node, type) => { + if (type !== 'MemberExpression') return; + const chain = readMemberChain(node); + if (!chain || chain[0] !== 'bim') return; + // Patterns we care about: + // bim. — at least 2 parts. Untargeted; default ns. + // bim.. — 3 parts; specific method. + // bim..(...) — same; we record at the chain stage. + const namespace = chain[1] ?? undefined; + const method = chain[2] ?? undefined; + if (!namespace) return; + const call = `bim.${namespace}${method ? `.${method}` : ''}`; + const caps = method + ? lookupNamespaceMethod(namespace, method) + : INFERENCE_FALLBACK_FOR(namespace); + observations.push({ + call, + capabilities: [...caps], + unknown: !isKnownNamespace(namespace), + }); }); + // A walk that stopped at the depth bound has NOT seen the whole + // script, so the capability set it produced is a floor, not the + // answer. Returning it as-is would fail open in both directions: + // `migrateSavedScripts` treats an empty set as "grant model.read and + // migrate anyway", and the promote dialog renders "No `bim.*` calls + // detected". Report it through `parseErrors`, which is the channel + // both callers already use to refuse the script — the migration skips + // it, and the dialog shows the warning. + if (depthExceeded) { + parseErrors.push({ + message: `source is nested more than ${MAX_AST_DEPTH} AST levels deep; capabilities could not be inferred`, + line: 0, + column: 0, + }); + return { capabilities: [], observations: [], parseErrors }; + } + return { capabilities: dedupeAndSort(observations.flatMap((o) => o.capabilities)), observations: dedupeObservations(observations), diff --git a/packages/extensions/src/validate/code.test.ts b/packages/extensions/src/validate/code.test.ts index b25bbe61f7..10114ee616 100644 --- a/packages/extensions/src/validate/code.test.ts +++ b/packages/extensions/src/validate/code.test.ts @@ -3,7 +3,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import { describe, expect, it } from 'vitest'; -import { validateCode } from './code.js'; +import { validateCode, type CodeValidationResult } from './code.js'; describe('validateCode — clean sources pass', () => { it('accepts a plain function declaration', () => { @@ -128,3 +128,57 @@ describe('validateCode — banned patterns report real line / column', () => { expect(lines).toContain('[2:10]'); }); }); + +describe('validateCode — deeply nested sources are bounded, not fatal', () => { + /** `levels` nested `if (1) { … }` blocks around `inner`. */ + function nestIf(levels: number, inner: string): string { + return 'if(1){'.repeat(levels) + inner + '}'.repeat(levels); + } + + it('still walks a deep-but-legal source to completion', () => { + // 400 source levels is ~800 AST levels — under the bound, so the + // violation buried at the bottom is still found, and nothing else + // is reported. + const r = validateCode(nestIf(400, 'eval("1");')); + expect(r.ok).toBe(false); + expect(r.errors).toHaveLength(1); + expect(r.errors[0].message).toContain('Banned call'); + }); + + it('accepts a deep-but-legal clean source', () => { + const r = validateCode(nestIf(400, 'const x = 1;')); + expect(r.ok).toBe(true); + expect(r.errors).toEqual([]); + }); + + it('reports a depth error instead of throwing past the bound', () => { + // Before the bound this threw `RangeError: Maximum call stack size + // exceeded` out of acorn-walk, escaping validateCode's contract. + let r!: CodeValidationResult; + expect(() => { + r = validateCode(nestIf(800, 'eval("1");')); + }).not.toThrow(); + expect(r.ok).toBe(false); + expect(r.errors.some((e) => /nested more than \d+ AST levels/.test(e.message))).toBe(true); + }); + + it('does not report ok for a too-deep source with no visible violation', () => { + // The dangerous shape: nothing banned above the cut-off. A + // truncated walk must not be mistaken for a clean bill of health. + const r = validateCode(nestIf(800, 'const x = 1;')); + expect(r.ok).toBe(false); + expect(r.errors.some((e) => /nested more than \d+ AST levels/.test(e.message))).toBe(true); + }); + + it('gives the same verdict however much stack the caller has left', () => { + // The reason we bound rather than catching RangeError: the same + // source must be judged identically from any call depth. + const source = nestIf(800, 'const x = 1;'); + const shallow = validateCode(source); + const recurse = (n: number): CodeValidationResult => + n === 0 ? validateCode(source) : recurse(n - 1); + const deep = recurse(2000); + expect(deep.ok).toBe(shallow.ok); + expect(deep.errors.map((e) => e.message)).toEqual(shallow.errors.map((e) => e.message)); + }); +}); diff --git a/packages/extensions/src/validate/code.ts b/packages/extensions/src/validate/code.ts index 166a256ef9..46a237f09c 100644 --- a/packages/extensions/src/validate/code.ts +++ b/packages/extensions/src/validate/code.ts @@ -21,7 +21,7 @@ */ import * as acorn from 'acorn'; -import * as walk from 'acorn-walk'; +import { MAX_AST_DEPTH, walkBounded } from '../ast/bounded-walk.js'; import type { ValidationError } from '../types.js'; const BANNED_GLOBALS = new Set(['globalThis', 'window', 'process', 'document', 'self']); @@ -72,65 +72,83 @@ export function validateCode(source: string, opts: CodeValidationOptions = {}): return { ok: false, errors }; } - walk.simple(ast as acorn.AnyNode, { - Identifier(node) { - const n = node as acorn.Identifier; - if (BANNED_GLOBALS.has(n.name)) { - // This catches identifier *references*. The walker visits identifiers - // anywhere they appear, including binding positions — and assignment - // to `window.foo` would also flag. That's intentionally conservative: - // we don't want to allow `globalThis.foo = bar` either. - errors.push({ - path: `${prefix}[${n.loc?.start.line ?? 0}:${n.loc?.start.column ?? 0}]`, - code: 'invalid_value', - message: `Banned global identifier: "${n.name}".`, - hint: 'Use ctx fields instead. The sandbox does not expose host realm globals.', - }); + const { depthExceeded } = walkBounded(ast, (node, type) => { + switch (type) { + case 'Identifier': { + const n = node as unknown as acorn.Identifier; + if (BANNED_GLOBALS.has(n.name)) { + // This catches identifier *references*. The walk visits identifiers + // anywhere they appear, including binding positions — and assignment + // to `window.foo` would also flag. That's intentionally conservative: + // we don't want to allow `globalThis.foo = bar` either. + errors.push({ + path: `${prefix}[${n.loc?.start.line ?? 0}:${n.loc?.start.column ?? 0}]`, + code: 'invalid_value', + message: `Banned global identifier: "${n.name}".`, + hint: 'Use ctx fields instead. The sandbox does not expose host realm globals.', + }); + } + break; } - }, - CallExpression(node) { - const n = node as acorn.CallExpression; - const callee = n.callee; - if (callee.type === 'Identifier' && BANNED_CALLS.has(callee.name)) { - errors.push({ - path: `${prefix}[${n.loc?.start.line ?? 0}:${n.loc?.start.column ?? 0}]`, - code: 'invalid_value', - message: `Banned call: "${callee.name}(...)" is not allowed in extension code.`, - hint: 'Inline the logic. Dynamic evaluation is out of scope for v1.', - }); + case 'CallExpression': { + const n = node as unknown as acorn.CallExpression; + const callee = n.callee; + if (callee.type === 'Identifier' && BANNED_CALLS.has(callee.name)) { + errors.push({ + path: `${prefix}[${n.loc?.start.line ?? 0}:${n.loc?.start.column ?? 0}]`, + code: 'invalid_value', + message: `Banned call: "${callee.name}(...)" is not allowed in extension code.`, + hint: 'Inline the logic. Dynamic evaluation is out of scope for v1.', + }); + } + break; } - }, - NewExpression(node) { - const n = node as acorn.NewExpression; - if (n.callee.type === 'Identifier' && n.callee.name === 'Function') { - errors.push({ - path: `${prefix}[${n.loc?.start.line ?? 0}:${n.loc?.start.column ?? 0}]`, - code: 'invalid_value', - message: 'Banned: `new Function(...)`.', - hint: 'Dynamic function construction is forbidden in extension code.', - }); + case 'NewExpression': { + const n = node as unknown as acorn.NewExpression; + if (n.callee.type === 'Identifier' && n.callee.name === 'Function') { + errors.push({ + path: `${prefix}[${n.loc?.start.line ?? 0}:${n.loc?.start.column ?? 0}]`, + code: 'invalid_value', + message: 'Banned: `new Function(...)`.', + hint: 'Dynamic function construction is forbidden in extension code.', + }); + } + break; } - }, - ImportExpression(node) { - const n = node as acorn.ImportExpression; - if (n.source.type !== 'Literal' || typeof n.source.value !== 'string') { - errors.push({ - path: `${prefix}[${n.loc?.start.line ?? 0}:${n.loc?.start.column ?? 0}]`, - code: 'invalid_value', - message: 'Dynamic import requires a string literal specifier.', - }); - return; + case 'ImportExpression': { + const n = node as unknown as acorn.ImportExpression; + if (n.source.type !== 'Literal' || typeof n.source.value !== 'string') { + errors.push({ + path: `${prefix}[${n.loc?.start.line ?? 0}:${n.loc?.start.column ?? 0}]`, + code: 'invalid_value', + message: 'Dynamic import requires a string literal specifier.', + }); + break; + } + if (!allowed.has(n.source.value)) { + errors.push({ + path: `${prefix}[${n.loc?.start.line ?? 0}:${n.loc?.start.column ?? 0}]`, + code: 'invalid_reference', + message: `Dynamic import of "${n.source.value}" is not allowed.`, + hint: 'Only specifiers internal to the bundle may be imported dynamically.', + }); + } + break; } - if (!allowed.has(n.source.value)) { - errors.push({ - path: `${prefix}[${n.loc?.start.line ?? 0}:${n.loc?.start.column ?? 0}]`, - code: 'invalid_reference', - message: `Dynamic import of "${n.source.value}" is not allowed.`, - hint: 'Only specifiers internal to the bundle may be imported dynamically.', - }); - } - }, + } }); + // A truncated walk has not proven the source clean — anything below + // the cut-off is unexamined. Report the depth as its own validation + // failure rather than returning `ok` on a partial inspection. + if (depthExceeded) { + errors.push({ + path: prefix, + code: 'invalid_value', + message: `Source is nested more than ${MAX_AST_DEPTH} AST levels deep; it was not fully validated.`, + hint: 'Flatten the source — extract deeply nested blocks into separate helper functions.', + }); + } + return { ok: errors.length === 0, errors }; }