From 6db17a5cc689a35c3f49383ea72d73d7216ca81d Mon Sep 17 00:00:00 2001 From: jaenyf <95911656+jaenyf@users.noreply.github.com> Date: Sun, 19 Apr 2026 12:07:22 +0200 Subject: [PATCH] Allow adding function with context access - Context access is disabled by default - It has to be specified with related option when calling addFunction - It does not work with arrows functions (because of bind) --- README.md | 19 ++++++++++++++++++- src/eval-ops.ts | 2 +- src/evaluator-async.ts | 6 +++--- src/evaluator.ts | 12 ++++++++---- src/index.ts | 2 +- src/plugins.ts | 15 +++++++++------ src/types.ts | 2 +- tests/bonsai.test.ts | 27 +++++++++++++++++++++++++++ tests/evaluator-transforms.test.ts | 7 ++++++- 9 files changed, 74 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index d18d243..4be503c 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ isEligible.evaluateSync({ ``` ### Async enrichment +#### Isolated by default ```ts import { bonsai } from 'bonsai-js' @@ -124,6 +125,21 @@ expr.addFunction('lookupTier', async (userId) => { await expr.evaluate('lookupTier(userId) == "pro"', { userId: 'u_123' }) ``` +#### With context access if enabled + +```ts +import { bonsai } from 'bonsai-js' + +const expr = bonsai() + +function lookupCurrentUserTier (this: { context: { currentUserId: string} } ) { + const row = await db.users.findById(this.context.currentUserId) + return row?.tier ?? 'free' +} +expr.addFunction('lookupCurrentUserTier', lookupCurrentUserTier, { allowContextAccess: true }) + +await expr.evaluate('lookupCurrentUserTier() == "pro"', { currentUserId: 'u_123' }) +``` ### Editor validation @@ -364,7 +380,7 @@ evaluateExpression('x * 2', { x: 21 }) // 42 interface BonsaiInstance { use(plugin: BonsaiPlugin): this addTransform(name: string, fn: TransformFn): this - addFunction(name: string, fn: FunctionFn): this + addFunction(name: string, fn: FunctionFn, addFunctionOptions?: {allowContextAccess: boolean}): this removeTransform(name: string): boolean removeFunction(name: string): boolean hasTransform(name: string): boolean @@ -540,6 +556,7 @@ What Bonsai does: - validates method call receivers against a safe allowlist of types (string, number, array, plain object) — array methods include `filter`, `map`, `find`, `some`, `every`, `includes`, `indexOf`, `slice`, `at` - automatically bypasses allow/deny lists for canonical numeric array indices (e.g., `items[0]`) - rejects `Promise` values in `evaluateSync()` with actionable errors that name the offending function/transform/method and suggest using `evaluate()` instead +- runs custom user-defined functions with isolation by default, although they can be bound to a given context if explicitly allowed Important operational caveats: diff --git a/src/eval-ops.ts b/src/eval-ops.ts index ec7b4b0..b0b7ae4 100644 --- a/src/eval-ops.ts +++ b/src/eval-ops.ts @@ -113,7 +113,7 @@ export function resolveTransform(name: string, transforms: Record): FunctionFn { +export function resolveFunction(name: string, functions: Record): { allowCtx: boolean, f: FunctionFn } { if (!Object.hasOwn(functions, name)) { const suggestion = suggest(name, Object.keys(functions)) throw new BonsaiReferenceError('function', name, suggestion) diff --git a/src/evaluator-async.ts b/src/evaluator-async.ts index 9e19dae..a24e66e 100644 --- a/src/evaluator-async.ts +++ b/src/evaluator-async.ts @@ -21,7 +21,7 @@ export async function evaluateAsync( node: ASTNode, context: Record, transforms: Record, - functions: Record, + functions: Record, guard: ExecutionContext, source?: string, ): Promise { @@ -214,12 +214,12 @@ async function evalCallExpressionAsync( if (node.callee.type === 'Identifier') { try { - const func = resolveFunction(node.callee.name, fn) + const resolved = resolveFunction(node.callee.name, fn) const args: unknown[] = [] for (const arg of node.args) { await pushCallArgumentAsync(args, arg, env) } - const result = await func(...args) + const result = await resolved.f(...args) g.checkTimeout() return result } catch (e) { diff --git a/src/evaluator.ts b/src/evaluator.ts index 1df38f1..4d63508 100644 --- a/src/evaluator.ts +++ b/src/evaluator.ts @@ -28,7 +28,7 @@ function rejectPromise(value: unknown, kind: 'function' | 'method' | 'transform' export interface EvalEnv { ctx: Record tr: Record - fn: Record + fn: Record g: ExecutionContext s?: string } @@ -37,7 +37,7 @@ export function evaluate( node: ASTNode, context: Record, transforms: Record, - functions: Record, + functions: Record, guard: ExecutionContext, source?: string, ): unknown { @@ -207,12 +207,16 @@ function evalCallExpression(node: Extract, if (node.callee.type === 'Identifier') { try { - const func = resolveFunction(node.callee.name, fn) + const resolved = resolveFunction(node.callee.name, fn) const args: unknown[] = [] for (const arg of node.args) { pushCallArgument(args, arg, env) } - return rejectPromise(func(...args), 'function', node.callee.name) + let func = resolved.f; + if (resolved.allowCtx) { + func = func.bind({ context: env.ctx }) + } + return rejectPromise(func(...args), "function", node.callee.name); } catch (e) { if (s) attachLocation(e, s, node.start, node.end) throw e diff --git a/src/index.ts b/src/index.ts index c219238..2c1d7e6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -121,7 +121,7 @@ export function bonsai(options: BonsaiOptions = {}): BonsaiInstance { const instance: BonsaiInstance = { use(plugin) { plugin(instance); return instance }, addTransform(name, fn) { registry.addTransform(name, fn); return instance }, - addFunction(name, fn) { registry.addFunction(name, fn); return instance }, + addFunction(name, fn, addFunctionOptions?: {allowContextAccess: boolean}) { registry.addFunction(name, fn, addFunctionOptions?.allowContextAccess ?? false); return instance }, removeTransform(name) { return registry.removeTransform(name) }, removeFunction(name) { return registry.removeFunction(name) }, hasTransform(name) { return registry.getTransform(name) !== undefined }, diff --git a/src/plugins.ts b/src/plugins.ts index bbf22d3..b9fc269 100644 --- a/src/plugins.ts +++ b/src/plugins.ts @@ -2,31 +2,34 @@ import type { TransformFn, FunctionFn } from './types.js' export interface PluginRegistry { addTransform(name: string, fn: TransformFn): void - addFunction(name: string, fn: FunctionFn): void + addFunction(name: string, fn: FunctionFn, allowCtx?: boolean): void removeTransform(name: string): boolean removeFunction(name: string): boolean getTransform(name: string): TransformFn | undefined - getFunction(name: string): FunctionFn | undefined + getFunction(name: string): { f: FunctionFn, allowCtx: boolean } | undefined getTransformNames(): string[] getFunctionNames(): string[] use(plugin: (registry: PluginRegistry) => void): void readonly transforms: Record - readonly functions: Record + readonly functions: Record } export function createPluginRegistry(): PluginRegistry { const transformMap = new Map() - const functionMap = new Map() + const functionMap = new Map() // Cached snapshots — rebuilt only when registry changes let transformsCache: Record = {} - let functionsCache: Record = {} + let functionsCache: Record = {} let transformsDirty = false let functionsDirty = false const registry: PluginRegistry = { addTransform(name, fn) { transformMap.set(name, fn); transformsDirty = true }, - addFunction(name, fn) { functionMap.set(name, fn); functionsDirty = true }, + addFunction(name, fn, allowContextAccess = false) { + functionMap.set(name, { f: fn, allowCtx: allowContextAccess }); + functionsDirty = true; + }, removeTransform(name) { const r = transformMap.delete(name); if (r) transformsDirty = true; return r }, removeFunction(name) { const r = functionMap.delete(name); if (r) functionsDirty = true; return r }, getTransform(name) { return transformMap.get(name) }, diff --git a/src/types.ts b/src/types.ts index d24f216..c730326 100644 --- a/src/types.ts +++ b/src/types.ts @@ -245,7 +245,7 @@ export interface BonsaiInstance { /** Register a named transform for use with the pipe operator (`|>`). */ addTransform(name: string, fn: TransformFn): this /** Register a named function callable as `name(args)` in expressions. */ - addFunction(name: string, fn: FunctionFn): this + addFunction(name: string, fn: FunctionFn, addFunctionOptions?: {allowContextAccess: boolean}): this /** Remove a previously registered transform. Returns true if it existed. */ removeTransform(name: string): boolean /** Remove a previously registered function. Returns true if it existed. */ diff --git a/tests/bonsai.test.ts b/tests/bonsai.test.ts index b53d5c0..01d2079 100644 --- a/tests/bonsai.test.ts +++ b/tests/bonsai.test.ts @@ -56,6 +56,33 @@ describe('bonsai()', () => { expect(expr.evaluateSync('greet("Dan")')).toBe('Hello, Dan!') }) + it("allows access to context from inside functions when specified", () => { + const expr = bonsai() + function functionWithContext (this: { context: { name: string} } ) { + return `Hello, ${this.context.name}!` + } + expr.addFunction("greet", functionWithContext, { allowContextAccess: true }); + expect(expr.evaluateSync("greet()", { name: "Dan" })).toBe("Hello, Dan!") + }) + + it("denies access to context from inside functions when specified", () => { + const expr = bonsai() + function functionWithContext (this: { context: { name: string} } ) { + return `Hello, ${this?.context?.name}!` + } + expr.addFunction("greet", functionWithContext, { allowContextAccess: false }); + expect(expr.evaluateSync("greet()", { name: "Dan" })).toBe("Hello, undefined!") + }) + + it("denies access to context from inside functions by default", () => { + const expr = bonsai() + function functionWithContext (this: { context: {name:string} }) { + return `Hello, ${this?.context?.name}!` + } + expr.addFunction("greet", functionWithContext) + expect(expr.evaluateSync("greet()", { name: "Dan" })).toBe("Hello, undefined!") + }) + it('should apply plugins via use()', () => { const expr = bonsai() expr.use((e) => { diff --git a/tests/evaluator-transforms.test.ts b/tests/evaluator-transforms.test.ts index 1655a75..5a55846 100644 --- a/tests/evaluator-transforms.test.ts +++ b/tests/evaluator-transforms.test.ts @@ -13,7 +13,12 @@ function run( ) { const ast = parse(expr) const ec = new ExecutionContext(new SecurityPolicy()) - return evaluate(ast, context, transforms, functions, ec) + const mappedFunctions = Object.fromEntries( + Object.entries(functions).map(([key, value]) => { + return [key, {allowCtx:false, f: value}]; + }) + ) + return evaluate(ast, context, transforms, mappedFunctions, ec) } describe('evaluator - functions', () => {