Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .changeset/hook-body-sandbox-globals-allowlist.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
---
"@objectstack/cli": patch
---

fix(cli): stop lowering hook/action bodies that reference globals the sandbox does not provide (#14301)

`detect-free-identifiers`' ambient allowlist was ONE generous list, documented as
"assume the runtime has it". The runtime a lowered body actually runs in is the
QuickJS sandbox, not the Node process that runs `objectstack build` — and the
list named `Intl` beside `JSON`, under the comment "Web-ish that the sandbox /
Node commonly provide". So a handler calling `Intl.DateTimeFormat` had no free
identifier at all: `extractHookBody` lowered it into `body.source`, the #13651
lint rule had nothing to report (it fires only on a *refused* lowering),
`validate` / `typecheck` / `test` / `build` were all green because the
in-process test runs the RAW function in Node where `Intl` exists — and
production threw `ReferenceError: Intl is not defined`. Under the
`onError: 'abort'` a validation-shaped hook must declare, that refuses every
write to the object.

The allowlist is now two sets, and membership is **measured**, never recalled: a
`typeof X`/`'X' in globalThis` probe is evaluated inside the same
`QuickJSScriptRunner` the runtime evaluates a body in, and
`sandbox-globals-probe.test.ts` fails unless each set is exactly the probe's
present/absent partition.

- **`SANDBOX_GLOBALS` (53, measured present)** — the ECMAScript surface:
`Math` `JSON` `Date` `Object` `Array` `String` `Number` `Boolean` `RegExp`
`Map` `Set` `WeakMap` `WeakSet` `Promise` `Symbol` `BigInt` `Function`
`Reflect` `Proxy`, the typed-array/buffer family, the eight error
constructors, `parseInt` `parseFloat` `isNaN` `isFinite` and the four URI
functions, plus `undefined` `NaN` `Infinity` `globalThis`.
- **`NODE_ONLY_GLOBALS` (15, measured absent)** — `Intl`, `structuredClone`,
`queueMicrotask`, `atob`, `btoa`, `setTimeout`, `clearTimeout`,
`setInterval`, `clearInterval`, `URL`, `URLSearchParams`, `TextEncoder`,
`TextDecoder`, `console`, `arguments`.

A free reference to one of the 15 is now a lowering refusal whose reason names
the identifier and the remedy — a string handler ref (`functions:` map plus
`handler: 'fn_name'`) or a validation rule, and for `console` specifically the
capability-gated `ctx.log`. It travels the SAME path #1876 already used: the
refusal is `kind: 'free-identifiers'` with the host-only half carried
separately as `nodeOnlyIdentifiers`, `lowerCallables` catches it and ships the
callable through the `.mjs` bundle (where it runs in-process in Node and
works), `os build` still exits 0 with a warning, and `os lint` reports it under
`hook-body/not-lowerable` as an `error` — the ACCIDENTAL class, because `Intl`
is a standard global in every browser and in Node and writing it is not the
recognisable "I am reaching for the host" act that `fetch(` and `process.` are.
The remedy sentence `os lint` prints is chosen from the refusal's own
classification: "inline the value" is impossible for a host global, and
printing it anyway would send an author after a second broken shape.

⛔ Not changed here: whether `os build` fails on the lowering class (#13838),
and what the sandbox provides (giving it `Intl` would be a capability
expansion). Nothing under `packages/runtime/**` is touched — the probe reads
that sandbox, it does not change it.

**Why `patch`.** No published accept-set moves: the metadata a valid app may
declare is identical, `HookBodySchema` is untouched, and no key is added,
removed or re-shaped. What narrows is which handlers the build LOWERS, and for
every handler affected the previous outcome was a body that could not run. An
app hitting this gains a warning and a working bundled closure in place of a
production `ReferenceError`; the deployment shape it loses was never one it had
in working order. Measured corpus: zero in-repo example or template handlers
reference any of the 15.
48 changes: 48 additions & 0 deletions packages/cli/src/lint/hook-body-lowering.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,19 @@ const forbiddenTokenHook = {
},
};

// [#14301] A global the NODE HOST provides and the sandbox does not. Reads as
// self-contained — `Intl` is standard in every browser and in Node — which is
// precisely why it is the ACCIDENTAL class and not the structural one.
const nodeOnlyGlobalHook = {
name: 'stamp_due_label',
object: 'task',
events: ['beforeInsert'],
handler: (ctx: any) => {
const f = new Intl.DateTimeFormat('en-US', { timeZone: 'UTC' });
ctx.input.due_label = f.format(new Date(ctx.input.due_at));
},
};

const selfContainedHook = {
name: 'normalize_name',
object: 'account',
Expand DownExpand Up@@ -91,6 +104,41 @@ describe('checkHookBodyLowering', () => {
expect(issues[0].message).toContain('deployment shape');
});

describe('a host global the sandbox lacks is the ACCIDENTAL class (#14301)', () => {
it('reports it as an ERROR under the not-lowerable rule', () => {
const issues = checkHookBodyLowering({ hooks: [nodeOnlyGlobalHook] });

expect(issues).toHaveLength(1);
expect(issues[0].severity).toBe('error');
expect(issues[0].rule).toBe(NOT_LOWERABLE_RULE);
expect(issues[0].path).toBe('hooks[0].handler');
expect(issues[0].message).toContain("hook 'stamp_due_label'");
expect(issues[0].message).toContain('Intl');
expect(issues[0].message).toContain('deployment shape');
});

it('prints the remedy that is POSSIBLE, not the inline one', () => {
// The wrong-remedy failure is worse than none: there is nothing to inline
// `Intl` from, so an author (or a code-writing model) told to inline it
// lands on a second broken shape. The remedy sentence is chosen from the
// refusal's own `nodeOnlyIdentifiers`, never re-derived here.
const [issue] = checkHookBodyLowering({ hooks: [nodeOnlyGlobalHook] });
expect(issue.message).toContain('provided by the Node host');
expect(issue.message).toContain('cannot be inlined');
expect(issue.message).toContain('string handler ref');
expect(issue.message).toContain('validation rule');
expect(issue.message).not.toContain('Inline the value(s) into the handler');
});

it('leaves the module-scope remedy exactly as it was', () => {
// The reverse leg: the #13651 sentence must not have been rewritten for
// every author by a branch added for one new sub-case.
const [issue] = checkHookBodyLowering({ hooks: [freeIdentifierHook] });
expect(issue.message).toContain('Inline the value(s) into the handler, or reach them through `ctx`.');
expect(issue.message).not.toContain('provided by the Node host');
});
});

it('keeps the STRUCTURAL refusal a warning — the bundle is its designed answer', () => {
const issues = checkHookBodyLowering({ hooks: [forbiddenTokenHook] });

Expand Down
45 changes: 39 additions & 6 deletions packages/cli/src/lint/hook-body-lowering.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,13 +38,22 @@
* Today both arrive through one catch, so they share one fate. They are not
* the same event, and the refusal kind already tells them apart:
*
* `free-identifiers` ACCIDENTAL. The handler IS expressible as a metadata
* body; it merely names a module-scope const/helper/
* import. The author wrote something that reads
* `free-identifiers` ACCIDENTAL. The author wrote something that reads
* self-contained, and the platform quietly re-shaped the
* deployment behind them — its own message even says "no
* behavior change", which is true of behaviour and false
* of shape. Remedy is local: inline the value. => `error`.
* of shape. => `error`, for both sub-cases:
* · a module-scope const/helper/import — remedy is
* local: inline the value.
* · a global the NODE HOST has and the sandbox does not
* (#14301, `Intl`) — NOT inlinable, and still
* accidental rather than a chosen bundle: `Intl` is a
* standard global in every browser and in Node, so
* writing it is not the recognisable "I am reaching
* for the host" act that `fetch(`/`process.` are.
* That is why it is an `error` here and not the
* `warning` its structural cousin gets — and why the
* remedy sentence below is CHOSEN per sub-case.
* `forbidden-token` STRUCTURAL. `fetch`/`require`/`process`/`eval`/… are
* capabilities the sandbox does not have, so the handler
* can NEVER be a metadata body. Writing one IS choosing a
Expand DownExpand Up@@ -129,12 +138,37 @@ function judge(fn: AnyFn, originLabel: string, path: string): HookBodyLintIssue
} catch (err: unknown) {
const kind = err instanceof HookBodyExtractionError ? err.kind : 'unknown';
const free = err instanceof HookBodyExtractionError ? err.freeIdentifiers : [];
// [#14301] The half of `free` the Node HOST provides and the sandbox does
// not. Read off the refusal rather than re-derived here: this rule's whole
// parity claim is that it cannot disagree with what `os build` did to the
// same handler, and a second membership table in this file would be exactly
// such a disagreement waiting to happen.
const nodeOnly = err instanceof HookBodyExtractionError ? err.nodeOnlyIdentifiers : [];
// Only the first line: the refusal text carries a multi-line offending-source
// dump for `--strict-body`'s per-callable diagnostic, which would swamp a
// lint report. `os build --strict-body` remains the place to read it whole.
const firstLine = String((err as Error)?.message ?? err).split('\n')[0];

if (kind === 'free-identifiers') {
// The remedy sentence is chosen, not fixed. "Inline the value(s)" is
// right for a module-scope helper and IMPOSSIBLE for a host global —
// there is nothing to inline `Intl` from — so printing it for both would
// send an author (or a code-writing model) after a second broken shape.
const moduleScope = free.filter((n) => !nodeOnly.includes(n));
const hostRemedy =
`${nodeOnly.join(', ')} ${nodeOnly.length === 1 ? 'is' : 'are'} provided by the Node host ` +
`and NOT by the sandbox, so ${nodeOnly.length === 1 ? 'it' : 'they'} cannot be inlined: ` +
`keep the check in a string handler ref, or move it to a validation rule.`;
const inlineRemedy =
`Inline the value(s) into the handler, or reach them through \`ctx\`.`;
const remedy =
nodeOnly.length === 0
? inlineRemedy
: moduleScope.length === 0
? hostRemedy
: `${hostRemedy} ${moduleScope.join(', ')} ${moduleScope.length === 1 ? 'is' : 'are'} ` +
`module-scope: inline ${moduleScope.length === 1 ? 'it' : 'them'} into the handler, ` +
`or reach ${moduleScope.length === 1 ? 'it' : 'them'} through \`ctx\`.`;
return {
severity: 'error',
rule: NOT_LOWERABLE_RULE,
Expand All@@ -143,8 +177,7 @@ function judge(fn: AnyFn, originLabel: string, path: string): HookBodyLintIssue
`${free.length === 1 ? 'the identifier' : 'identifiers'} ${free.join(', ')}, which ` +
`${free.length === 1 ? 'is' : 'are'} not in scope inside the sandbox. The handler is ` +
`BUNDLED instead, so this app is no longer shippable as pure metadata — a change of ` +
`deployment shape, not of behaviour. Inline the value(s) into the handler, or reach ` +
`them through \`ctx\`. ${DELIBERATE_BUNDLE_REMEDY}`,
`deployment shape, not of behaviour. ${remedy} ${DELIBERATE_BUNDLE_REMEDY}`,
path,
};
}
Expand Down
89 changes: 88 additions & 1 deletion packages/cli/src/utils/detect-free-identifiers.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { detectFreeIdentifiers } from './detect-free-identifiers.js';
import {
detectFreeIdentifiers,
NODE_ONLY_GLOBALS,
SANDBOX_GLOBALS,
} from './detect-free-identifiers.js';

/** Helper: stringify a real function so we test the exact `.toString()` path. */
const src = (fn: (...a: any[]) => any) => String(fn);
Expand DownExpand Up@@ -53,6 +57,14 @@ describe('detectFreeIdentifiers (#1876 — body self-containment)', () => {
['nested destructuring', '({ a: { b } }) => b + 1'],
['rest params', '(...args) => args.length'],
['typeof a local', '(ctx) => { const v = ctx.v; return typeof v; }'],
// #14301 — the node-only refusal is a SCOPE analysis, not a token scan.
// A locally-bound name that happens to spell a host global is bound, and
// a member NAMED after one is not a reference at all. Both would be
// false refusals, and a false refusal here costs an author a body they
// were entitled to.
['a local shadowing a host global', '(ctx) => { const Intl = ctx.fmt; return Intl.format(ctx.d); }'],
['a member named after a host global', '(ctx) => { return ctx.Intl.format(ctx.d); }'],
['a param named after a host global', '(ctx, Intl) => Intl.format(ctx.d)'],
];
for (const [label, source] of selfContained) {
it(label, () => {
Expand DownExpand Up@@ -81,4 +93,79 @@ describe('detectFreeIdentifiers (#1876 — body self-containment)', () => {
expect(detectFreeIdentifiers('').free).toEqual([]);
expect(detectFreeIdentifiers('{ not: valid').free).toEqual([]);
});
// ── #14301 — globals the NODE HOST has and the sandbox does not ──────────
//
// The reported shape: `Intl` sat in one generous allowlist beside `JSON`, so
// a handler calling `Intl.DateTimeFormat` had no free identifier, lowered
// into `body.source`, and threw `ReferenceError` in production while every
// local gate was green. The split makes the reference visible again; the
// membership of the two sets is not asserted here from knowledge — it is
// measured inside the real sandbox by `sandbox-globals-probe.test.ts`.
describe('reports host globals the sandbox does not provide', () => {
it('the card reproduction — Intl.DateTimeFormat', () => {
const r = detectFreeIdentifiers(
"(ctx) => { const f = new Intl.DateTimeFormat('en-US'); ctx.input.label = f.format(new Date(ctx.input.at)); }",
);
expect(r.unparsed).toBe(false);
expect(r.free).toEqual(['Intl']);
expect(r.nodeOnly).toEqual(['Intl']);
});

it('`nodeOnly` is a labelled SUBSET of `free`, not a second list', () => {
// Both halves at once. The caller needs them apart because the remedies
// are opposite — inline the helper, but a host global cannot be inlined
// — and needs them together because the refusal names every name.
const r = detectFreeIdentifiers('(ctx) => { ctx.x = slugify(ctx.name) + Intl.NumberFormat; }');
expect(r.free).toEqual(['Intl', 'slugify']);
expect(r.nodeOnly).toEqual(['Intl']);
expect(r.nodeOnly.every((n) => r.free.includes(n))).toBe(true);
});

it('a sandbox-provided global is still waived — the positive control', () => {
const r = detectFreeIdentifiers('(ctx) => { ctx.x = JSON.stringify(Math.round(ctx.n)); }');
expect(r.free).toEqual([]);
expect(r.nodeOnly).toEqual([]);
});

it('every member of NODE_ONLY_GLOBALS is reported when referenced free', () => {
// Set-wide rather than per-name: a member added to the set without the
// detector reading it would otherwise sit inert, which is the exact
// shape of the defect being closed one level up.
for (const name of NODE_ONLY_GLOBALS) {
const r = detectFreeIdentifiers(`(ctx) => { ctx.x = ${name}; }`);
expect({ name, free: r.free, nodeOnly: r.nodeOnly }).toEqual({
name,
free: [name],
nodeOnly: [name],
});
}
});

it('every member of SANDBOX_GLOBALS is waived when referenced free', () => {
for (const name of SANDBOX_GLOBALS) {
const r = detectFreeIdentifiers(`(ctx) => { ctx.x = ${name}; }`);
expect({ name, free: r.free, nodeOnly: r.nodeOnly }).toEqual({
name,
free: [],
nodeOnly: [],
});
}
});

it('junk input reports neither list (conservative — never blocks extraction)', () => {
// The bias the file's header states, restated over the NEW field: a
// source this analysis cannot make sense of must not produce a refusal.
// Asserted over the same three junk shapes the #1876 case uses, and
// without asserting `unparsed` — TS error-recovery decides that, and the
// invariant that matters is that neither list fills.
for (const junk of ['this is not a function', '', '{ not: valid']) {
const r = detectFreeIdentifiers(junk);
expect({ junk, free: r.free, nodeOnly: r.nodeOnly }).toEqual({
junk,
free: [],
nodeOnly: [],
});
}
});
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .changeset/hook-body-sandbox-globals-allowlist.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
---
"@objectstack/cli": patch
---

fix(cli): stop lowering hook/action bodies that reference globals the sandbox does not provide (#14301)

`detect-free-identifiers`' ambient allowlist was ONE generous list, documented as
"assume the runtime has it". The runtime a lowered body actually runs in is the
QuickJS sandbox, not the Node process that runs `objectstack build` — and the
list named `Intl` beside `JSON`, under the comment "Web-ish that the sandbox /
Node commonly provide". So a handler calling `Intl.DateTimeFormat` had no free
identifier at all: `extractHookBody` lowered it into `body.source`, the #13651
lint rule had nothing to report (it fires only on a *refused* lowering),
`validate` / `typecheck` / `test` / `build` were all green because the
in-process test runs the RAW function in Node where `Intl` exists — and
production threw `ReferenceError: Intl is not defined`. Under the
`onError: 'abort'` a validation-shaped hook must declare, that refuses every
write to the object.

The allowlist is now two sets, and membership is **measured**, never recalled: a
`typeof X`/`'X' in globalThis` probe is evaluated inside the same
`QuickJSScriptRunner` the runtime evaluates a body in, and
`sandbox-globals-probe.test.ts` fails unless each set is exactly the probe's
present/absent partition.

- **`SANDBOX_GLOBALS` (53, measured present)** — the ECMAScript surface:
`Math` `JSON` `Date` `Object` `Array` `String` `Number` `Boolean` `RegExp`
`Map` `Set` `WeakMap` `WeakSet` `Promise` `Symbol` `BigInt` `Function`
`Reflect` `Proxy`, the typed-array/buffer family, the eight error
constructors, `parseInt` `parseFloat` `isNaN` `isFinite` and the four URI
functions, plus `undefined` `NaN` `Infinity` `globalThis`.
- **`NODE_ONLY_GLOBALS` (15, measured absent)** — `Intl`, `structuredClone`,
`queueMicrotask`, `atob`, `btoa`, `setTimeout`, `clearTimeout`,
`setInterval`, `clearInterval`, `URL`, `URLSearchParams`, `TextEncoder`,
`TextDecoder`, `console`, `arguments`.

A free reference to one of the 15 is now a lowering refusal whose reason names
the identifier and the remedy — a string handler ref (`functions:` map plus
`handler: 'fn_name'`) or a validation rule, and for `console` specifically the
capability-gated `ctx.log`. It travels the SAME path #1876 already used: the
refusal is `kind: 'free-identifiers'` with the host-only half carried
separately as `nodeOnlyIdentifiers`, `lowerCallables` catches it and ships the
callable through the `.mjs` bundle (where it runs in-process in Node and
works), `os build` still exits 0 with a warning, and `os lint` reports it under
`hook-body/not-lowerable` as an `error` — the ACCIDENTAL class, because `Intl`
is a standard global in every browser and in Node and writing it is not the
recognisable "I am reaching for the host" act that `fetch(` and `process.` are.
The remedy sentence `os lint` prints is chosen from the refusal's own
classification: "inline the value" is impossible for a host global, and
printing it anyway would send an author after a second broken shape.

⛔ Not changed here: whether `os build` fails on the lowering class (#13838),
and what the sandbox provides (giving it `Intl` would be a capability
expansion). Nothing under `packages/runtime/**` is touched — the probe reads
that sandbox, it does not change it.

**Why `patch`.** No published accept-set moves: the metadata a valid app may
declare is identical, `HookBodySchema` is untouched, and no key is added,
removed or re-shaped. What narrows is which handlers the build LOWERS, and for
every handler affected the previous outcome was a body that could not run. An
app hitting this gains a warning and a working bundled closure in place of a
production `ReferenceError`; the deployment shape it loses was never one it had
in working order. Measured corpus: zero in-repo example or template handlers
reference any of the 15.
48 changes: 48 additions & 0 deletions packages/cli/src/lint/hook-body-lowering.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,19 @@ const forbiddenTokenHook = {
},
};

// [#14301] A global the NODE HOST provides and the sandbox does not. Reads as
// self-contained — `Intl` is standard in every browser and in Node — which is
// precisely why it is the ACCIDENTAL class and not the structural one.
const nodeOnlyGlobalHook = {
name: 'stamp_due_label',
object: 'task',
events: ['beforeInsert'],
handler: (ctx: any) => {
const f = new Intl.DateTimeFormat('en-US', { timeZone: 'UTC' });
ctx.input.due_label = f.format(new Date(ctx.input.due_at));
},
};

const selfContainedHook = {
name: 'normalize_name',
object: 'account',
Expand DownExpand Up@@ -91,6 +104,41 @@ describe('checkHookBodyLowering', () => {
expect(issues[0].message).toContain('deployment shape');
});

describe('a host global the sandbox lacks is the ACCIDENTAL class (#14301)', () => {
it('reports it as an ERROR under the not-lowerable rule', () => {
const issues = checkHookBodyLowering({ hooks: [nodeOnlyGlobalHook] });

expect(issues).toHaveLength(1);
expect(issues[0].severity).toBe('error');
expect(issues[0].rule).toBe(NOT_LOWERABLE_RULE);
expect(issues[0].path).toBe('hooks[0].handler');
expect(issues[0].message).toContain("hook 'stamp_due_label'");
expect(issues[0].message).toContain('Intl');
expect(issues[0].message).toContain('deployment shape');
});

it('prints the remedy that is POSSIBLE, not the inline one', () => {
// The wrong-remedy failure is worse than none: there is nothing to inline
// `Intl` from, so an author (or a code-writing model) told to inline it
// lands on a second broken shape. The remedy sentence is chosen from the
// refusal's own `nodeOnlyIdentifiers`, never re-derived here.
const [issue] = checkHookBodyLowering({ hooks: [nodeOnlyGlobalHook] });
expect(issue.message).toContain('provided by the Node host');
expect(issue.message).toContain('cannot be inlined');
expect(issue.message).toContain('string handler ref');
expect(issue.message).toContain('validation rule');
expect(issue.message).not.toContain('Inline the value(s) into the handler');
});

it('leaves the module-scope remedy exactly as it was', () => {
// The reverse leg: the #13651 sentence must not have been rewritten for
// every author by a branch added for one new sub-case.
const [issue] = checkHookBodyLowering({ hooks: [freeIdentifierHook] });
expect(issue.message).toContain('Inline the value(s) into the handler, or reach them through `ctx`.');
expect(issue.message).not.toContain('provided by the Node host');
});
});

it('keeps the STRUCTURAL refusal a warning — the bundle is its designed answer', () => {
const issues = checkHookBodyLowering({ hooks: [forbiddenTokenHook] });

Expand Down
45 changes: 39 additions & 6 deletions packages/cli/src/lint/hook-body-lowering.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,13 +38,22 @@
* Today both arrive through one catch, so they share one fate. They are not
* the same event, and the refusal kind already tells them apart:
*
* `free-identifiers` ACCIDENTAL. The handler IS expressible as a metadata
* body; it merely names a module-scope const/helper/
* import. The author wrote something that reads
* `free-identifiers` ACCIDENTAL. The author wrote something that reads
* self-contained, and the platform quietly re-shaped the
* deployment behind them — its own message even says "no
* behavior change", which is true of behaviour and false
* of shape. Remedy is local: inline the value. => `error`.
* of shape. => `error`, for both sub-cases:
* · a module-scope const/helper/import — remedy is
* local: inline the value.
* · a global the NODE HOST has and the sandbox does not
* (#14301, `Intl`) — NOT inlinable, and still
* accidental rather than a chosen bundle: `Intl` is a
* standard global in every browser and in Node, so
* writing it is not the recognisable "I am reaching
* for the host" act that `fetch(`/`process.` are.
* That is why it is an `error` here and not the
* `warning` its structural cousin gets — and why the
* remedy sentence below is CHOSEN per sub-case.
* `forbidden-token` STRUCTURAL. `fetch`/`require`/`process`/`eval`/… are
* capabilities the sandbox does not have, so the handler
* can NEVER be a metadata body. Writing one IS choosing a
Expand DownExpand Up@@ -129,12 +138,37 @@ function judge(fn: AnyFn, originLabel: string, path: string): HookBodyLintIssue
} catch (err: unknown) {
const kind = err instanceof HookBodyExtractionError ? err.kind : 'unknown';
const free = err instanceof HookBodyExtractionError ? err.freeIdentifiers : [];
// [#14301] The half of `free` the Node HOST provides and the sandbox does
// not. Read off the refusal rather than re-derived here: this rule's whole
// parity claim is that it cannot disagree with what `os build` did to the
// same handler, and a second membership table in this file would be exactly
// such a disagreement waiting to happen.
const nodeOnly = err instanceof HookBodyExtractionError ? err.nodeOnlyIdentifiers : [];
// Only the first line: the refusal text carries a multi-line offending-source
// dump for `--strict-body`'s per-callable diagnostic, which would swamp a
// lint report. `os build --strict-body` remains the place to read it whole.
const firstLine = String((err as Error)?.message ?? err).split('\n')[0];

if (kind === 'free-identifiers') {
// The remedy sentence is chosen, not fixed. "Inline the value(s)" is
// right for a module-scope helper and IMPOSSIBLE for a host global —
// there is nothing to inline `Intl` from — so printing it for both would
// send an author (or a code-writing model) after a second broken shape.
const moduleScope = free.filter((n) => !nodeOnly.includes(n));
const hostRemedy =
`${nodeOnly.join(', ')} ${nodeOnly.length === 1 ? 'is' : 'are'} provided by the Node host ` +
`and NOT by the sandbox, so ${nodeOnly.length === 1 ? 'it' : 'they'} cannot be inlined: ` +
`keep the check in a string handler ref, or move it to a validation rule.`;
const inlineRemedy =
`Inline the value(s) into the handler, or reach them through \`ctx\`.`;
const remedy =
nodeOnly.length === 0
? inlineRemedy
: moduleScope.length === 0
? hostRemedy
: `${hostRemedy} ${moduleScope.join(', ')} ${moduleScope.length === 1 ? 'is' : 'are'} ` +
`module-scope: inline ${moduleScope.length === 1 ? 'it' : 'them'} into the handler, ` +
`or reach ${moduleScope.length === 1 ? 'it' : 'them'} through \`ctx\`.`;
return {
severity: 'error',
rule: NOT_LOWERABLE_RULE,
Expand All@@ -143,8 +177,7 @@ function judge(fn: AnyFn, originLabel: string, path: string): HookBodyLintIssue
`${free.length === 1 ? 'the identifier' : 'identifiers'} ${free.join(', ')}, which ` +
`${free.length === 1 ? 'is' : 'are'} not in scope inside the sandbox. The handler is ` +
`BUNDLED instead, so this app is no longer shippable as pure metadata — a change of ` +
`deployment shape, not of behaviour. Inline the value(s) into the handler, or reach ` +
`them through \`ctx\`. ${DELIBERATE_BUNDLE_REMEDY}`,
`deployment shape, not of behaviour. ${remedy} ${DELIBERATE_BUNDLE_REMEDY}`,
path,
};
}
Expand Down
89 changes: 88 additions & 1 deletion packages/cli/src/utils/detect-free-identifiers.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { detectFreeIdentifiers } from './detect-free-identifiers.js';
import {
detectFreeIdentifiers,
NODE_ONLY_GLOBALS,
SANDBOX_GLOBALS,
} from './detect-free-identifiers.js';

/** Helper: stringify a real function so we test the exact `.toString()` path. */
const src = (fn: (...a: any[]) => any) => String(fn);
Expand DownExpand Up@@ -53,6 +57,14 @@ describe('detectFreeIdentifiers (#1876 — body self-containment)', () => {
['nested destructuring', '({ a: { b } }) => b + 1'],
['rest params', '(...args) => args.length'],
['typeof a local', '(ctx) => { const v = ctx.v; return typeof v; }'],
// #14301 — the node-only refusal is a SCOPE analysis, not a token scan.
// A locally-bound name that happens to spell a host global is bound, and
// a member NAMED after one is not a reference at all. Both would be
// false refusals, and a false refusal here costs an author a body they
// were entitled to.
['a local shadowing a host global', '(ctx) => { const Intl = ctx.fmt; return Intl.format(ctx.d); }'],
['a member named after a host global', '(ctx) => { return ctx.Intl.format(ctx.d); }'],
['a param named after a host global', '(ctx, Intl) => Intl.format(ctx.d)'],
];
for (const [label, source] of selfContained) {
it(label, () => {
Expand DownExpand Up@@ -81,4 +93,79 @@ describe('detectFreeIdentifiers (#1876 — body self-containment)', () => {
expect(detectFreeIdentifiers('').free).toEqual([]);
expect(detectFreeIdentifiers('{ not: valid').free).toEqual([]);
});
// ── #14301 — globals the NODE HOST has and the sandbox does not ──────────
//
// The reported shape: `Intl` sat in one generous allowlist beside `JSON`, so
// a handler calling `Intl.DateTimeFormat` had no free identifier, lowered
// into `body.source`, and threw `ReferenceError` in production while every
// local gate was green. The split makes the reference visible again; the
// membership of the two sets is not asserted here from knowledge — it is
// measured inside the real sandbox by `sandbox-globals-probe.test.ts`.
describe('reports host globals the sandbox does not provide', () => {
it('the card reproduction — Intl.DateTimeFormat', () => {
const r = detectFreeIdentifiers(
"(ctx) => { const f = new Intl.DateTimeFormat('en-US'); ctx.input.label = f.format(new Date(ctx.input.at)); }",
);
expect(r.unparsed).toBe(false);
expect(r.free).toEqual(['Intl']);
expect(r.nodeOnly).toEqual(['Intl']);
});

it('`nodeOnly` is a labelled SUBSET of `free`, not a second list', () => {
// Both halves at once. The caller needs them apart because the remedies
// are opposite — inline the helper, but a host global cannot be inlined
// — and needs them together because the refusal names every name.
const r = detectFreeIdentifiers('(ctx) => { ctx.x = slugify(ctx.name) + Intl.NumberFormat; }');
expect(r.free).toEqual(['Intl', 'slugify']);
expect(r.nodeOnly).toEqual(['Intl']);
expect(r.nodeOnly.every((n) => r.free.includes(n))).toBe(true);
});

it('a sandbox-provided global is still waived — the positive control', () => {
const r = detectFreeIdentifiers('(ctx) => { ctx.x = JSON.stringify(Math.round(ctx.n)); }');
expect(r.free).toEqual([]);
expect(r.nodeOnly).toEqual([]);
});

it('every member of NODE_ONLY_GLOBALS is reported when referenced free', () => {
// Set-wide rather than per-name: a member added to the set without the
// detector reading it would otherwise sit inert, which is the exact
// shape of the defect being closed one level up.
for (const name of NODE_ONLY_GLOBALS) {
const r = detectFreeIdentifiers(`(ctx) => { ctx.x = ${name}; }`);
expect({ name, free: r.free, nodeOnly: r.nodeOnly }).toEqual({
name,
free: [name],
nodeOnly: [name],
});
}
});

it('every member of SANDBOX_GLOBALS is waived when referenced free', () => {
for (const name of SANDBOX_GLOBALS) {
const r = detectFreeIdentifiers(`(ctx) => { ctx.x = ${name}; }`);
expect({ name, free: r.free, nodeOnly: r.nodeOnly }).toEqual({
name,
free: [],
nodeOnly: [],
});
}
});

it('junk input reports neither list (conservative — never blocks extraction)', () => {
// The bias the file's header states, restated over the NEW field: a
// source this analysis cannot make sense of must not produce a refusal.
// Asserted over the same three junk shapes the #1876 case uses, and
// without asserting `unparsed` — TS error-recovery decides that, and the
// invariant that matters is that neither list fills.
for (const junk of ['this is not a function', '', '{ not: valid']) {
const r = detectFreeIdentifiers(junk);
expect({ junk, free: r.free, nodeOnly: r.nodeOnly }).toEqual({
junk,
free: [],
nodeOnly: [],
});
}
});
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .changeset/hook-body-sandbox-globals-allowlist.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
---
"@objectstack/cli": patch
---

fix(cli): stop lowering hook/action bodies that reference globals the sandbox does not provide (#14301)

`detect-free-identifiers`' ambient allowlist was ONE generous list, documented as
"assume the runtime has it". The runtime a lowered body actually runs in is the
QuickJS sandbox, not the Node process that runs `objectstack build` — and the
list named `Intl` beside `JSON`, under the comment "Web-ish that the sandbox /
Node commonly provide". So a handler calling `Intl.DateTimeFormat` had no free
identifier at all: `extractHookBody` lowered it into `body.source`, the #13651
lint rule had nothing to report (it fires only on a *refused* lowering),
`validate` / `typecheck` / `test` / `build` were all green because the
in-process test runs the RAW function in Node where `Intl` exists — and
production threw `ReferenceError: Intl is not defined`. Under the
`onError: 'abort'` a validation-shaped hook must declare, that refuses every
write to the object.

The allowlist is now two sets, and membership is **measured**, never recalled: a
`typeof X`/`'X' in globalThis` probe is evaluated inside the same
`QuickJSScriptRunner` the runtime evaluates a body in, and
`sandbox-globals-probe.test.ts` fails unless each set is exactly the probe's
present/absent partition.

- **`SANDBOX_GLOBALS` (53, measured present)** — the ECMAScript surface:
`Math` `JSON` `Date` `Object` `Array` `String` `Number` `Boolean` `RegExp`
`Map` `Set` `WeakMap` `WeakSet` `Promise` `Symbol` `BigInt` `Function`
`Reflect` `Proxy`, the typed-array/buffer family, the eight error
constructors, `parseInt` `parseFloat` `isNaN` `isFinite` and the four URI
functions, plus `undefined` `NaN` `Infinity` `globalThis`.
- **`NODE_ONLY_GLOBALS` (15, measured absent)** — `Intl`, `structuredClone`,
`queueMicrotask`, `atob`, `btoa`, `setTimeout`, `clearTimeout`,
`setInterval`, `clearInterval`, `URL`, `URLSearchParams`, `TextEncoder`,
`TextDecoder`, `console`, `arguments`.

A free reference to one of the 15 is now a lowering refusal whose reason names
the identifier and the remedy — a string handler ref (`functions:` map plus
`handler: 'fn_name'`) or a validation rule, and for `console` specifically the
capability-gated `ctx.log`. It travels the SAME path #1876 already used: the
refusal is `kind: 'free-identifiers'` with the host-only half carried
separately as `nodeOnlyIdentifiers`, `lowerCallables` catches it and ships the
callable through the `.mjs` bundle (where it runs in-process in Node and
works), `os build` still exits 0 with a warning, and `os lint` reports it under
`hook-body/not-lowerable` as an `error` — the ACCIDENTAL class, because `Intl`
is a standard global in every browser and in Node and writing it is not the
recognisable "I am reaching for the host" act that `fetch(` and `process.` are.
The remedy sentence `os lint` prints is chosen from the refusal's own
classification: "inline the value" is impossible for a host global, and
printing it anyway would send an author after a second broken shape.

⛔ Not changed here: whether `os build` fails on the lowering class (#13838),
and what the sandbox provides (giving it `Intl` would be a capability
expansion). Nothing under `packages/runtime/**` is touched — the probe reads
that sandbox, it does not change it.

**Why `patch`.** No published accept-set moves: the metadata a valid app may
declare is identical, `HookBodySchema` is untouched, and no key is added,
removed or re-shaped. What narrows is which handlers the build LOWERS, and for
every handler affected the previous outcome was a body that could not run. An
app hitting this gains a warning and a working bundled closure in place of a
production `ReferenceError`; the deployment shape it loses was never one it had
in working order. Measured corpus: zero in-repo example or template handlers
reference any of the 15.
48 changes: 48 additions & 0 deletions packages/cli/src/lint/hook-body-lowering.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,19 @@ const forbiddenTokenHook = {
},
};

// [#14301] A global the NODE HOST provides and the sandbox does not. Reads as
// self-contained — `Intl` is standard in every browser and in Node — which is
// precisely why it is the ACCIDENTAL class and not the structural one.
const nodeOnlyGlobalHook = {
name: 'stamp_due_label',
object: 'task',
events: ['beforeInsert'],
handler: (ctx: any) => {
const f = new Intl.DateTimeFormat('en-US', { timeZone: 'UTC' });
ctx.input.due_label = f.format(new Date(ctx.input.due_at));
},
};

const selfContainedHook = {
name: 'normalize_name',
object: 'account',
Expand DownExpand Up@@ -91,6 +104,41 @@ describe('checkHookBodyLowering', () => {
expect(issues[0].message).toContain('deployment shape');
});

describe('a host global the sandbox lacks is the ACCIDENTAL class (#14301)', () => {
it('reports it as an ERROR under the not-lowerable rule', () => {
const issues = checkHookBodyLowering({ hooks: [nodeOnlyGlobalHook] });

expect(issues).toHaveLength(1);
expect(issues[0].severity).toBe('error');
expect(issues[0].rule).toBe(NOT_LOWERABLE_RULE);
expect(issues[0].path).toBe('hooks[0].handler');
expect(issues[0].message).toContain("hook 'stamp_due_label'");
expect(issues[0].message).toContain('Intl');
expect(issues[0].message).toContain('deployment shape');
});

it('prints the remedy that is POSSIBLE, not the inline one', () => {
// The wrong-remedy failure is worse than none: there is nothing to inline
// `Intl` from, so an author (or a code-writing model) told to inline it
// lands on a second broken shape. The remedy sentence is chosen from the
// refusal's own `nodeOnlyIdentifiers`, never re-derived here.
const [issue] = checkHookBodyLowering({ hooks: [nodeOnlyGlobalHook] });
expect(issue.message).toContain('provided by the Node host');
expect(issue.message).toContain('cannot be inlined');
expect(issue.message).toContain('string handler ref');
expect(issue.message).toContain('validation rule');
expect(issue.message).not.toContain('Inline the value(s) into the handler');
});

it('leaves the module-scope remedy exactly as it was', () => {
// The reverse leg: the #13651 sentence must not have been rewritten for
// every author by a branch added for one new sub-case.
const [issue] = checkHookBodyLowering({ hooks: [freeIdentifierHook] });
expect(issue.message).toContain('Inline the value(s) into the handler, or reach them through `ctx`.');
expect(issue.message).not.toContain('provided by the Node host');
});
});

it('keeps the STRUCTURAL refusal a warning — the bundle is its designed answer', () => {
const issues = checkHookBodyLowering({ hooks: [forbiddenTokenHook] });

Expand Down
45 changes: 39 additions & 6 deletions packages/cli/src/lint/hook-body-lowering.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,13 +38,22 @@
* Today both arrive through one catch, so they share one fate. They are not
* the same event, and the refusal kind already tells them apart:
*
* `free-identifiers` ACCIDENTAL. The handler IS expressible as a metadata
* body; it merely names a module-scope const/helper/
* import. The author wrote something that reads
* `free-identifiers` ACCIDENTAL. The author wrote something that reads
* self-contained, and the platform quietly re-shaped the
* deployment behind them — its own message even says "no
* behavior change", which is true of behaviour and false
* of shape. Remedy is local: inline the value. => `error`.
* of shape. => `error`, for both sub-cases:
* · a module-scope const/helper/import — remedy is
* local: inline the value.
* · a global the NODE HOST has and the sandbox does not
* (#14301, `Intl`) — NOT inlinable, and still
* accidental rather than a chosen bundle: `Intl` is a
* standard global in every browser and in Node, so
* writing it is not the recognisable "I am reaching
* for the host" act that `fetch(`/`process.` are.
* That is why it is an `error` here and not the
* `warning` its structural cousin gets — and why the
* remedy sentence below is CHOSEN per sub-case.
* `forbidden-token` STRUCTURAL. `fetch`/`require`/`process`/`eval`/… are
* capabilities the sandbox does not have, so the handler
* can NEVER be a metadata body. Writing one IS choosing a
Expand DownExpand Up@@ -129,12 +138,37 @@ function judge(fn: AnyFn, originLabel: string, path: string): HookBodyLintIssue
} catch (err: unknown) {
const kind = err instanceof HookBodyExtractionError ? err.kind : 'unknown';
const free = err instanceof HookBodyExtractionError ? err.freeIdentifiers : [];
// [#14301] The half of `free` the Node HOST provides and the sandbox does
// not. Read off the refusal rather than re-derived here: this rule's whole
// parity claim is that it cannot disagree with what `os build` did to the
// same handler, and a second membership table in this file would be exactly
// such a disagreement waiting to happen.
const nodeOnly = err instanceof HookBodyExtractionError ? err.nodeOnlyIdentifiers : [];
// Only the first line: the refusal text carries a multi-line offending-source
// dump for `--strict-body`'s per-callable diagnostic, which would swamp a
// lint report. `os build --strict-body` remains the place to read it whole.
const firstLine = String((err as Error)?.message ?? err).split('\n')[0];

if (kind === 'free-identifiers') {
// The remedy sentence is chosen, not fixed. "Inline the value(s)" is
// right for a module-scope helper and IMPOSSIBLE for a host global —
// there is nothing to inline `Intl` from — so printing it for both would
// send an author (or a code-writing model) after a second broken shape.
const moduleScope = free.filter((n) => !nodeOnly.includes(n));
const hostRemedy =
`${nodeOnly.join(', ')} ${nodeOnly.length === 1 ? 'is' : 'are'} provided by the Node host ` +
`and NOT by the sandbox, so ${nodeOnly.length === 1 ? 'it' : 'they'} cannot be inlined: ` +
`keep the check in a string handler ref, or move it to a validation rule.`;
const inlineRemedy =
`Inline the value(s) into the handler, or reach them through \`ctx\`.`;
const remedy =
nodeOnly.length === 0
? inlineRemedy
: moduleScope.length === 0
? hostRemedy
: `${hostRemedy} ${moduleScope.join(', ')} ${moduleScope.length === 1 ? 'is' : 'are'} ` +
`module-scope: inline ${moduleScope.length === 1 ? 'it' : 'them'} into the handler, ` +
`or reach ${moduleScope.length === 1 ? 'it' : 'them'} through \`ctx\`.`;
return {
severity: 'error',
rule: NOT_LOWERABLE_RULE,
Expand All@@ -143,8 +177,7 @@ function judge(fn: AnyFn, originLabel: string, path: string): HookBodyLintIssue
`${free.length === 1 ? 'the identifier' : 'identifiers'} ${free.join(', ')}, which ` +
`${free.length === 1 ? 'is' : 'are'} not in scope inside the sandbox. The handler is ` +
`BUNDLED instead, so this app is no longer shippable as pure metadata — a change of ` +
`deployment shape, not of behaviour. Inline the value(s) into the handler, or reach ` +
`them through \`ctx\`. ${DELIBERATE_BUNDLE_REMEDY}`,
`deployment shape, not of behaviour. ${remedy} ${DELIBERATE_BUNDLE_REMEDY}`,
path,
};
}
Expand Down
89 changes: 88 additions & 1 deletion packages/cli/src/utils/detect-free-identifiers.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { detectFreeIdentifiers } from './detect-free-identifiers.js';
import {
detectFreeIdentifiers,
NODE_ONLY_GLOBALS,
SANDBOX_GLOBALS,
} from './detect-free-identifiers.js';

/** Helper: stringify a real function so we test the exact `.toString()` path. */
const src = (fn: (...a: any[]) => any) => String(fn);
Expand DownExpand Up@@ -53,6 +57,14 @@ describe('detectFreeIdentifiers (#1876 — body self-containment)', () => {
['nested destructuring', '({ a: { b } }) => b + 1'],
['rest params', '(...args) => args.length'],
['typeof a local', '(ctx) => { const v = ctx.v; return typeof v; }'],
// #14301 — the node-only refusal is a SCOPE analysis, not a token scan.
// A locally-bound name that happens to spell a host global is bound, and
// a member NAMED after one is not a reference at all. Both would be
// false refusals, and a false refusal here costs an author a body they
// were entitled to.
['a local shadowing a host global', '(ctx) => { const Intl = ctx.fmt; return Intl.format(ctx.d); }'],
['a member named after a host global', '(ctx) => { return ctx.Intl.format(ctx.d); }'],
['a param named after a host global', '(ctx, Intl) => Intl.format(ctx.d)'],
];
for (const [label, source] of selfContained) {
it(label, () => {
Expand DownExpand Up@@ -81,4 +93,79 @@ describe('detectFreeIdentifiers (#1876 — body self-containment)', () => {
expect(detectFreeIdentifiers('').free).toEqual([]);
expect(detectFreeIdentifiers('{ not: valid').free).toEqual([]);
});
// ── #14301 — globals the NODE HOST has and the sandbox does not ──────────
//
// The reported shape: `Intl` sat in one generous allowlist beside `JSON`, so
// a handler calling `Intl.DateTimeFormat` had no free identifier, lowered
// into `body.source`, and threw `ReferenceError` in production while every
// local gate was green. The split makes the reference visible again; the
// membership of the two sets is not asserted here from knowledge — it is
// measured inside the real sandbox by `sandbox-globals-probe.test.ts`.
describe('reports host globals the sandbox does not provide', () => {
it('the card reproduction — Intl.DateTimeFormat', () => {
const r = detectFreeIdentifiers(
"(ctx) => { const f = new Intl.DateTimeFormat('en-US'); ctx.input.label = f.format(new Date(ctx.input.at)); }",
);
expect(r.unparsed).toBe(false);
expect(r.free).toEqual(['Intl']);
expect(r.nodeOnly).toEqual(['Intl']);
});

it('`nodeOnly` is a labelled SUBSET of `free`, not a second list', () => {
// Both halves at once. The caller needs them apart because the remedies
// are opposite — inline the helper, but a host global cannot be inlined
// — and needs them together because the refusal names every name.
const r = detectFreeIdentifiers('(ctx) => { ctx.x = slugify(ctx.name) + Intl.NumberFormat; }');
expect(r.free).toEqual(['Intl', 'slugify']);
expect(r.nodeOnly).toEqual(['Intl']);
expect(r.nodeOnly.every((n) => r.free.includes(n))).toBe(true);
});

it('a sandbox-provided global is still waived — the positive control', () => {
const r = detectFreeIdentifiers('(ctx) => { ctx.x = JSON.stringify(Math.round(ctx.n)); }');
expect(r.free).toEqual([]);
expect(r.nodeOnly).toEqual([]);
});

it('every member of NODE_ONLY_GLOBALS is reported when referenced free', () => {
// Set-wide rather than per-name: a member added to the set without the
// detector reading it would otherwise sit inert, which is the exact
// shape of the defect being closed one level up.
for (const name of NODE_ONLY_GLOBALS) {
const r = detectFreeIdentifiers(`(ctx) => { ctx.x = ${name}; }`);
expect({ name, free: r.free, nodeOnly: r.nodeOnly }).toEqual({
name,
free: [name],
nodeOnly: [name],
});
}
});

it('every member of SANDBOX_GLOBALS is waived when referenced free', () => {
for (const name of SANDBOX_GLOBALS) {
const r = detectFreeIdentifiers(`(ctx) => { ctx.x = ${name}; }`);
expect({ name, free: r.free, nodeOnly: r.nodeOnly }).toEqual({
name,
free: [],
nodeOnly: [],
});
}
});

it('junk input reports neither list (conservative — never blocks extraction)', () => {
// The bias the file's header states, restated over the NEW field: a
// source this analysis cannot make sense of must not produce a refusal.
// Asserted over the same three junk shapes the #1876 case uses, and
// without asserting `unparsed` — TS error-recovery decides that, and the
// invariant that matters is that neither list fills.
for (const junk of ['this is not a function', '', '{ not: valid']) {
const r = detectFreeIdentifiers(junk);
expect({ junk, free: r.free, nodeOnly: r.nodeOnly }).toEqual({
junk,
free: [],
nodeOnly: [],
});
}
});
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .changeset/hook-body-sandbox-globals-allowlist.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
---
"@objectstack/cli": patch
---

fix(cli): stop lowering hook/action bodies that reference globals the sandbox does not provide (#14301)

`detect-free-identifiers`' ambient allowlist was ONE generous list, documented as
"assume the runtime has it". The runtime a lowered body actually runs in is the
QuickJS sandbox, not the Node process that runs `objectstack build` — and the
list named `Intl` beside `JSON`, under the comment "Web-ish that the sandbox /
Node commonly provide". So a handler calling `Intl.DateTimeFormat` had no free
identifier at all: `extractHookBody` lowered it into `body.source`, the #13651
lint rule had nothing to report (it fires only on a *refused* lowering),
`validate` / `typecheck` / `test` / `build` were all green because the
in-process test runs the RAW function in Node where `Intl` exists — and
production threw `ReferenceError: Intl is not defined`. Under the
`onError: 'abort'` a validation-shaped hook must declare, that refuses every
write to the object.

The allowlist is now two sets, and membership is **measured**, never recalled: a
`typeof X`/`'X' in globalThis` probe is evaluated inside the same
`QuickJSScriptRunner` the runtime evaluates a body in, and
`sandbox-globals-probe.test.ts` fails unless each set is exactly the probe's
present/absent partition.

- **`SANDBOX_GLOBALS` (53, measured present)** — the ECMAScript surface:
`Math` `JSON` `Date` `Object` `Array` `String` `Number` `Boolean` `RegExp`
`Map` `Set` `WeakMap` `WeakSet` `Promise` `Symbol` `BigInt` `Function`
`Reflect` `Proxy`, the typed-array/buffer family, the eight error
constructors, `parseInt` `parseFloat` `isNaN` `isFinite` and the four URI
functions, plus `undefined` `NaN` `Infinity` `globalThis`.
- **`NODE_ONLY_GLOBALS` (15, measured absent)** — `Intl`, `structuredClone`,
`queueMicrotask`, `atob`, `btoa`, `setTimeout`, `clearTimeout`,
`setInterval`, `clearInterval`, `URL`, `URLSearchParams`, `TextEncoder`,
`TextDecoder`, `console`, `arguments`.

A free reference to one of the 15 is now a lowering refusal whose reason names
the identifier and the remedy — a string handler ref (`functions:` map plus
`handler: 'fn_name'`) or a validation rule, and for `console` specifically the
capability-gated `ctx.log`. It travels the SAME path #1876 already used: the
refusal is `kind: 'free-identifiers'` with the host-only half carried
separately as `nodeOnlyIdentifiers`, `lowerCallables` catches it and ships the
callable through the `.mjs` bundle (where it runs in-process in Node and
works), `os build` still exits 0 with a warning, and `os lint` reports it under
`hook-body/not-lowerable` as an `error` — the ACCIDENTAL class, because `Intl`
is a standard global in every browser and in Node and writing it is not the
recognisable "I am reaching for the host" act that `fetch(` and `process.` are.
The remedy sentence `os lint` prints is chosen from the refusal's own
classification: "inline the value" is impossible for a host global, and
printing it anyway would send an author after a second broken shape.

⛔ Not changed here: whether `os build` fails on the lowering class (#13838),
and what the sandbox provides (giving it `Intl` would be a capability
expansion). Nothing under `packages/runtime/**` is touched — the probe reads
that sandbox, it does not change it.

**Why `patch`.** No published accept-set moves: the metadata a valid app may
declare is identical, `HookBodySchema` is untouched, and no key is added,
removed or re-shaped. What narrows is which handlers the build LOWERS, and for
every handler affected the previous outcome was a body that could not run. An
app hitting this gains a warning and a working bundled closure in place of a
production `ReferenceError`; the deployment shape it loses was never one it had
in working order. Measured corpus: zero in-repo example or template handlers
reference any of the 15.
48 changes: 48 additions & 0 deletions packages/cli/src/lint/hook-body-lowering.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,19 @@ const forbiddenTokenHook = {
},
};

// [#14301] A global the NODE HOST provides and the sandbox does not. Reads as
// self-contained — `Intl` is standard in every browser and in Node — which is
// precisely why it is the ACCIDENTAL class and not the structural one.
const nodeOnlyGlobalHook = {
name: 'stamp_due_label',
object: 'task',
events: ['beforeInsert'],
handler: (ctx: any) => {
const f = new Intl.DateTimeFormat('en-US', { timeZone: 'UTC' });
ctx.input.due_label = f.format(new Date(ctx.input.due_at));
},
};

const selfContainedHook = {
name: 'normalize_name',
object: 'account',
Expand DownExpand Up@@ -91,6 +104,41 @@ describe('checkHookBodyLowering', () => {
expect(issues[0].message).toContain('deployment shape');
});

describe('a host global the sandbox lacks is the ACCIDENTAL class (#14301)', () => {
it('reports it as an ERROR under the not-lowerable rule', () => {
const issues = checkHookBodyLowering({ hooks: [nodeOnlyGlobalHook] });

expect(issues).toHaveLength(1);
expect(issues[0].severity).toBe('error');
expect(issues[0].rule).toBe(NOT_LOWERABLE_RULE);
expect(issues[0].path).toBe('hooks[0].handler');
expect(issues[0].message).toContain("hook 'stamp_due_label'");
expect(issues[0].message).toContain('Intl');
expect(issues[0].message).toContain('deployment shape');
});

it('prints the remedy that is POSSIBLE, not the inline one', () => {
// The wrong-remedy failure is worse than none: there is nothing to inline
// `Intl` from, so an author (or a code-writing model) told to inline it
// lands on a second broken shape. The remedy sentence is chosen from the
// refusal's own `nodeOnlyIdentifiers`, never re-derived here.
const [issue] = checkHookBodyLowering({ hooks: [nodeOnlyGlobalHook] });
expect(issue.message).toContain('provided by the Node host');
expect(issue.message).toContain('cannot be inlined');
expect(issue.message).toContain('string handler ref');
expect(issue.message).toContain('validation rule');
expect(issue.message).not.toContain('Inline the value(s) into the handler');
});

it('leaves the module-scope remedy exactly as it was', () => {
// The reverse leg: the #13651 sentence must not have been rewritten for
// every author by a branch added for one new sub-case.
const [issue] = checkHookBodyLowering({ hooks: [freeIdentifierHook] });
expect(issue.message).toContain('Inline the value(s) into the handler, or reach them through `ctx`.');
expect(issue.message).not.toContain('provided by the Node host');
});
});

it('keeps the STRUCTURAL refusal a warning — the bundle is its designed answer', () => {
const issues = checkHookBodyLowering({ hooks: [forbiddenTokenHook] });

Expand Down
45 changes: 39 additions & 6 deletions packages/cli/src/lint/hook-body-lowering.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,13 +38,22 @@
* Today both arrive through one catch, so they share one fate. They are not
* the same event, and the refusal kind already tells them apart:
*
* `free-identifiers` ACCIDENTAL. The handler IS expressible as a metadata
* body; it merely names a module-scope const/helper/
* import. The author wrote something that reads
* `free-identifiers` ACCIDENTAL. The author wrote something that reads
* self-contained, and the platform quietly re-shaped the
* deployment behind them — its own message even says "no
* behavior change", which is true of behaviour and false
* of shape. Remedy is local: inline the value. => `error`.
* of shape. => `error`, for both sub-cases:
* · a module-scope const/helper/import — remedy is
* local: inline the value.
* · a global the NODE HOST has and the sandbox does not
* (#14301, `Intl`) — NOT inlinable, and still
* accidental rather than a chosen bundle: `Intl` is a
* standard global in every browser and in Node, so
* writing it is not the recognisable "I am reaching
* for the host" act that `fetch(`/`process.` are.
* That is why it is an `error` here and not the
* `warning` its structural cousin gets — and why the
* remedy sentence below is CHOSEN per sub-case.
* `forbidden-token` STRUCTURAL. `fetch`/`require`/`process`/`eval`/… are
* capabilities the sandbox does not have, so the handler
* can NEVER be a metadata body. Writing one IS choosing a
Expand DownExpand Up@@ -129,12 +138,37 @@ function judge(fn: AnyFn, originLabel: string, path: string): HookBodyLintIssue
} catch (err: unknown) {
const kind = err instanceof HookBodyExtractionError ? err.kind : 'unknown';
const free = err instanceof HookBodyExtractionError ? err.freeIdentifiers : [];
// [#14301] The half of `free` the Node HOST provides and the sandbox does
// not. Read off the refusal rather than re-derived here: this rule's whole
// parity claim is that it cannot disagree with what `os build` did to the
// same handler, and a second membership table in this file would be exactly
// such a disagreement waiting to happen.
const nodeOnly = err instanceof HookBodyExtractionError ? err.nodeOnlyIdentifiers : [];
// Only the first line: the refusal text carries a multi-line offending-source
// dump for `--strict-body`'s per-callable diagnostic, which would swamp a
// lint report. `os build --strict-body` remains the place to read it whole.
const firstLine = String((err as Error)?.message ?? err).split('\n')[0];

if (kind === 'free-identifiers') {
// The remedy sentence is chosen, not fixed. "Inline the value(s)" is
// right for a module-scope helper and IMPOSSIBLE for a host global —
// there is nothing to inline `Intl` from — so printing it for both would
// send an author (or a code-writing model) after a second broken shape.
const moduleScope = free.filter((n) => !nodeOnly.includes(n));
const hostRemedy =
`${nodeOnly.join(', ')} ${nodeOnly.length === 1 ? 'is' : 'are'} provided by the Node host ` +
`and NOT by the sandbox, so ${nodeOnly.length === 1 ? 'it' : 'they'} cannot be inlined: ` +
`keep the check in a string handler ref, or move it to a validation rule.`;
const inlineRemedy =
`Inline the value(s) into the handler, or reach them through \`ctx\`.`;
const remedy =
nodeOnly.length === 0
? inlineRemedy
: moduleScope.length === 0
? hostRemedy
: `${hostRemedy} ${moduleScope.join(', ')} ${moduleScope.length === 1 ? 'is' : 'are'} ` +
`module-scope: inline ${moduleScope.length === 1 ? 'it' : 'them'} into the handler, ` +
`or reach ${moduleScope.length === 1 ? 'it' : 'them'} through \`ctx\`.`;
return {
severity: 'error',
rule: NOT_LOWERABLE_RULE,
Expand All@@ -143,8 +177,7 @@ function judge(fn: AnyFn, originLabel: string, path: string): HookBodyLintIssue
`${free.length === 1 ? 'the identifier' : 'identifiers'} ${free.join(', ')}, which ` +
`${free.length === 1 ? 'is' : 'are'} not in scope inside the sandbox. The handler is ` +
`BUNDLED instead, so this app is no longer shippable as pure metadata — a change of ` +
`deployment shape, not of behaviour. Inline the value(s) into the handler, or reach ` +
`them through \`ctx\`. ${DELIBERATE_BUNDLE_REMEDY}`,
`deployment shape, not of behaviour. ${remedy} ${DELIBERATE_BUNDLE_REMEDY}`,
path,
};
}
Expand Down
89 changes: 88 additions & 1 deletion packages/cli/src/utils/detect-free-identifiers.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { detectFreeIdentifiers } from './detect-free-identifiers.js';
import {
detectFreeIdentifiers,
NODE_ONLY_GLOBALS,
SANDBOX_GLOBALS,
} from './detect-free-identifiers.js';

/** Helper: stringify a real function so we test the exact `.toString()` path. */
const src = (fn: (...a: any[]) => any) => String(fn);
Expand DownExpand Up@@ -53,6 +57,14 @@ describe('detectFreeIdentifiers (#1876 — body self-containment)', () => {
['nested destructuring', '({ a: { b } }) => b + 1'],
['rest params', '(...args) => args.length'],
['typeof a local', '(ctx) => { const v = ctx.v; return typeof v; }'],
// #14301 — the node-only refusal is a SCOPE analysis, not a token scan.
// A locally-bound name that happens to spell a host global is bound, and
// a member NAMED after one is not a reference at all. Both would be
// false refusals, and a false refusal here costs an author a body they
// were entitled to.
['a local shadowing a host global', '(ctx) => { const Intl = ctx.fmt; return Intl.format(ctx.d); }'],
['a member named after a host global', '(ctx) => { return ctx.Intl.format(ctx.d); }'],
['a param named after a host global', '(ctx, Intl) => Intl.format(ctx.d)'],
];
for (const [label, source] of selfContained) {
it(label, () => {
Expand DownExpand Up@@ -81,4 +93,79 @@ describe('detectFreeIdentifiers (#1876 — body self-containment)', () => {
expect(detectFreeIdentifiers('').free).toEqual([]);
expect(detectFreeIdentifiers('{ not: valid').free).toEqual([]);
});
// ── #14301 — globals the NODE HOST has and the sandbox does not ──────────
//
// The reported shape: `Intl` sat in one generous allowlist beside `JSON`, so
// a handler calling `Intl.DateTimeFormat` had no free identifier, lowered
// into `body.source`, and threw `ReferenceError` in production while every
// local gate was green. The split makes the reference visible again; the
// membership of the two sets is not asserted here from knowledge — it is
// measured inside the real sandbox by `sandbox-globals-probe.test.ts`.
describe('reports host globals the sandbox does not provide', () => {
it('the card reproduction — Intl.DateTimeFormat', () => {
const r = detectFreeIdentifiers(
"(ctx) => { const f = new Intl.DateTimeFormat('en-US'); ctx.input.label = f.format(new Date(ctx.input.at)); }",
);
expect(r.unparsed).toBe(false);
expect(r.free).toEqual(['Intl']);
expect(r.nodeOnly).toEqual(['Intl']);
});

it('`nodeOnly` is a labelled SUBSET of `free`, not a second list', () => {
// Both halves at once. The caller needs them apart because the remedies
// are opposite — inline the helper, but a host global cannot be inlined
// — and needs them together because the refusal names every name.
const r = detectFreeIdentifiers('(ctx) => { ctx.x = slugify(ctx.name) + Intl.NumberFormat; }');
expect(r.free).toEqual(['Intl', 'slugify']);
expect(r.nodeOnly).toEqual(['Intl']);
expect(r.nodeOnly.every((n) => r.free.includes(n))).toBe(true);
});

it('a sandbox-provided global is still waived — the positive control', () => {
const r = detectFreeIdentifiers('(ctx) => { ctx.x = JSON.stringify(Math.round(ctx.n)); }');
expect(r.free).toEqual([]);
expect(r.nodeOnly).toEqual([]);
});

it('every member of NODE_ONLY_GLOBALS is reported when referenced free', () => {
// Set-wide rather than per-name: a member added to the set without the
// detector reading it would otherwise sit inert, which is the exact
// shape of the defect being closed one level up.
for (const name of NODE_ONLY_GLOBALS) {
const r = detectFreeIdentifiers(`(ctx) => { ctx.x = ${name}; }`);
expect({ name, free: r.free, nodeOnly: r.nodeOnly }).toEqual({
name,
free: [name],
nodeOnly: [name],
});
}
});

it('every member of SANDBOX_GLOBALS is waived when referenced free', () => {
for (const name of SANDBOX_GLOBALS) {
const r = detectFreeIdentifiers(`(ctx) => { ctx.x = ${name}; }`);
expect({ name, free: r.free, nodeOnly: r.nodeOnly }).toEqual({
name,
free: [],
nodeOnly: [],
});
}
});

it('junk input reports neither list (conservative — never blocks extraction)', () => {
// The bias the file's header states, restated over the NEW field: a
// source this analysis cannot make sense of must not produce a refusal.
// Asserted over the same three junk shapes the #1876 case uses, and
// without asserting `unparsed` — TS error-recovery decides that, and the
// invariant that matters is that neither list fills.
for (const junk of ['this is not a function', '', '{ not: valid']) {
const r = detectFreeIdentifiers(junk);
expect({ junk, free: r.free, nodeOnly: r.nodeOnly }).toEqual({
junk,
free: [],
nodeOnly: [],
});
}
});
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .changeset/hook-body-sandbox-globals-allowlist.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
---
"@objectstack/cli": patch
---

fix(cli): stop lowering hook/action bodies that reference globals the sandbox does not provide (#14301)

`detect-free-identifiers`' ambient allowlist was ONE generous list, documented as
"assume the runtime has it". The runtime a lowered body actually runs in is the
QuickJS sandbox, not the Node process that runs `objectstack build` — and the
list named `Intl` beside `JSON`, under the comment "Web-ish that the sandbox /
Node commonly provide". So a handler calling `Intl.DateTimeFormat` had no free
identifier at all: `extractHookBody` lowered it into `body.source`, the #13651
lint rule had nothing to report (it fires only on a *refused* lowering),
`validate` / `typecheck` / `test` / `build` were all green because the
in-process test runs the RAW function in Node where `Intl` exists — and
production threw `ReferenceError: Intl is not defined`. Under the
`onError: 'abort'` a validation-shaped hook must declare, that refuses every
write to the object.

The allowlist is now two sets, and membership is **measured**, never recalled: a
`typeof X`/`'X' in globalThis` probe is evaluated inside the same
`QuickJSScriptRunner` the runtime evaluates a body in, and
`sandbox-globals-probe.test.ts` fails unless each set is exactly the probe's
present/absent partition.

- **`SANDBOX_GLOBALS` (53, measured present)** — the ECMAScript surface:
`Math` `JSON` `Date` `Object` `Array` `String` `Number` `Boolean` `RegExp`
`Map` `Set` `WeakMap` `WeakSet` `Promise` `Symbol` `BigInt` `Function`
`Reflect` `Proxy`, the typed-array/buffer family, the eight error
constructors, `parseInt` `parseFloat` `isNaN` `isFinite` and the four URI
functions, plus `undefined` `NaN` `Infinity` `globalThis`.
- **`NODE_ONLY_GLOBALS` (15, measured absent)** — `Intl`, `structuredClone`,
`queueMicrotask`, `atob`, `btoa`, `setTimeout`, `clearTimeout`,
`setInterval`, `clearInterval`, `URL`, `URLSearchParams`, `TextEncoder`,
`TextDecoder`, `console`, `arguments`.

A free reference to one of the 15 is now a lowering refusal whose reason names
the identifier and the remedy — a string handler ref (`functions:` map plus
`handler: 'fn_name'`) or a validation rule, and for `console` specifically the
capability-gated `ctx.log`. It travels the SAME path #1876 already used: the
refusal is `kind: 'free-identifiers'` with the host-only half carried
separately as `nodeOnlyIdentifiers`, `lowerCallables` catches it and ships the
callable through the `.mjs` bundle (where it runs in-process in Node and
works), `os build` still exits 0 with a warning, and `os lint` reports it under
`hook-body/not-lowerable` as an `error` — the ACCIDENTAL class, because `Intl`
is a standard global in every browser and in Node and writing it is not the
recognisable "I am reaching for the host" act that `fetch(` and `process.` are.
The remedy sentence `os lint` prints is chosen from the refusal's own
classification: "inline the value" is impossible for a host global, and
printing it anyway would send an author after a second broken shape.

⛔ Not changed here: whether `os build` fails on the lowering class (#13838),
and what the sandbox provides (giving it `Intl` would be a capability
expansion). Nothing under `packages/runtime/**` is touched — the probe reads
that sandbox, it does not change it.

**Why `patch`.** No published accept-set moves: the metadata a valid app may
declare is identical, `HookBodySchema` is untouched, and no key is added,
removed or re-shaped. What narrows is which handlers the build LOWERS, and for
every handler affected the previous outcome was a body that could not run. An
app hitting this gains a warning and a working bundled closure in place of a
production `ReferenceError`; the deployment shape it loses was never one it had
in working order. Measured corpus: zero in-repo example or template handlers
reference any of the 15.
48 changes: 48 additions & 0 deletions packages/cli/src/lint/hook-body-lowering.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,19 @@ const forbiddenTokenHook = {
},
};

// [#14301] A global the NODE HOST provides and the sandbox does not. Reads as
// self-contained — `Intl` is standard in every browser and in Node — which is
// precisely why it is the ACCIDENTAL class and not the structural one.
const nodeOnlyGlobalHook = {
name: 'stamp_due_label',
object: 'task',
events: ['beforeInsert'],
handler: (ctx: any) => {
const f = new Intl.DateTimeFormat('en-US', { timeZone: 'UTC' });
ctx.input.due_label = f.format(new Date(ctx.input.due_at));
},
};

const selfContainedHook = {
name: 'normalize_name',
object: 'account',
Expand DownExpand Up@@ -91,6 +104,41 @@ describe('checkHookBodyLowering', () => {
expect(issues[0].message).toContain('deployment shape');
});

describe('a host global the sandbox lacks is the ACCIDENTAL class (#14301)', () => {
it('reports it as an ERROR under the not-lowerable rule', () => {
const issues = checkHookBodyLowering({ hooks: [nodeOnlyGlobalHook] });

expect(issues).toHaveLength(1);
expect(issues[0].severity).toBe('error');
expect(issues[0].rule).toBe(NOT_LOWERABLE_RULE);
expect(issues[0].path).toBe('hooks[0].handler');
expect(issues[0].message).toContain("hook 'stamp_due_label'");
expect(issues[0].message).toContain('Intl');
expect(issues[0].message).toContain('deployment shape');
});

it('prints the remedy that is POSSIBLE, not the inline one', () => {
// The wrong-remedy failure is worse than none: there is nothing to inline
// `Intl` from, so an author (or a code-writing model) told to inline it
// lands on a second broken shape. The remedy sentence is chosen from the
// refusal's own `nodeOnlyIdentifiers`, never re-derived here.
const [issue] = checkHookBodyLowering({ hooks: [nodeOnlyGlobalHook] });
expect(issue.message).toContain('provided by the Node host');
expect(issue.message).toContain('cannot be inlined');
expect(issue.message).toContain('string handler ref');
expect(issue.message).toContain('validation rule');
expect(issue.message).not.toContain('Inline the value(s) into the handler');
});

it('leaves the module-scope remedy exactly as it was', () => {
// The reverse leg: the #13651 sentence must not have been rewritten for
// every author by a branch added for one new sub-case.
const [issue] = checkHookBodyLowering({ hooks: [freeIdentifierHook] });
expect(issue.message).toContain('Inline the value(s) into the handler, or reach them through `ctx`.');
expect(issue.message).not.toContain('provided by the Node host');
});
});

it('keeps the STRUCTURAL refusal a warning — the bundle is its designed answer', () => {
const issues = checkHookBodyLowering({ hooks: [forbiddenTokenHook] });

Expand Down
45 changes: 39 additions & 6 deletions packages/cli/src/lint/hook-body-lowering.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,13 +38,22 @@
* Today both arrive through one catch, so they share one fate. They are not
* the same event, and the refusal kind already tells them apart:
*
* `free-identifiers` ACCIDENTAL. The handler IS expressible as a metadata
* body; it merely names a module-scope const/helper/
* import. The author wrote something that reads
* `free-identifiers` ACCIDENTAL. The author wrote something that reads
* self-contained, and the platform quietly re-shaped the
* deployment behind them — its own message even says "no
* behavior change", which is true of behaviour and false
* of shape. Remedy is local: inline the value. => `error`.
* of shape. => `error`, for both sub-cases:
* · a module-scope const/helper/import — remedy is
* local: inline the value.
* · a global the NODE HOST has and the sandbox does not
* (#14301, `Intl`) — NOT inlinable, and still
* accidental rather than a chosen bundle: `Intl` is a
* standard global in every browser and in Node, so
* writing it is not the recognisable "I am reaching
* for the host" act that `fetch(`/`process.` are.
* That is why it is an `error` here and not the
* `warning` its structural cousin gets — and why the
* remedy sentence below is CHOSEN per sub-case.
* `forbidden-token` STRUCTURAL. `fetch`/`require`/`process`/`eval`/… are
* capabilities the sandbox does not have, so the handler
* can NEVER be a metadata body. Writing one IS choosing a
Expand DownExpand Up@@ -129,12 +138,37 @@ function judge(fn: AnyFn, originLabel: string, path: string): HookBodyLintIssue
} catch (err: unknown) {
const kind = err instanceof HookBodyExtractionError ? err.kind : 'unknown';
const free = err instanceof HookBodyExtractionError ? err.freeIdentifiers : [];
// [#14301] The half of `free` the Node HOST provides and the sandbox does
// not. Read off the refusal rather than re-derived here: this rule's whole
// parity claim is that it cannot disagree with what `os build` did to the
// same handler, and a second membership table in this file would be exactly
// such a disagreement waiting to happen.
const nodeOnly = err instanceof HookBodyExtractionError ? err.nodeOnlyIdentifiers : [];
// Only the first line: the refusal text carries a multi-line offending-source
// dump for `--strict-body`'s per-callable diagnostic, which would swamp a
// lint report. `os build --strict-body` remains the place to read it whole.
const firstLine = String((err as Error)?.message ?? err).split('\n')[0];

if (kind === 'free-identifiers') {
// The remedy sentence is chosen, not fixed. "Inline the value(s)" is
// right for a module-scope helper and IMPOSSIBLE for a host global —
// there is nothing to inline `Intl` from — so printing it for both would
// send an author (or a code-writing model) after a second broken shape.
const moduleScope = free.filter((n) => !nodeOnly.includes(n));
const hostRemedy =
`${nodeOnly.join(', ')} ${nodeOnly.length === 1 ? 'is' : 'are'} provided by the Node host ` +
`and NOT by the sandbox, so ${nodeOnly.length === 1 ? 'it' : 'they'} cannot be inlined: ` +
`keep the check in a string handler ref, or move it to a validation rule.`;
const inlineRemedy =
`Inline the value(s) into the handler, or reach them through \`ctx\`.`;
const remedy =
nodeOnly.length === 0
? inlineRemedy
: moduleScope.length === 0
? hostRemedy
: `${hostRemedy} ${moduleScope.join(', ')} ${moduleScope.length === 1 ? 'is' : 'are'} ` +
`module-scope: inline ${moduleScope.length === 1 ? 'it' : 'them'} into the handler, ` +
`or reach ${moduleScope.length === 1 ? 'it' : 'them'} through \`ctx\`.`;
return {
severity: 'error',
rule: NOT_LOWERABLE_RULE,
Expand All@@ -143,8 +177,7 @@ function judge(fn: AnyFn, originLabel: string, path: string): HookBodyLintIssue
`${free.length === 1 ? 'the identifier' : 'identifiers'} ${free.join(', ')}, which ` +
`${free.length === 1 ? 'is' : 'are'} not in scope inside the sandbox. The handler is ` +
`BUNDLED instead, so this app is no longer shippable as pure metadata — a change of ` +
`deployment shape, not of behaviour. Inline the value(s) into the handler, or reach ` +
`them through \`ctx\`. ${DELIBERATE_BUNDLE_REMEDY}`,
`deployment shape, not of behaviour. ${remedy} ${DELIBERATE_BUNDLE_REMEDY}`,
path,
};
}
Expand Down
89 changes: 88 additions & 1 deletion packages/cli/src/utils/detect-free-identifiers.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { detectFreeIdentifiers } from './detect-free-identifiers.js';
import {
detectFreeIdentifiers,
NODE_ONLY_GLOBALS,
SANDBOX_GLOBALS,
} from './detect-free-identifiers.js';

/** Helper: stringify a real function so we test the exact `.toString()` path. */
const src = (fn: (...a: any[]) => any) => String(fn);
Expand DownExpand Up@@ -53,6 +57,14 @@ describe('detectFreeIdentifiers (#1876 — body self-containment)', () => {
['nested destructuring', '({ a: { b } }) => b + 1'],
['rest params', '(...args) => args.length'],
['typeof a local', '(ctx) => { const v = ctx.v; return typeof v; }'],
// #14301 — the node-only refusal is a SCOPE analysis, not a token scan.
// A locally-bound name that happens to spell a host global is bound, and
// a member NAMED after one is not a reference at all. Both would be
// false refusals, and a false refusal here costs an author a body they
// were entitled to.
['a local shadowing a host global', '(ctx) => { const Intl = ctx.fmt; return Intl.format(ctx.d); }'],
['a member named after a host global', '(ctx) => { return ctx.Intl.format(ctx.d); }'],
['a param named after a host global', '(ctx, Intl) => Intl.format(ctx.d)'],
];
for (const [label, source] of selfContained) {
it(label, () => {
Expand DownExpand Up@@ -81,4 +93,79 @@ describe('detectFreeIdentifiers (#1876 — body self-containment)', () => {
expect(detectFreeIdentifiers('').free).toEqual([]);
expect(detectFreeIdentifiers('{ not: valid').free).toEqual([]);
});
// ── #14301 — globals the NODE HOST has and the sandbox does not ──────────
//
// The reported shape: `Intl` sat in one generous allowlist beside `JSON`, so
// a handler calling `Intl.DateTimeFormat` had no free identifier, lowered
// into `body.source`, and threw `ReferenceError` in production while every
// local gate was green. The split makes the reference visible again; the
// membership of the two sets is not asserted here from knowledge — it is
// measured inside the real sandbox by `sandbox-globals-probe.test.ts`.
describe('reports host globals the sandbox does not provide', () => {
it('the card reproduction — Intl.DateTimeFormat', () => {
const r = detectFreeIdentifiers(
"(ctx) => { const f = new Intl.DateTimeFormat('en-US'); ctx.input.label = f.format(new Date(ctx.input.at)); }",
);
expect(r.unparsed).toBe(false);
expect(r.free).toEqual(['Intl']);
expect(r.nodeOnly).toEqual(['Intl']);
});

it('`nodeOnly` is a labelled SUBSET of `free`, not a second list', () => {
// Both halves at once. The caller needs them apart because the remedies
// are opposite — inline the helper, but a host global cannot be inlined
// — and needs them together because the refusal names every name.
const r = detectFreeIdentifiers('(ctx) => { ctx.x = slugify(ctx.name) + Intl.NumberFormat; }');
expect(r.free).toEqual(['Intl', 'slugify']);
expect(r.nodeOnly).toEqual(['Intl']);
expect(r.nodeOnly.every((n) => r.free.includes(n))).toBe(true);
});

it('a sandbox-provided global is still waived — the positive control', () => {
const r = detectFreeIdentifiers('(ctx) => { ctx.x = JSON.stringify(Math.round(ctx.n)); }');
expect(r.free).toEqual([]);
expect(r.nodeOnly).toEqual([]);
});

it('every member of NODE_ONLY_GLOBALS is reported when referenced free', () => {
// Set-wide rather than per-name: a member added to the set without the
// detector reading it would otherwise sit inert, which is the exact
// shape of the defect being closed one level up.
for (const name of NODE_ONLY_GLOBALS) {
const r = detectFreeIdentifiers(`(ctx) => { ctx.x = ${name}; }`);
expect({ name, free: r.free, nodeOnly: r.nodeOnly }).toEqual({
name,
free: [name],
nodeOnly: [name],
});
}
});

it('every member of SANDBOX_GLOBALS is waived when referenced free', () => {
for (const name of SANDBOX_GLOBALS) {
const r = detectFreeIdentifiers(`(ctx) => { ctx.x = ${name}; }`);
expect({ name, free: r.free, nodeOnly: r.nodeOnly }).toEqual({
name,
free: [],
nodeOnly: [],
});
}
});

it('junk input reports neither list (conservative — never blocks extraction)', () => {
// The bias the file's header states, restated over the NEW field: a
// source this analysis cannot make sense of must not produce a refusal.
// Asserted over the same three junk shapes the #1876 case uses, and
// without asserting `unparsed` — TS error-recovery decides that, and the
// invariant that matters is that neither list fills.
for (const junk of ['this is not a function', '', '{ not: valid']) {
const r = detectFreeIdentifiers(junk);
expect({ junk, free: r.free, nodeOnly: r.nodeOnly }).toEqual({
junk,
free: [],
nodeOnly: [],
});
}
});
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .changeset/hook-body-sandbox-globals-allowlist.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
---
"@objectstack/cli": patch
---

fix(cli): stop lowering hook/action bodies that reference globals the sandbox does not provide (#14301)

`detect-free-identifiers`' ambient allowlist was ONE generous list, documented as
"assume the runtime has it". The runtime a lowered body actually runs in is the
QuickJS sandbox, not the Node process that runs `objectstack build` — and the
list named `Intl` beside `JSON`, under the comment "Web-ish that the sandbox /
Node commonly provide". So a handler calling `Intl.DateTimeFormat` had no free
identifier at all: `extractHookBody` lowered it into `body.source`, the #13651
lint rule had nothing to report (it fires only on a *refused* lowering),
`validate` / `typecheck` / `test` / `build` were all green because the
in-process test runs the RAW function in Node where `Intl` exists — and
production threw `ReferenceError: Intl is not defined`. Under the
`onError: 'abort'` a validation-shaped hook must declare, that refuses every
write to the object.

The allowlist is now two sets, and membership is **measured**, never recalled: a
`typeof X`/`'X' in globalThis` probe is evaluated inside the same
`QuickJSScriptRunner` the runtime evaluates a body in, and
`sandbox-globals-probe.test.ts` fails unless each set is exactly the probe's
present/absent partition.

- **`SANDBOX_GLOBALS` (53, measured present)** — the ECMAScript surface:
`Math` `JSON` `Date` `Object` `Array` `String` `Number` `Boolean` `RegExp`
`Map` `Set` `WeakMap` `WeakSet` `Promise` `Symbol` `BigInt` `Function`
`Reflect` `Proxy`, the typed-array/buffer family, the eight error
constructors, `parseInt` `parseFloat` `isNaN` `isFinite` and the four URI
functions, plus `undefined` `NaN` `Infinity` `globalThis`.
- **`NODE_ONLY_GLOBALS` (15, measured absent)** — `Intl`, `structuredClone`,
`queueMicrotask`, `atob`, `btoa`, `setTimeout`, `clearTimeout`,
`setInterval`, `clearInterval`, `URL`, `URLSearchParams`, `TextEncoder`,
`TextDecoder`, `console`, `arguments`.

A free reference to one of the 15 is now a lowering refusal whose reason names
the identifier and the remedy — a string handler ref (`functions:` map plus
`handler: 'fn_name'`) or a validation rule, and for `console` specifically the
capability-gated `ctx.log`. It travels the SAME path #1876 already used: the
refusal is `kind: 'free-identifiers'` with the host-only half carried
separately as `nodeOnlyIdentifiers`, `lowerCallables` catches it and ships the
callable through the `.mjs` bundle (where it runs in-process in Node and
works), `os build` still exits 0 with a warning, and `os lint` reports it under
`hook-body/not-lowerable` as an `error` — the ACCIDENTAL class, because `Intl`
is a standard global in every browser and in Node and writing it is not the
recognisable "I am reaching for the host" act that `fetch(` and `process.` are.
The remedy sentence `os lint` prints is chosen from the refusal's own
classification: "inline the value" is impossible for a host global, and
printing it anyway would send an author after a second broken shape.

⛔ Not changed here: whether `os build` fails on the lowering class (#13838),
and what the sandbox provides (giving it `Intl` would be a capability
expansion). Nothing under `packages/runtime/**` is touched — the probe reads
that sandbox, it does not change it.

**Why `patch`.** No published accept-set moves: the metadata a valid app may
declare is identical, `HookBodySchema` is untouched, and no key is added,
removed or re-shaped. What narrows is which handlers the build LOWERS, and for
every handler affected the previous outcome was a body that could not run. An
app hitting this gains a warning and a working bundled closure in place of a
production `ReferenceError`; the deployment shape it loses was never one it had
in working order. Measured corpus: zero in-repo example or template handlers
reference any of the 15.
48 changes: 48 additions & 0 deletions packages/cli/src/lint/hook-body-lowering.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,19 @@ const forbiddenTokenHook = {
},
};

// [#14301] A global the NODE HOST provides and the sandbox does not. Reads as
// self-contained — `Intl` is standard in every browser and in Node — which is
// precisely why it is the ACCIDENTAL class and not the structural one.
const nodeOnlyGlobalHook = {
name: 'stamp_due_label',
object: 'task',
events: ['beforeInsert'],
handler: (ctx: any) => {
const f = new Intl.DateTimeFormat('en-US', { timeZone: 'UTC' });
ctx.input.due_label = f.format(new Date(ctx.input.due_at));
},
};

const selfContainedHook = {
name: 'normalize_name',
object: 'account',
Expand DownExpand Up@@ -91,6 +104,41 @@ describe('checkHookBodyLowering', () => {
expect(issues[0].message).toContain('deployment shape');
});

describe('a host global the sandbox lacks is the ACCIDENTAL class (#14301)', () => {
it('reports it as an ERROR under the not-lowerable rule', () => {
const issues = checkHookBodyLowering({ hooks: [nodeOnlyGlobalHook] });

expect(issues).toHaveLength(1);
expect(issues[0].severity).toBe('error');
expect(issues[0].rule).toBe(NOT_LOWERABLE_RULE);
expect(issues[0].path).toBe('hooks[0].handler');
expect(issues[0].message).toContain("hook 'stamp_due_label'");
expect(issues[0].message).toContain('Intl');
expect(issues[0].message).toContain('deployment shape');
});

it('prints the remedy that is POSSIBLE, not the inline one', () => {
// The wrong-remedy failure is worse than none: there is nothing to inline
// `Intl` from, so an author (or a code-writing model) told to inline it
// lands on a second broken shape. The remedy sentence is chosen from the
// refusal's own `nodeOnlyIdentifiers`, never re-derived here.
const [issue] = checkHookBodyLowering({ hooks: [nodeOnlyGlobalHook] });
expect(issue.message).toContain('provided by the Node host');
expect(issue.message).toContain('cannot be inlined');
expect(issue.message).toContain('string handler ref');
expect(issue.message).toContain('validation rule');
expect(issue.message).not.toContain('Inline the value(s) into the handler');
});

it('leaves the module-scope remedy exactly as it was', () => {
// The reverse leg: the #13651 sentence must not have been rewritten for
// every author by a branch added for one new sub-case.
const [issue] = checkHookBodyLowering({ hooks: [freeIdentifierHook] });
expect(issue.message).toContain('Inline the value(s) into the handler, or reach them through `ctx`.');
expect(issue.message).not.toContain('provided by the Node host');
});
});

it('keeps the STRUCTURAL refusal a warning — the bundle is its designed answer', () => {
const issues = checkHookBodyLowering({ hooks: [forbiddenTokenHook] });

Expand Down
45 changes: 39 additions & 6 deletions packages/cli/src/lint/hook-body-lowering.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,13 +38,22 @@
* Today both arrive through one catch, so they share one fate. They are not
* the same event, and the refusal kind already tells them apart:
*
* `free-identifiers` ACCIDENTAL. The handler IS expressible as a metadata
* body; it merely names a module-scope const/helper/
* import. The author wrote something that reads
* `free-identifiers` ACCIDENTAL. The author wrote something that reads
* self-contained, and the platform quietly re-shaped the
* deployment behind them — its own message even says "no
* behavior change", which is true of behaviour and false
* of shape. Remedy is local: inline the value. => `error`.
* of shape. => `error`, for both sub-cases:
* · a module-scope const/helper/import — remedy is
* local: inline the value.
* · a global the NODE HOST has and the sandbox does not
* (#14301, `Intl`) — NOT inlinable, and still
* accidental rather than a chosen bundle: `Intl` is a
* standard global in every browser and in Node, so
* writing it is not the recognisable "I am reaching
* for the host" act that `fetch(`/`process.` are.
* That is why it is an `error` here and not the
* `warning` its structural cousin gets — and why the
* remedy sentence below is CHOSEN per sub-case.
* `forbidden-token` STRUCTURAL. `fetch`/`require`/`process`/`eval`/… are
* capabilities the sandbox does not have, so the handler
* can NEVER be a metadata body. Writing one IS choosing a
Expand DownExpand Up@@ -129,12 +138,37 @@ function judge(fn: AnyFn, originLabel: string, path: string): HookBodyLintIssue
} catch (err: unknown) {
const kind = err instanceof HookBodyExtractionError ? err.kind : 'unknown';
const free = err instanceof HookBodyExtractionError ? err.freeIdentifiers : [];
// [#14301] The half of `free` the Node HOST provides and the sandbox does
// not. Read off the refusal rather than re-derived here: this rule's whole
// parity claim is that it cannot disagree with what `os build` did to the
// same handler, and a second membership table in this file would be exactly
// such a disagreement waiting to happen.
const nodeOnly = err instanceof HookBodyExtractionError ? err.nodeOnlyIdentifiers : [];
// Only the first line: the refusal text carries a multi-line offending-source
// dump for `--strict-body`'s per-callable diagnostic, which would swamp a
// lint report. `os build --strict-body` remains the place to read it whole.
const firstLine = String((err as Error)?.message ?? err).split('\n')[0];

if (kind === 'free-identifiers') {
// The remedy sentence is chosen, not fixed. "Inline the value(s)" is
// right for a module-scope helper and IMPOSSIBLE for a host global —
// there is nothing to inline `Intl` from — so printing it for both would
// send an author (or a code-writing model) after a second broken shape.
const moduleScope = free.filter((n) => !nodeOnly.includes(n));
const hostRemedy =
`${nodeOnly.join(', ')} ${nodeOnly.length === 1 ? 'is' : 'are'} provided by the Node host ` +
`and NOT by the sandbox, so ${nodeOnly.length === 1 ? 'it' : 'they'} cannot be inlined: ` +
`keep the check in a string handler ref, or move it to a validation rule.`;
const inlineRemedy =
`Inline the value(s) into the handler, or reach them through \`ctx\`.`;
const remedy =
nodeOnly.length === 0
? inlineRemedy
: moduleScope.length === 0
? hostRemedy
: `${hostRemedy} ${moduleScope.join(', ')} ${moduleScope.length === 1 ? 'is' : 'are'} ` +
`module-scope: inline ${moduleScope.length === 1 ? 'it' : 'them'} into the handler, ` +
`or reach ${moduleScope.length === 1 ? 'it' : 'them'} through \`ctx\`.`;
return {
severity: 'error',
rule: NOT_LOWERABLE_RULE,
Expand All@@ -143,8 +177,7 @@ function judge(fn: AnyFn, originLabel: string, path: string): HookBodyLintIssue
`${free.length === 1 ? 'the identifier' : 'identifiers'} ${free.join(', ')}, which ` +
`${free.length === 1 ? 'is' : 'are'} not in scope inside the sandbox. The handler is ` +
`BUNDLED instead, so this app is no longer shippable as pure metadata — a change of ` +
`deployment shape, not of behaviour. Inline the value(s) into the handler, or reach ` +
`them through \`ctx\`. ${DELIBERATE_BUNDLE_REMEDY}`,
`deployment shape, not of behaviour. ${remedy} ${DELIBERATE_BUNDLE_REMEDY}`,
path,
};
}
Expand Down
89 changes: 88 additions & 1 deletion packages/cli/src/utils/detect-free-identifiers.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { detectFreeIdentifiers } from './detect-free-identifiers.js';
import {
detectFreeIdentifiers,
NODE_ONLY_GLOBALS,
SANDBOX_GLOBALS,
} from './detect-free-identifiers.js';

/** Helper: stringify a real function so we test the exact `.toString()` path. */
const src = (fn: (...a: any[]) => any) => String(fn);
Expand DownExpand Up@@ -53,6 +57,14 @@ describe('detectFreeIdentifiers (#1876 — body self-containment)', () => {
['nested destructuring', '({ a: { b } }) => b + 1'],
['rest params', '(...args) => args.length'],
['typeof a local', '(ctx) => { const v = ctx.v; return typeof v; }'],
// #14301 — the node-only refusal is a SCOPE analysis, not a token scan.
// A locally-bound name that happens to spell a host global is bound, and
// a member NAMED after one is not a reference at all. Both would be
// false refusals, and a false refusal here costs an author a body they
// were entitled to.
['a local shadowing a host global', '(ctx) => { const Intl = ctx.fmt; return Intl.format(ctx.d); }'],
['a member named after a host global', '(ctx) => { return ctx.Intl.format(ctx.d); }'],
['a param named after a host global', '(ctx, Intl) => Intl.format(ctx.d)'],
];
for (const [label, source] of selfContained) {
it(label, () => {
Expand DownExpand Up@@ -81,4 +93,79 @@ describe('detectFreeIdentifiers (#1876 — body self-containment)', () => {
expect(detectFreeIdentifiers('').free).toEqual([]);
expect(detectFreeIdentifiers('{ not: valid').free).toEqual([]);
});
// ── #14301 — globals the NODE HOST has and the sandbox does not ──────────
//
// The reported shape: `Intl` sat in one generous allowlist beside `JSON`, so
// a handler calling `Intl.DateTimeFormat` had no free identifier, lowered
// into `body.source`, and threw `ReferenceError` in production while every
// local gate was green. The split makes the reference visible again; the
// membership of the two sets is not asserted here from knowledge — it is
// measured inside the real sandbox by `sandbox-globals-probe.test.ts`.
describe('reports host globals the sandbox does not provide', () => {
it('the card reproduction — Intl.DateTimeFormat', () => {
const r = detectFreeIdentifiers(
"(ctx) => { const f = new Intl.DateTimeFormat('en-US'); ctx.input.label = f.format(new Date(ctx.input.at)); }",
);
expect(r.unparsed).toBe(false);
expect(r.free).toEqual(['Intl']);
expect(r.nodeOnly).toEqual(['Intl']);
});

it('`nodeOnly` is a labelled SUBSET of `free`, not a second list', () => {
// Both halves at once. The caller needs them apart because the remedies
// are opposite — inline the helper, but a host global cannot be inlined
// — and needs them together because the refusal names every name.
const r = detectFreeIdentifiers('(ctx) => { ctx.x = slugify(ctx.name) + Intl.NumberFormat; }');
expect(r.free).toEqual(['Intl', 'slugify']);
expect(r.nodeOnly).toEqual(['Intl']);
expect(r.nodeOnly.every((n) => r.free.includes(n))).toBe(true);
});

it('a sandbox-provided global is still waived — the positive control', () => {
const r = detectFreeIdentifiers('(ctx) => { ctx.x = JSON.stringify(Math.round(ctx.n)); }');
expect(r.free).toEqual([]);
expect(r.nodeOnly).toEqual([]);
});

it('every member of NODE_ONLY_GLOBALS is reported when referenced free', () => {
// Set-wide rather than per-name: a member added to the set without the
// detector reading it would otherwise sit inert, which is the exact
// shape of the defect being closed one level up.
for (const name of NODE_ONLY_GLOBALS) {
const r = detectFreeIdentifiers(`(ctx) => { ctx.x = ${name}; }`);
expect({ name, free: r.free, nodeOnly: r.nodeOnly }).toEqual({
name,
free: [name],
nodeOnly: [name],
});
}
});

it('every member of SANDBOX_GLOBALS is waived when referenced free', () => {
for (const name of SANDBOX_GLOBALS) {
const r = detectFreeIdentifiers(`(ctx) => { ctx.x = ${name}; }`);
expect({ name, free: r.free, nodeOnly: r.nodeOnly }).toEqual({
name,
free: [],
nodeOnly: [],
});
}
});

it('junk input reports neither list (conservative — never blocks extraction)', () => {
// The bias the file's header states, restated over the NEW field: a
// source this analysis cannot make sense of must not produce a refusal.
// Asserted over the same three junk shapes the #1876 case uses, and
// without asserting `unparsed` — TS error-recovery decides that, and the
// invariant that matters is that neither list fills.
for (const junk of ['this is not a function', '', '{ not: valid']) {
const r = detectFreeIdentifiers(junk);
expect({ junk, free: r.free, nodeOnly: r.nodeOnly }).toEqual({
junk,
free: [],
nodeOnly: [],
});
}
});
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .changeset/hook-body-sandbox-globals-allowlist.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
---
"@objectstack/cli": patch
---

fix(cli): stop lowering hook/action bodies that reference globals the sandbox does not provide (#14301)

`detect-free-identifiers`' ambient allowlist was ONE generous list, documented as
"assume the runtime has it". The runtime a lowered body actually runs in is the
QuickJS sandbox, not the Node process that runs `objectstack build` — and the
list named `Intl` beside `JSON`, under the comment "Web-ish that the sandbox /
Node commonly provide". So a handler calling `Intl.DateTimeFormat` had no free
identifier at all: `extractHookBody` lowered it into `body.source`, the #13651
lint rule had nothing to report (it fires only on a *refused* lowering),
`validate` / `typecheck` / `test` / `build` were all green because the
in-process test runs the RAW function in Node where `Intl` exists — and
production threw `ReferenceError: Intl is not defined`. Under the
`onError: 'abort'` a validation-shaped hook must declare, that refuses every
write to the object.

The allowlist is now two sets, and membership is **measured**, never recalled: a
`typeof X`/`'X' in globalThis` probe is evaluated inside the same
`QuickJSScriptRunner` the runtime evaluates a body in, and
`sandbox-globals-probe.test.ts` fails unless each set is exactly the probe's
present/absent partition.

- **`SANDBOX_GLOBALS` (53, measured present)** — the ECMAScript surface:
`Math` `JSON` `Date` `Object` `Array` `String` `Number` `Boolean` `RegExp`
`Map` `Set` `WeakMap` `WeakSet` `Promise` `Symbol` `BigInt` `Function`
`Reflect` `Proxy`, the typed-array/buffer family, the eight error
constructors, `parseInt` `parseFloat` `isNaN` `isFinite` and the four URI
functions, plus `undefined` `NaN` `Infinity` `globalThis`.
- **`NODE_ONLY_GLOBALS` (15, measured absent)** — `Intl`, `structuredClone`,
`queueMicrotask`, `atob`, `btoa`, `setTimeout`, `clearTimeout`,
`setInterval`, `clearInterval`, `URL`, `URLSearchParams`, `TextEncoder`,
`TextDecoder`, `console`, `arguments`.

A free reference to one of the 15 is now a lowering refusal whose reason names
the identifier and the remedy — a string handler ref (`functions:` map plus
`handler: 'fn_name'`) or a validation rule, and for `console` specifically the
capability-gated `ctx.log`. It travels the SAME path #1876 already used: the
refusal is `kind: 'free-identifiers'` with the host-only half carried
separately as `nodeOnlyIdentifiers`, `lowerCallables` catches it and ships the
callable through the `.mjs` bundle (where it runs in-process in Node and
works), `os build` still exits 0 with a warning, and `os lint` reports it under
`hook-body/not-lowerable` as an `error` — the ACCIDENTAL class, because `Intl`
is a standard global in every browser and in Node and writing it is not the
recognisable "I am reaching for the host" act that `fetch(` and `process.` are.
The remedy sentence `os lint` prints is chosen from the refusal's own
classification: "inline the value" is impossible for a host global, and
printing it anyway would send an author after a second broken shape.

⛔ Not changed here: whether `os build` fails on the lowering class (#13838),
and what the sandbox provides (giving it `Intl` would be a capability
expansion). Nothing under `packages/runtime/**` is touched — the probe reads
that sandbox, it does not change it.

**Why `patch`.** No published accept-set moves: the metadata a valid app may
declare is identical, `HookBodySchema` is untouched, and no key is added,
removed or re-shaped. What narrows is which handlers the build LOWERS, and for
every handler affected the previous outcome was a body that could not run. An
app hitting this gains a warning and a working bundled closure in place of a
production `ReferenceError`; the deployment shape it loses was never one it had
in working order. Measured corpus: zero in-repo example or template handlers
reference any of the 15.
48 changes: 48 additions & 0 deletions packages/cli/src/lint/hook-body-lowering.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,19 @@ const forbiddenTokenHook = {
},
};

// [#14301] A global the NODE HOST provides and the sandbox does not. Reads as
// self-contained — `Intl` is standard in every browser and in Node — which is
// precisely why it is the ACCIDENTAL class and not the structural one.
const nodeOnlyGlobalHook = {
name: 'stamp_due_label',
object: 'task',
events: ['beforeInsert'],
handler: (ctx: any) => {
const f = new Intl.DateTimeFormat('en-US', { timeZone: 'UTC' });
ctx.input.due_label = f.format(new Date(ctx.input.due_at));
},
};

const selfContainedHook = {
name: 'normalize_name',
object: 'account',
Expand DownExpand Up@@ -91,6 +104,41 @@ describe('checkHookBodyLowering', () => {
expect(issues[0].message).toContain('deployment shape');
});

describe('a host global the sandbox lacks is the ACCIDENTAL class (#14301)', () => {
it('reports it as an ERROR under the not-lowerable rule', () => {
const issues = checkHookBodyLowering({ hooks: [nodeOnlyGlobalHook] });

expect(issues).toHaveLength(1);
expect(issues[0].severity).toBe('error');
expect(issues[0].rule).toBe(NOT_LOWERABLE_RULE);
expect(issues[0].path).toBe('hooks[0].handler');
expect(issues[0].message).toContain("hook 'stamp_due_label'");
expect(issues[0].message).toContain('Intl');
expect(issues[0].message).toContain('deployment shape');
});

it('prints the remedy that is POSSIBLE, not the inline one', () => {
// The wrong-remedy failure is worse than none: there is nothing to inline
// `Intl` from, so an author (or a code-writing model) told to inline it
// lands on a second broken shape. The remedy sentence is chosen from the
// refusal's own `nodeOnlyIdentifiers`, never re-derived here.
const [issue] = checkHookBodyLowering({ hooks: [nodeOnlyGlobalHook] });
expect(issue.message).toContain('provided by the Node host');
expect(issue.message).toContain('cannot be inlined');
expect(issue.message).toContain('string handler ref');
expect(issue.message).toContain('validation rule');
expect(issue.message).not.toContain('Inline the value(s) into the handler');
});

it('leaves the module-scope remedy exactly as it was', () => {
// The reverse leg: the #13651 sentence must not have been rewritten for
// every author by a branch added for one new sub-case.
const [issue] = checkHookBodyLowering({ hooks: [freeIdentifierHook] });
expect(issue.message).toContain('Inline the value(s) into the handler, or reach them through `ctx`.');
expect(issue.message).not.toContain('provided by the Node host');
});
});

it('keeps the STRUCTURAL refusal a warning — the bundle is its designed answer', () => {
const issues = checkHookBodyLowering({ hooks: [forbiddenTokenHook] });

Expand Down
45 changes: 39 additions & 6 deletions packages/cli/src/lint/hook-body-lowering.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,13 +38,22 @@
* Today both arrive through one catch, so they share one fate. They are not
* the same event, and the refusal kind already tells them apart:
*
* `free-identifiers` ACCIDENTAL. The handler IS expressible as a metadata
* body; it merely names a module-scope const/helper/
* import. The author wrote something that reads
* `free-identifiers` ACCIDENTAL. The author wrote something that reads
* self-contained, and the platform quietly re-shaped the
* deployment behind them — its own message even says "no
* behavior change", which is true of behaviour and false
* of shape. Remedy is local: inline the value. => `error`.
* of shape. => `error`, for both sub-cases:
* · a module-scope const/helper/import — remedy is
* local: inline the value.
* · a global the NODE HOST has and the sandbox does not
* (#14301, `Intl`) — NOT inlinable, and still
* accidental rather than a chosen bundle: `Intl` is a
* standard global in every browser and in Node, so
* writing it is not the recognisable "I am reaching
* for the host" act that `fetch(`/`process.` are.
* That is why it is an `error` here and not the
* `warning` its structural cousin gets — and why the
* remedy sentence below is CHOSEN per sub-case.
* `forbidden-token` STRUCTURAL. `fetch`/`require`/`process`/`eval`/… are
* capabilities the sandbox does not have, so the handler
* can NEVER be a metadata body. Writing one IS choosing a
Expand DownExpand Up@@ -129,12 +138,37 @@ function judge(fn: AnyFn, originLabel: string, path: string): HookBodyLintIssue
} catch (err: unknown) {
const kind = err instanceof HookBodyExtractionError ? err.kind : 'unknown';
const free = err instanceof HookBodyExtractionError ? err.freeIdentifiers : [];
// [#14301] The half of `free` the Node HOST provides and the sandbox does
// not. Read off the refusal rather than re-derived here: this rule's whole
// parity claim is that it cannot disagree with what `os build` did to the
// same handler, and a second membership table in this file would be exactly
// such a disagreement waiting to happen.
const nodeOnly = err instanceof HookBodyExtractionError ? err.nodeOnlyIdentifiers : [];
// Only the first line: the refusal text carries a multi-line offending-source
// dump for `--strict-body`'s per-callable diagnostic, which would swamp a
// lint report. `os build --strict-body` remains the place to read it whole.
const firstLine = String((err as Error)?.message ?? err).split('\n')[0];

if (kind === 'free-identifiers') {
// The remedy sentence is chosen, not fixed. "Inline the value(s)" is
// right for a module-scope helper and IMPOSSIBLE for a host global —
// there is nothing to inline `Intl` from — so printing it for both would
// send an author (or a code-writing model) after a second broken shape.
const moduleScope = free.filter((n) => !nodeOnly.includes(n));
const hostRemedy =
`${nodeOnly.join(', ')} ${nodeOnly.length === 1 ? 'is' : 'are'} provided by the Node host ` +
`and NOT by the sandbox, so ${nodeOnly.length === 1 ? 'it' : 'they'} cannot be inlined: ` +
`keep the check in a string handler ref, or move it to a validation rule.`;
const inlineRemedy =
`Inline the value(s) into the handler, or reach them through \`ctx\`.`;
const remedy =
nodeOnly.length === 0
? inlineRemedy
: moduleScope.length === 0
? hostRemedy
: `${hostRemedy} ${moduleScope.join(', ')} ${moduleScope.length === 1 ? 'is' : 'are'} ` +
`module-scope: inline ${moduleScope.length === 1 ? 'it' : 'them'} into the handler, ` +
`or reach ${moduleScope.length === 1 ? 'it' : 'them'} through \`ctx\`.`;
return {
severity: 'error',
rule: NOT_LOWERABLE_RULE,
Expand All@@ -143,8 +177,7 @@ function judge(fn: AnyFn, originLabel: string, path: string): HookBodyLintIssue
`${free.length === 1 ? 'the identifier' : 'identifiers'} ${free.join(', ')}, which ` +
`${free.length === 1 ? 'is' : 'are'} not in scope inside the sandbox. The handler is ` +
`BUNDLED instead, so this app is no longer shippable as pure metadata — a change of ` +
`deployment shape, not of behaviour. Inline the value(s) into the handler, or reach ` +
`them through \`ctx\`. ${DELIBERATE_BUNDLE_REMEDY}`,
`deployment shape, not of behaviour. ${remedy} ${DELIBERATE_BUNDLE_REMEDY}`,
path,
};
}
Expand Down
89 changes: 88 additions & 1 deletion packages/cli/src/utils/detect-free-identifiers.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { detectFreeIdentifiers } from './detect-free-identifiers.js';
import {
detectFreeIdentifiers,
NODE_ONLY_GLOBALS,
SANDBOX_GLOBALS,
} from './detect-free-identifiers.js';

/** Helper: stringify a real function so we test the exact `.toString()` path. */
const src = (fn: (...a: any[]) => any) => String(fn);
Expand DownExpand Up@@ -53,6 +57,14 @@ describe('detectFreeIdentifiers (#1876 — body self-containment)', () => {
['nested destructuring', '({ a: { b } }) => b + 1'],
['rest params', '(...args) => args.length'],
['typeof a local', '(ctx) => { const v = ctx.v; return typeof v; }'],
// #14301 — the node-only refusal is a SCOPE analysis, not a token scan.
// A locally-bound name that happens to spell a host global is bound, and
// a member NAMED after one is not a reference at all. Both would be
// false refusals, and a false refusal here costs an author a body they
// were entitled to.
['a local shadowing a host global', '(ctx) => { const Intl = ctx.fmt; return Intl.format(ctx.d); }'],
['a member named after a host global', '(ctx) => { return ctx.Intl.format(ctx.d); }'],
['a param named after a host global', '(ctx, Intl) => Intl.format(ctx.d)'],
];
for (const [label, source] of selfContained) {
it(label, () => {
Expand DownExpand Up@@ -81,4 +93,79 @@ describe('detectFreeIdentifiers (#1876 — body self-containment)', () => {
expect(detectFreeIdentifiers('').free).toEqual([]);
expect(detectFreeIdentifiers('{ not: valid').free).toEqual([]);
});
// ── #14301 — globals the NODE HOST has and the sandbox does not ──────────
//
// The reported shape: `Intl` sat in one generous allowlist beside `JSON`, so
// a handler calling `Intl.DateTimeFormat` had no free identifier, lowered
// into `body.source`, and threw `ReferenceError` in production while every
// local gate was green. The split makes the reference visible again; the
// membership of the two sets is not asserted here from knowledge — it is
// measured inside the real sandbox by `sandbox-globals-probe.test.ts`.
describe('reports host globals the sandbox does not provide', () => {
it('the card reproduction — Intl.DateTimeFormat', () => {
const r = detectFreeIdentifiers(
"(ctx) => { const f = new Intl.DateTimeFormat('en-US'); ctx.input.label = f.format(new Date(ctx.input.at)); }",
);
expect(r.unparsed).toBe(false);
expect(r.free).toEqual(['Intl']);
expect(r.nodeOnly).toEqual(['Intl']);
});

it('`nodeOnly` is a labelled SUBSET of `free`, not a second list', () => {
// Both halves at once. The caller needs them apart because the remedies
// are opposite — inline the helper, but a host global cannot be inlined
// — and needs them together because the refusal names every name.
const r = detectFreeIdentifiers('(ctx) => { ctx.x = slugify(ctx.name) + Intl.NumberFormat; }');
expect(r.free).toEqual(['Intl', 'slugify']);
expect(r.nodeOnly).toEqual(['Intl']);
expect(r.nodeOnly.every((n) => r.free.includes(n))).toBe(true);
});

it('a sandbox-provided global is still waived — the positive control', () => {
const r = detectFreeIdentifiers('(ctx) => { ctx.x = JSON.stringify(Math.round(ctx.n)); }');
expect(r.free).toEqual([]);
expect(r.nodeOnly).toEqual([]);
});

it('every member of NODE_ONLY_GLOBALS is reported when referenced free', () => {
// Set-wide rather than per-name: a member added to the set without the
// detector reading it would otherwise sit inert, which is the exact
// shape of the defect being closed one level up.
for (const name of NODE_ONLY_GLOBALS) {
const r = detectFreeIdentifiers(`(ctx) => { ctx.x = ${name}; }`);
expect({ name, free: r.free, nodeOnly: r.nodeOnly }).toEqual({
name,
free: [name],
nodeOnly: [name],
});
}
});

it('every member of SANDBOX_GLOBALS is waived when referenced free', () => {
for (const name of SANDBOX_GLOBALS) {
const r = detectFreeIdentifiers(`(ctx) => { ctx.x = ${name}; }`);
expect({ name, free: r.free, nodeOnly: r.nodeOnly }).toEqual({
name,
free: [],
nodeOnly: [],
});
}
});

it('junk input reports neither list (conservative — never blocks extraction)', () => {
// The bias the file's header states, restated over the NEW field: a
// source this analysis cannot make sense of must not produce a refusal.
// Asserted over the same three junk shapes the #1876 case uses, and
// without asserting `unparsed` — TS error-recovery decides that, and the
// invariant that matters is that neither list fills.
for (const junk of ['this is not a function', '', '{ not: valid']) {
const r = detectFreeIdentifiers(junk);
expect({ junk, free: r.free, nodeOnly: r.nodeOnly }).toEqual({
junk,
free: [],
nodeOnly: [],
});
}
});
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .changeset/hook-body-sandbox-globals-allowlist.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
---
"@objectstack/cli": patch
---

fix(cli): stop lowering hook/action bodies that reference globals the sandbox does not provide (#14301)

`detect-free-identifiers`' ambient allowlist was ONE generous list, documented as
"assume the runtime has it". The runtime a lowered body actually runs in is the
QuickJS sandbox, not the Node process that runs `objectstack build` — and the
list named `Intl` beside `JSON`, under the comment "Web-ish that the sandbox /
Node commonly provide". So a handler calling `Intl.DateTimeFormat` had no free
identifier at all: `extractHookBody` lowered it into `body.source`, the #13651
lint rule had nothing to report (it fires only on a *refused* lowering),
`validate` / `typecheck` / `test` / `build` were all green because the
in-process test runs the RAW function in Node where `Intl` exists — and
production threw `ReferenceError: Intl is not defined`. Under the
`onError: 'abort'` a validation-shaped hook must declare, that refuses every
write to the object.

The allowlist is now two sets, and membership is **measured**, never recalled: a
`typeof X`/`'X' in globalThis` probe is evaluated inside the same
`QuickJSScriptRunner` the runtime evaluates a body in, and
`sandbox-globals-probe.test.ts` fails unless each set is exactly the probe's
present/absent partition.

- **`SANDBOX_GLOBALS` (53, measured present)** — the ECMAScript surface:
`Math` `JSON` `Date` `Object` `Array` `String` `Number` `Boolean` `RegExp`
`Map` `Set` `WeakMap` `WeakSet` `Promise` `Symbol` `BigInt` `Function`
`Reflect` `Proxy`, the typed-array/buffer family, the eight error
constructors, `parseInt` `parseFloat` `isNaN` `isFinite` and the four URI
functions, plus `undefined` `NaN` `Infinity` `globalThis`.
- **`NODE_ONLY_GLOBALS` (15, measured absent)** — `Intl`, `structuredClone`,
`queueMicrotask`, `atob`, `btoa`, `setTimeout`, `clearTimeout`,
`setInterval`, `clearInterval`, `URL`, `URLSearchParams`, `TextEncoder`,
`TextDecoder`, `console`, `arguments`.

A free reference to one of the 15 is now a lowering refusal whose reason names
the identifier and the remedy — a string handler ref (`functions:` map plus
`handler: 'fn_name'`) or a validation rule, and for `console` specifically the
capability-gated `ctx.log`. It travels the SAME path #1876 already used: the
refusal is `kind: 'free-identifiers'` with the host-only half carried
separately as `nodeOnlyIdentifiers`, `lowerCallables` catches it and ships the
callable through the `.mjs` bundle (where it runs in-process in Node and
works), `os build` still exits 0 with a warning, and `os lint` reports it under
`hook-body/not-lowerable` as an `error` — the ACCIDENTAL class, because `Intl`
is a standard global in every browser and in Node and writing it is not the
recognisable "I am reaching for the host" act that `fetch(` and `process.` are.
The remedy sentence `os lint` prints is chosen from the refusal's own
classification: "inline the value" is impossible for a host global, and
printing it anyway would send an author after a second broken shape.

⛔ Not changed here: whether `os build` fails on the lowering class (#13838),
and what the sandbox provides (giving it `Intl` would be a capability
expansion). Nothing under `packages/runtime/**` is touched — the probe reads
that sandbox, it does not change it.

**Why `patch`.** No published accept-set moves: the metadata a valid app may
declare is identical, `HookBodySchema` is untouched, and no key is added,
removed or re-shaped. What narrows is which handlers the build LOWERS, and for
every handler affected the previous outcome was a body that could not run. An
app hitting this gains a warning and a working bundled closure in place of a
production `ReferenceError`; the deployment shape it loses was never one it had
in working order. Measured corpus: zero in-repo example or template handlers
reference any of the 15.
48 changes: 48 additions & 0 deletions packages/cli/src/lint/hook-body-lowering.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,19 @@ const forbiddenTokenHook = {
},
};

// [#14301] A global the NODE HOST provides and the sandbox does not. Reads as
// self-contained — `Intl` is standard in every browser and in Node — which is
// precisely why it is the ACCIDENTAL class and not the structural one.
const nodeOnlyGlobalHook = {
name: 'stamp_due_label',
object: 'task',
events: ['beforeInsert'],
handler: (ctx: any) => {
const f = new Intl.DateTimeFormat('en-US', { timeZone: 'UTC' });
ctx.input.due_label = f.format(new Date(ctx.input.due_at));
},
};

const selfContainedHook = {
name: 'normalize_name',
object: 'account',
Expand DownExpand Up@@ -91,6 +104,41 @@ describe('checkHookBodyLowering', () => {
expect(issues[0].message).toContain('deployment shape');
});

describe('a host global the sandbox lacks is the ACCIDENTAL class (#14301)', () => {
it('reports it as an ERROR under the not-lowerable rule', () => {
const issues = checkHookBodyLowering({ hooks: [nodeOnlyGlobalHook] });

expect(issues).toHaveLength(1);
expect(issues[0].severity).toBe('error');
expect(issues[0].rule).toBe(NOT_LOWERABLE_RULE);
expect(issues[0].path).toBe('hooks[0].handler');
expect(issues[0].message).toContain("hook 'stamp_due_label'");
expect(issues[0].message).toContain('Intl');
expect(issues[0].message).toContain('deployment shape');
});

it('prints the remedy that is POSSIBLE, not the inline one', () => {
// The wrong-remedy failure is worse than none: there is nothing to inline
// `Intl` from, so an author (or a code-writing model) told to inline it
// lands on a second broken shape. The remedy sentence is chosen from the
// refusal's own `nodeOnlyIdentifiers`, never re-derived here.
const [issue] = checkHookBodyLowering({ hooks: [nodeOnlyGlobalHook] });
expect(issue.message).toContain('provided by the Node host');
expect(issue.message).toContain('cannot be inlined');
expect(issue.message).toContain('string handler ref');
expect(issue.message).toContain('validation rule');
expect(issue.message).not.toContain('Inline the value(s) into the handler');
});

it('leaves the module-scope remedy exactly as it was', () => {
// The reverse leg: the #13651 sentence must not have been rewritten for
// every author by a branch added for one new sub-case.
const [issue] = checkHookBodyLowering({ hooks: [freeIdentifierHook] });
expect(issue.message).toContain('Inline the value(s) into the handler, or reach them through `ctx`.');
expect(issue.message).not.toContain('provided by the Node host');
});
});

it('keeps the STRUCTURAL refusal a warning — the bundle is its designed answer', () => {
const issues = checkHookBodyLowering({ hooks: [forbiddenTokenHook] });

Expand Down
45 changes: 39 additions & 6 deletions packages/cli/src/lint/hook-body-lowering.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,13 +38,22 @@
* Today both arrive through one catch, so they share one fate. They are not
* the same event, and the refusal kind already tells them apart:
*
* `free-identifiers` ACCIDENTAL. The handler IS expressible as a metadata
* body; it merely names a module-scope const/helper/
* import. The author wrote something that reads
* `free-identifiers` ACCIDENTAL. The author wrote something that reads
* self-contained, and the platform quietly re-shaped the
* deployment behind them — its own message even says "no
* behavior change", which is true of behaviour and false
* of shape. Remedy is local: inline the value. => `error`.
* of shape. => `error`, for both sub-cases:
* · a module-scope const/helper/import — remedy is
* local: inline the value.
* · a global the NODE HOST has and the sandbox does not
* (#14301, `Intl`) — NOT inlinable, and still
* accidental rather than a chosen bundle: `Intl` is a
* standard global in every browser and in Node, so
* writing it is not the recognisable "I am reaching
* for the host" act that `fetch(`/`process.` are.
* That is why it is an `error` here and not the
* `warning` its structural cousin gets — and why the
* remedy sentence below is CHOSEN per sub-case.
* `forbidden-token` STRUCTURAL. `fetch`/`require`/`process`/`eval`/… are
* capabilities the sandbox does not have, so the handler
* can NEVER be a metadata body. Writing one IS choosing a
Expand DownExpand Up@@ -129,12 +138,37 @@ function judge(fn: AnyFn, originLabel: string, path: string): HookBodyLintIssue
} catch (err: unknown) {
const kind = err instanceof HookBodyExtractionError ? err.kind : 'unknown';
const free = err instanceof HookBodyExtractionError ? err.freeIdentifiers : [];
// [#14301] The half of `free` the Node HOST provides and the sandbox does
// not. Read off the refusal rather than re-derived here: this rule's whole
// parity claim is that it cannot disagree with what `os build` did to the
// same handler, and a second membership table in this file would be exactly
// such a disagreement waiting to happen.
const nodeOnly = err instanceof HookBodyExtractionError ? err.nodeOnlyIdentifiers : [];
// Only the first line: the refusal text carries a multi-line offending-source
// dump for `--strict-body`'s per-callable diagnostic, which would swamp a
// lint report. `os build --strict-body` remains the place to read it whole.
const firstLine = String((err as Error)?.message ?? err).split('\n')[0];

if (kind === 'free-identifiers') {
// The remedy sentence is chosen, not fixed. "Inline the value(s)" is
// right for a module-scope helper and IMPOSSIBLE for a host global —
// there is nothing to inline `Intl` from — so printing it for both would
// send an author (or a code-writing model) after a second broken shape.
const moduleScope = free.filter((n) => !nodeOnly.includes(n));
const hostRemedy =
`${nodeOnly.join(', ')} ${nodeOnly.length === 1 ? 'is' : 'are'} provided by the Node host ` +
`and NOT by the sandbox, so ${nodeOnly.length === 1 ? 'it' : 'they'} cannot be inlined: ` +
`keep the check in a string handler ref, or move it to a validation rule.`;
const inlineRemedy =
`Inline the value(s) into the handler, or reach them through \`ctx\`.`;
const remedy =
nodeOnly.length === 0
? inlineRemedy
: moduleScope.length === 0
? hostRemedy
: `${hostRemedy} ${moduleScope.join(', ')} ${moduleScope.length === 1 ? 'is' : 'are'} ` +
`module-scope: inline ${moduleScope.length === 1 ? 'it' : 'them'} into the handler, ` +
`or reach ${moduleScope.length === 1 ? 'it' : 'them'} through \`ctx\`.`;
return {
severity: 'error',
rule: NOT_LOWERABLE_RULE,
Expand All@@ -143,8 +177,7 @@ function judge(fn: AnyFn, originLabel: string, path: string): HookBodyLintIssue
`${free.length === 1 ? 'the identifier' : 'identifiers'} ${free.join(', ')}, which ` +
`${free.length === 1 ? 'is' : 'are'} not in scope inside the sandbox. The handler is ` +
`BUNDLED instead, so this app is no longer shippable as pure metadata — a change of ` +
`deployment shape, not of behaviour. Inline the value(s) into the handler, or reach ` +
`them through \`ctx\`. ${DELIBERATE_BUNDLE_REMEDY}`,
`deployment shape, not of behaviour. ${remedy} ${DELIBERATE_BUNDLE_REMEDY}`,
path,
};
}
Expand Down
89 changes: 88 additions & 1 deletion packages/cli/src/utils/detect-free-identifiers.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { detectFreeIdentifiers } from './detect-free-identifiers.js';
import {
detectFreeIdentifiers,
NODE_ONLY_GLOBALS,
SANDBOX_GLOBALS,
} from './detect-free-identifiers.js';

/** Helper: stringify a real function so we test the exact `.toString()` path. */
const src = (fn: (...a: any[]) => any) => String(fn);
Expand DownExpand Up@@ -53,6 +57,14 @@ describe('detectFreeIdentifiers (#1876 — body self-containment)', () => {
['nested destructuring', '({ a: { b } }) => b + 1'],
['rest params', '(...args) => args.length'],
['typeof a local', '(ctx) => { const v = ctx.v; return typeof v; }'],
// #14301 — the node-only refusal is a SCOPE analysis, not a token scan.
// A locally-bound name that happens to spell a host global is bound, and
// a member NAMED after one is not a reference at all. Both would be
// false refusals, and a false refusal here costs an author a body they
// were entitled to.
['a local shadowing a host global', '(ctx) => { const Intl = ctx.fmt; return Intl.format(ctx.d); }'],
['a member named after a host global', '(ctx) => { return ctx.Intl.format(ctx.d); }'],
['a param named after a host global', '(ctx, Intl) => Intl.format(ctx.d)'],
];
for (const [label, source] of selfContained) {
it(label, () => {
Expand DownExpand Up@@ -81,4 +93,79 @@ describe('detectFreeIdentifiers (#1876 — body self-containment)', () => {
expect(detectFreeIdentifiers('').free).toEqual([]);
expect(detectFreeIdentifiers('{ not: valid').free).toEqual([]);
});
// ── #14301 — globals the NODE HOST has and the sandbox does not ──────────
//
// The reported shape: `Intl` sat in one generous allowlist beside `JSON`, so
// a handler calling `Intl.DateTimeFormat` had no free identifier, lowered
// into `body.source`, and threw `ReferenceError` in production while every
// local gate was green. The split makes the reference visible again; the
// membership of the two sets is not asserted here from knowledge — it is
// measured inside the real sandbox by `sandbox-globals-probe.test.ts`.
describe('reports host globals the sandbox does not provide', () => {
it('the card reproduction — Intl.DateTimeFormat', () => {
const r = detectFreeIdentifiers(
"(ctx) => { const f = new Intl.DateTimeFormat('en-US'); ctx.input.label = f.format(new Date(ctx.input.at)); }",
);
expect(r.unparsed).toBe(false);
expect(r.free).toEqual(['Intl']);
expect(r.nodeOnly).toEqual(['Intl']);
});

it('`nodeOnly` is a labelled SUBSET of `free`, not a second list', () => {
// Both halves at once. The caller needs them apart because the remedies
// are opposite — inline the helper, but a host global cannot be inlined
// — and needs them together because the refusal names every name.
const r = detectFreeIdentifiers('(ctx) => { ctx.x = slugify(ctx.name) + Intl.NumberFormat; }');
expect(r.free).toEqual(['Intl', 'slugify']);
expect(r.nodeOnly).toEqual(['Intl']);
expect(r.nodeOnly.every((n) => r.free.includes(n))).toBe(true);
});

it('a sandbox-provided global is still waived — the positive control', () => {
const r = detectFreeIdentifiers('(ctx) => { ctx.x = JSON.stringify(Math.round(ctx.n)); }');
expect(r.free).toEqual([]);
expect(r.nodeOnly).toEqual([]);
});

it('every member of NODE_ONLY_GLOBALS is reported when referenced free', () => {
// Set-wide rather than per-name: a member added to the set without the
// detector reading it would otherwise sit inert, which is the exact
// shape of the defect being closed one level up.
for (const name of NODE_ONLY_GLOBALS) {
const r = detectFreeIdentifiers(`(ctx) => { ctx.x = ${name}; }`);
expect({ name, free: r.free, nodeOnly: r.nodeOnly }).toEqual({
name,
free: [name],
nodeOnly: [name],
});
}
});

it('every member of SANDBOX_GLOBALS is waived when referenced free', () => {
for (const name of SANDBOX_GLOBALS) {
const r = detectFreeIdentifiers(`(ctx) => { ctx.x = ${name}; }`);
expect({ name, free: r.free, nodeOnly: r.nodeOnly }).toEqual({
name,
free: [],
nodeOnly: [],
});
}
});

it('junk input reports neither list (conservative — never blocks extraction)', () => {
// The bias the file's header states, restated over the NEW field: a
// source this analysis cannot make sense of must not produce a refusal.
// Asserted over the same three junk shapes the #1876 case uses, and
// without asserting `unparsed` — TS error-recovery decides that, and the
// invariant that matters is that neither list fills.
for (const junk of ['this is not a function', '', '{ not: valid']) {
const r = detectFreeIdentifiers(junk);
expect({ junk, free: r.free, nodeOnly: r.nodeOnly }).toEqual({
junk,
free: [],
nodeOnly: [],
});
}
});
});
});
Loading
Loading