fix(cli): refuse to lower hook bodies that reference globals the sandbox does not provide (#14301) - #14661

Merged
os-trump merged 2 commits into
mainfrom
claude/issue-14301-sandbox-globals-allowlist
Sep 2, 2026
Merged

fix(cli): refuse to lower hook bodies that reference globals the sandbox does not provide (#14301)#14661
os-trump merged 2 commits into
mainfrom
claude/issue-14301-sandbox-globals-allowlist

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#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 the reported handler had no free identifier at all, lowered into body.source, passed every local gate, and threw ReferenceError in production.

This splits the allowlist in two, measures the membership inside the real sandbox, and turns a free reference to a host-only name into a lowering refusal that names the identifier and a remedy that is actually possible.

The measurement

packages/cli/src/utils/sandbox-globals-probe.test.ts evaluates, for every member of both sets, typeof X !== 'undefined' || 'X' in globalThisinside a real QuickJSScriptRunner — the same ScriptRunnerAppPlugin wires for hook and action bodies, on the same runScript path, with the same empty capability set a body with no inferred capabilities gets. The pin fails unless each set is exactly the probe's partition, so a name added from memory reddens it.

Two limbs per name, not one: typeof X alone reports the single global whose VALUE is undefinedundefined itself — as absent, and would have demanded it be listed host-only. 'X' in globalThis asks existence instead of value.

Measured present — SANDBOX_GLOBALS (53):

Math JSON Date Object Array String Number Boolean RegExp Map Set WeakMap WeakSet
Promise Symbol BigInt Function Reflect Proxy
ArrayBuffer SharedArrayBuffer DataView
Int8Array Uint8Array Uint8ClampedArray Int16Array Uint16Array Int32Array
Uint32Array Float32Array Float64Array BigInt64Array BigUint64Array
Error TypeError RangeError SyntaxError ReferenceError EvalError URIError AggregateError
parseInt parseFloat isNaN isFinite encodeURIComponent decodeURIComponent encodeURI decodeURI
undefined NaN Infinity globalThis

Measured absent — NODE_ONLY_GLOBALS (15):

Intl
structuredClone queueMicrotask atob btoa
setTimeout clearTimeout setInterval clearInterval
URL URLSearchParams TextEncoder TextDecoder
console
arguments

53 + 15 = 68 = the size of the GLOBALS set this replaces, and the two sets are disjoint — no name was dropped or invented in the split. Three of the readings are worth stating out loud:

  • Intl is the reported one: ECMA-402, standard in every browser and in Node, absent here.
  • console is a HOST object; the sandbox deliberately routes logging through the capability-gated ctx.log (buildBodyLogSurface in the runtime's body runner) and installs no global. So the refusal for console also names ctx.log — the one entry in a deliberately closed replacement table, because "keep it in a string handler ref" is poor advice for a log line when the platform's own answer is one capability away.
  • arguments is the one member that is not a global at all. It is an implicit binding of ordinary function scope, and the runner wraps a lowered body in an ARROW ((async (ctx) => { … })(ctx)), which provides none. It measures absent for a different reason than the rest and is refused for the same one: a function (ctx) { … arguments … } handler works in-process and throws once lowered.

Red-first

The card's reproduction, run against byte-for-byte copies of the two base (HEAD) sources so the reading was about the tree as it was rather than the working tree:

BASE_FREE=[] unparsed=false
BASE_OUTCOME=LOWERED
BASE_BODY_SOURCE="\n\tconst fmt = new Intl.DateTimeFormat(\"en-US\", { timeZone: \"UTC\" });\n\tctx.input.due_label = fmt.format(new Date(ctx.input.due_at));\n"

No free identifier, extraction succeeded, and the emitted body.source is the Intl call verbatim. After the change the same handler is REFUSED (kind: 'free-identifiers', freeIdentifiers: ['Intl'], nodeOnlyIdentifiers: ['Intl']), pinned in test/extract-hook-body.test.ts.

The named refusal

[hook-body-extract] hook 'stamp_due_label': handler references identifier(s) not in scope at
runtime: Intl. Intl is not available in the hook sandbox — the QuickJS build the runtime
evaluates a lowered body in does not provide it, so the body would throw ReferenceError in
production while validate, typecheck, test and build all stay green (they run the raw function
in Node, where it exists). This handler will be BUNDLED instead (no behavior change). To keep
it as metadata, keep the check in a string handler ref — put the function in the top-level
`functions:` map and write `handler: 'fn_name'` — or move it to a validation rule.

The module-scope sentence is unchanged and pinned byte-identical (content/docs/automation/hook-bodies.mdx, os build's warn-and-bundle line and --strict-body's diagnostic all quote it); the host-only branch is additional prose for a case that could not previously arrive here at all.

Ablation — the probe pin can fail

One measured-absent global moved into the sandbox set on disk, on the committed tree:

HEAD_BLOB=e8c163f85d2bf3d42a30840ff4f6e6f81abbc7be
PRE_WORKTREE_HASH=e8c163f85d2bf3d42a30840ff4f6e6f81abbc7be
PRE_removed_anchor=1 PRE_injected_marker=0 # the exact text about to change
POST_removed_anchor=0 POST_injected_marker=1 # the mutation, observed on disk
POST_WORKTREE_HASH=5ae13309b3124bedfcc7d12fd5743dde5c737334
ABLATION_PIN_EXIT=1
FAIL src/utils/sandbox-globals-probe.test.ts > the two sets are exactly the sandbox
present/absent partition
AssertionError: expected [ …(51) ] to deeply equal [ …(52) ]
POST_RESTORE_HASH=e8c163f85d2bf3d42a30840ff4f6e6f81abbc7be # == HEAD_BLOB
RESTORE_DIFF_LINES=0 RESTORE_marker_left=0

No rebuild leg: the mutated module is CLI source, imported relatively by the pin, so vitest reads it straight from src — and the red is itself the proof the mutation reached the running code. Restore is trapped (trap … EXIT INT TERM, absolute paths) and proved by bytes, not by the trap firing.

Why free-identifiers and not a new refusal kind

forbidden-token's prose fits the sandbox ("the body uses something the sandbox cannot provide") but its severity does not: that class means "writing fetch( IS choosing a bundled closure", and os lint keeps it a warning so the legitimate path is not punished. Intl is a standard global in every browser and in Node — writing it is not the recognisable "I am reaching for the host" act. It is the ACCIDENTAL class #13651 already defines, so it is an error a gate can fail on, travelling the path that already exists. No new kind, no change to os build's accept set: lowerCallables still catches, still bundles, still exits 0.

The one lint edit is the remedy sentence, and it is load-bearing rather than cosmetic: "inline the value(s) into the handler" is impossible for a host global, and an author — or a code-writing model — told to inline Intl lands on a second broken shape. The sentence is chosen from the refusal's own nodeOnlyIdentifiers, never re-derived in the lint, so the rule cannot disagree with what os build did to the same handler. The module-scope sentence is pinned unchanged in the same file.

Corpus reading (measured, no edit)

Whether any in-repo hook body would be newly refused: none. Scanned the corpus checkHookBodyLowering walks — all 10 objectstack.config.ts roots plus every handler: / target: under examples/ and apps/. Two inline function handlers exist (examples/app-crm/src/hooks/opportunity.hook.ts, examples/app-todo/src/objects/task.hook.ts); they use Date, Error, String only — all measured present. git grep -n "\bIntl\b" -- examples apps returns nothing, and the only URL hits in examples/app-showcase/objectstack.config.ts are in comments. examples/app-showcase already ships its callables through the functions: map by name, which is never lowered.

Changeset level

patch on @objectstack/cli. No published accept-set moves — the metadata a valid app may declare is identical, HookBodySchema is untouched, 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 affected app gains a warning plus a working bundled closure in place of a production ReferenceError. The deployment shape it "loses" was never one it had in working order, and the measured corpus of affected in-repo sources is zero.

Verification

All at f97d3b85c4 (the final commit; the gate union was derived and run on this same tree).

whatcommandresult
buildpnpm exec turbo run build --concurrency=2 --filter=@objectstack/cli + the 13 example/plugin filters the i18n gate namesexit 0
typecheckpnpm --filter @objectstack/cli run typecheckexit 0
affected filespnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 over detect-free-identifiers, sandbox-globals-probe, hook-body-lowering, extract-hook-body, lower-callables, hook-body-build-reach.e2e, vitest-tiers-partition7 files, 94 tests, all passed
package tierpnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2159 files, 2093 tests, all passed
gate unionnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands -> 37 commands, each run with its exit captured before any pipe35 exit 0, 2 NOT MEASURED
repo lintpnpm lint (eslint . --no-inline-config)exit 0

--ran reconciliation: the 37 derived commands were run 1:1, no additions. pnpm check:nul-bytes was run beyond the derived union (any edit implies it) and is green, plus a direct control-byte scan of the eight changed files (no hits).

NOT MEASURED (2), in the gates' own words — neither is a red:

  • node scripts/check-test-completeness.mjs — exit 3. "Nothing was measured: this gate exited before parsing a single summary line ... ⛔ It is not a red, and there is nothing here to fix. Fix: pass a saved turbo run test log — or, running the family locally, record this gate as NOT MEASURED."
  • node scripts/pm/check-half-states.mjs — exit 3. "Treat this exit as an unread instrument, never as a quiet board" — a PM-board census over the GitHub API, unrelated to this diff. Its sibling pnpm check:pm-half-states (also in the union) exits 0.

Two more first returned a prerequisite verdict rather than a result on a worktree built for the cli graph alone, and are reported green after building the closure they name: pnpm check:dual-build-cjs-loads ("PREREQUISITE NOT MET ... ⛔ This is NOT a pass: nothing was measured") and pnpm check:i18n-coverage ("COULD NOT MEASURE — 1 of 13 config(s) failed to lint", a missing @objectstack/connector-mcp dist). The second one matters here beyond bookkeeping: it runs os lint over all 13 in-repo configs, so its green is a direct reading that this change makes os lint report nothing new on any of them.

Deviations from the dispatch

  1. packages/cli/src/utils/extract-hook-body.ts is in the diff. The dispatch's IN list named the detector, its test and the lint rule; the refusal is thrown in extract-hook-body, and "routed through the existing refusal path" cannot be done without composing the reason there. No new refusal kind, no change to what os build accepts, and the pre-existing sentence is pinned byte-identical.
  2. The lint rule was touched, which the dispatch preferred to avoid. Only the remedy sentence, and only because the existing one ("inline the value(s)") is impossible to follow for a host global — argued above. The module-scope remedy is pinned unchanged in the same file.
  3. The probe pin is its own file rather than an addition to detect-free-identifiers.test.ts: it boots a WASM VM and imports @objectstack/runtime, and keeping it separate leaves the pure-AST unit file fast and independent of build state. It classifies as unit under the test.projects partition that landed today (pnpm --filter @objectstack/cli test is a ~24-minute serialized run, and on a shared agent container it holds the verify lock for the whole of it #13504's predicate is SPAWN ∨ KERNEL; this file is neither), and test/vitest-tiers-partition.test.ts passes on this branch.
  4. Two runs were NARROWED and executed outside the shared verify lock, declared here: (a) the residual build after a locked run was cut short by the container's foreground cap — 26 of 28 turbo tasks were already cached — plus the two temporary measurement harnesses; (b) the first typecheck and targeted six-file vitest run. Cumulative queue time on the lock was about 45 minutes, with one holder resident 1190s and another 611s. Everything in the verification table above was re-run at f97d3b85c4 with the build and the two test runs UNDER the lock.check:* gate scripts and pnpm lint are outside the lock's coverage by its own definition, so those ran unlocked by design.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

…lobals (#14301)
`detect-free-identifiers`' ambient allowlist was one generous list documented
as "assume the runtime has it". The runtime a lowered body runs in is the
QuickJS sandbox, not the Node process that runs `objectstack build` — and the
list named `Intl` beside `JSON`. A handler calling `Intl.DateTimeFormat`
therefore had no free identifier: it lowered into `body.source`, the #13651
lint rule had nothing to report (it fires only on a refused lowering), every
local gate was green because the in-process test runs the raw function in Node,
and production threw `ReferenceError: Intl is not defined`.
Split the allowlist into `SANDBOX_GLOBALS` (53) and `NODE_ONLY_GLOBALS` (15),
with membership MEASURED by a `typeof`/`in globalThis` probe evaluated inside
the same `QuickJSScriptRunner` the runtime uses and pinned by a test that reads
that probe. A free reference to a host-only name is now reported and refused
with a reason that names the identifier and the remedy — a string handler ref
or a validation rule, and `ctx.log` for `console` — travelling the existing
`free-identifiers` path, so `lowerCallables` still bundles the callable and
`os build` still exits 0 with a warning.
Not changed: whether `os build` fails on the lowering class (#13838), what the
sandbox provides, and anything under `packages/runtime/**`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 13 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/hook-bodies.mdx(via globalThis (literal, a string literal in GLOBALS; a string literal in SANDBOX_GLOBALS))
What this run could not see
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2aa8456cf2d66ec3825d262686fe4218e57cfd27packageMentionDocs.

Which tree this was computed on

This run read content/docs from bcec761c21b97b1dc8ed9cd61f527e6eb4fa6653 — the merge of head f97d3b85c46399f2ccd02ec4c084a6278be20970 into base 2aa8456cf2d66ec3825d262686fe4218e57cfd27, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin bcec761c21b97b1dc8ed9cd61f527e6eb4fa6653 && git checkout bcec761c21b97b1dc8ed9cd61f527e6eb4fa6653
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2aa8456cf2d66ec3825d262686fe4218e57cfd27 f97d3b85c46399f2ccd02ec4c084a6278be20970 && git checkout -B drift-repro 2aa8456cf2d66ec3825d262686fe4218e57cfd27 && git merge --no-ff f97d3b85c46399f2ccd02ec4c084a6278be20970
node scripts/docs-audit/affected-docs.mjs --json 2aa8456cf2d66ec3825d262686fe4218e57cfd27

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2aa8456cf2d66ec3825d262686fe4218e57cfd27 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-trump@claude
, '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

fix(cli): refuse to lower hook bodies that reference globals the sandbox does not provide (#14301) - #14661

Merged
os-trump merged 2 commits into
mainfrom
claude/issue-14301-sandbox-globals-allowlist
Sep 2, 2026
Merged

fix(cli): refuse to lower hook bodies that reference globals the sandbox does not provide (#14301)#14661
os-trump merged 2 commits into
mainfrom
claude/issue-14301-sandbox-globals-allowlist

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#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 the reported handler had no free identifier at all, lowered into body.source, passed every local gate, and threw ReferenceError in production.

This splits the allowlist in two, measures the membership inside the real sandbox, and turns a free reference to a host-only name into a lowering refusal that names the identifier and a remedy that is actually possible.

The measurement

packages/cli/src/utils/sandbox-globals-probe.test.ts evaluates, for every member of both sets, typeof X !== 'undefined' || 'X' in globalThisinside a real QuickJSScriptRunner — the same ScriptRunnerAppPlugin wires for hook and action bodies, on the same runScript path, with the same empty capability set a body with no inferred capabilities gets. The pin fails unless each set is exactly the probe's partition, so a name added from memory reddens it.

Two limbs per name, not one: typeof X alone reports the single global whose VALUE is undefinedundefined itself — as absent, and would have demanded it be listed host-only. 'X' in globalThis asks existence instead of value.

Measured present — SANDBOX_GLOBALS (53):

Math JSON Date Object Array String Number Boolean RegExp Map Set WeakMap WeakSet
Promise Symbol BigInt Function Reflect Proxy
ArrayBuffer SharedArrayBuffer DataView
Int8Array Uint8Array Uint8ClampedArray Int16Array Uint16Array Int32Array
Uint32Array Float32Array Float64Array BigInt64Array BigUint64Array
Error TypeError RangeError SyntaxError ReferenceError EvalError URIError AggregateError
parseInt parseFloat isNaN isFinite encodeURIComponent decodeURIComponent encodeURI decodeURI
undefined NaN Infinity globalThis

Measured absent — NODE_ONLY_GLOBALS (15):

Intl
structuredClone queueMicrotask atob btoa
setTimeout clearTimeout setInterval clearInterval
URL URLSearchParams TextEncoder TextDecoder
console
arguments

53 + 15 = 68 = the size of the GLOBALS set this replaces, and the two sets are disjoint — no name was dropped or invented in the split. Three of the readings are worth stating out loud:

  • Intl is the reported one: ECMA-402, standard in every browser and in Node, absent here.
  • console is a HOST object; the sandbox deliberately routes logging through the capability-gated ctx.log (buildBodyLogSurface in the runtime's body runner) and installs no global. So the refusal for console also names ctx.log — the one entry in a deliberately closed replacement table, because "keep it in a string handler ref" is poor advice for a log line when the platform's own answer is one capability away.
  • arguments is the one member that is not a global at all. It is an implicit binding of ordinary function scope, and the runner wraps a lowered body in an ARROW ((async (ctx) => { … })(ctx)), which provides none. It measures absent for a different reason than the rest and is refused for the same one: a function (ctx) { … arguments … } handler works in-process and throws once lowered.

Red-first

The card's reproduction, run against byte-for-byte copies of the two base (HEAD) sources so the reading was about the tree as it was rather than the working tree:

BASE_FREE=[] unparsed=false
BASE_OUTCOME=LOWERED
BASE_BODY_SOURCE="\n\tconst fmt = new Intl.DateTimeFormat(\"en-US\", { timeZone: \"UTC\" });\n\tctx.input.due_label = fmt.format(new Date(ctx.input.due_at));\n"

No free identifier, extraction succeeded, and the emitted body.source is the Intl call verbatim. After the change the same handler is REFUSED (kind: 'free-identifiers', freeIdentifiers: ['Intl'], nodeOnlyIdentifiers: ['Intl']), pinned in test/extract-hook-body.test.ts.

The named refusal

[hook-body-extract] hook 'stamp_due_label': handler references identifier(s) not in scope at
runtime: Intl. Intl is not available in the hook sandbox — the QuickJS build the runtime
evaluates a lowered body in does not provide it, so the body would throw ReferenceError in
production while validate, typecheck, test and build all stay green (they run the raw function
in Node, where it exists). This handler will be BUNDLED instead (no behavior change). To keep
it as metadata, keep the check in a string handler ref — put the function in the top-level
`functions:` map and write `handler: 'fn_name'` — or move it to a validation rule.

The module-scope sentence is unchanged and pinned byte-identical (content/docs/automation/hook-bodies.mdx, os build's warn-and-bundle line and --strict-body's diagnostic all quote it); the host-only branch is additional prose for a case that could not previously arrive here at all.

Ablation — the probe pin can fail

One measured-absent global moved into the sandbox set on disk, on the committed tree:

HEAD_BLOB=e8c163f85d2bf3d42a30840ff4f6e6f81abbc7be
PRE_WORKTREE_HASH=e8c163f85d2bf3d42a30840ff4f6e6f81abbc7be
PRE_removed_anchor=1 PRE_injected_marker=0 # the exact text about to change
POST_removed_anchor=0 POST_injected_marker=1 # the mutation, observed on disk
POST_WORKTREE_HASH=5ae13309b3124bedfcc7d12fd5743dde5c737334
ABLATION_PIN_EXIT=1
FAIL src/utils/sandbox-globals-probe.test.ts > the two sets are exactly the sandbox
present/absent partition
AssertionError: expected [ …(51) ] to deeply equal [ …(52) ]
POST_RESTORE_HASH=e8c163f85d2bf3d42a30840ff4f6e6f81abbc7be # == HEAD_BLOB
RESTORE_DIFF_LINES=0 RESTORE_marker_left=0

No rebuild leg: the mutated module is CLI source, imported relatively by the pin, so vitest reads it straight from src — and the red is itself the proof the mutation reached the running code. Restore is trapped (trap … EXIT INT TERM, absolute paths) and proved by bytes, not by the trap firing.

Why free-identifiers and not a new refusal kind

forbidden-token's prose fits the sandbox ("the body uses something the sandbox cannot provide") but its severity does not: that class means "writing fetch( IS choosing a bundled closure", and os lint keeps it a warning so the legitimate path is not punished. Intl is a standard global in every browser and in Node — writing it is not the recognisable "I am reaching for the host" act. It is the ACCIDENTAL class #13651 already defines, so it is an error a gate can fail on, travelling the path that already exists. No new kind, no change to os build's accept set: lowerCallables still catches, still bundles, still exits 0.

The one lint edit is the remedy sentence, and it is load-bearing rather than cosmetic: "inline the value(s) into the handler" is impossible for a host global, and an author — or a code-writing model — told to inline Intl lands on a second broken shape. The sentence is chosen from the refusal's own nodeOnlyIdentifiers, never re-derived in the lint, so the rule cannot disagree with what os build did to the same handler. The module-scope sentence is pinned unchanged in the same file.

Corpus reading (measured, no edit)

Whether any in-repo hook body would be newly refused: none. Scanned the corpus checkHookBodyLowering walks — all 10 objectstack.config.ts roots plus every handler: / target: under examples/ and apps/. Two inline function handlers exist (examples/app-crm/src/hooks/opportunity.hook.ts, examples/app-todo/src/objects/task.hook.ts); they use Date, Error, String only — all measured present. git grep -n "\bIntl\b" -- examples apps returns nothing, and the only URL hits in examples/app-showcase/objectstack.config.ts are in comments. examples/app-showcase already ships its callables through the functions: map by name, which is never lowered.

Changeset level

patch on @objectstack/cli. No published accept-set moves — the metadata a valid app may declare is identical, HookBodySchema is untouched, 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 affected app gains a warning plus a working bundled closure in place of a production ReferenceError. The deployment shape it "loses" was never one it had in working order, and the measured corpus of affected in-repo sources is zero.

Verification

All at f97d3b85c4 (the final commit; the gate union was derived and run on this same tree).

whatcommandresult
buildpnpm exec turbo run build --concurrency=2 --filter=@objectstack/cli + the 13 example/plugin filters the i18n gate namesexit 0
typecheckpnpm --filter @objectstack/cli run typecheckexit 0
affected filespnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 over detect-free-identifiers, sandbox-globals-probe, hook-body-lowering, extract-hook-body, lower-callables, hook-body-build-reach.e2e, vitest-tiers-partition7 files, 94 tests, all passed
package tierpnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2159 files, 2093 tests, all passed
gate unionnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands -> 37 commands, each run with its exit captured before any pipe35 exit 0, 2 NOT MEASURED
repo lintpnpm lint (eslint . --no-inline-config)exit 0

--ran reconciliation: the 37 derived commands were run 1:1, no additions. pnpm check:nul-bytes was run beyond the derived union (any edit implies it) and is green, plus a direct control-byte scan of the eight changed files (no hits).

NOT MEASURED (2), in the gates' own words — neither is a red:

  • node scripts/check-test-completeness.mjs — exit 3. "Nothing was measured: this gate exited before parsing a single summary line ... ⛔ It is not a red, and there is nothing here to fix. Fix: pass a saved turbo run test log — or, running the family locally, record this gate as NOT MEASURED."
  • node scripts/pm/check-half-states.mjs — exit 3. "Treat this exit as an unread instrument, never as a quiet board" — a PM-board census over the GitHub API, unrelated to this diff. Its sibling pnpm check:pm-half-states (also in the union) exits 0.

Two more first returned a prerequisite verdict rather than a result on a worktree built for the cli graph alone, and are reported green after building the closure they name: pnpm check:dual-build-cjs-loads ("PREREQUISITE NOT MET ... ⛔ This is NOT a pass: nothing was measured") and pnpm check:i18n-coverage ("COULD NOT MEASURE — 1 of 13 config(s) failed to lint", a missing @objectstack/connector-mcp dist). The second one matters here beyond bookkeeping: it runs os lint over all 13 in-repo configs, so its green is a direct reading that this change makes os lint report nothing new on any of them.

Deviations from the dispatch

  1. packages/cli/src/utils/extract-hook-body.ts is in the diff. The dispatch's IN list named the detector, its test and the lint rule; the refusal is thrown in extract-hook-body, and "routed through the existing refusal path" cannot be done without composing the reason there. No new refusal kind, no change to what os build accepts, and the pre-existing sentence is pinned byte-identical.
  2. The lint rule was touched, which the dispatch preferred to avoid. Only the remedy sentence, and only because the existing one ("inline the value(s)") is impossible to follow for a host global — argued above. The module-scope remedy is pinned unchanged in the same file.
  3. The probe pin is its own file rather than an addition to detect-free-identifiers.test.ts: it boots a WASM VM and imports @objectstack/runtime, and keeping it separate leaves the pure-AST unit file fast and independent of build state. It classifies as unit under the test.projects partition that landed today (pnpm --filter @objectstack/cli test is a ~24-minute serialized run, and on a shared agent container it holds the verify lock for the whole of it #13504's predicate is SPAWN ∨ KERNEL; this file is neither), and test/vitest-tiers-partition.test.ts passes on this branch.
  4. Two runs were NARROWED and executed outside the shared verify lock, declared here: (a) the residual build after a locked run was cut short by the container's foreground cap — 26 of 28 turbo tasks were already cached — plus the two temporary measurement harnesses; (b) the first typecheck and targeted six-file vitest run. Cumulative queue time on the lock was about 45 minutes, with one holder resident 1190s and another 611s. Everything in the verification table above was re-run at f97d3b85c4 with the build and the two test runs UNDER the lock.check:* gate scripts and pnpm lint are outside the lock's coverage by its own definition, so those ran unlocked by design.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

…lobals (#14301)
`detect-free-identifiers`' ambient allowlist was one generous list documented
as "assume the runtime has it". The runtime a lowered body runs in is the
QuickJS sandbox, not the Node process that runs `objectstack build` — and the
list named `Intl` beside `JSON`. A handler calling `Intl.DateTimeFormat`
therefore had no free identifier: it lowered into `body.source`, the #13651
lint rule had nothing to report (it fires only on a refused lowering), every
local gate was green because the in-process test runs the raw function in Node,
and production threw `ReferenceError: Intl is not defined`.
Split the allowlist into `SANDBOX_GLOBALS` (53) and `NODE_ONLY_GLOBALS` (15),
with membership MEASURED by a `typeof`/`in globalThis` probe evaluated inside
the same `QuickJSScriptRunner` the runtime uses and pinned by a test that reads
that probe. A free reference to a host-only name is now reported and refused
with a reason that names the identifier and the remedy — a string handler ref
or a validation rule, and `ctx.log` for `console` — travelling the existing
`free-identifiers` path, so `lowerCallables` still bundles the callable and
`os build` still exits 0 with a warning.
Not changed: whether `os build` fails on the lowering class (#13838), what the
sandbox provides, and anything under `packages/runtime/**`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 13 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/hook-bodies.mdx(via globalThis (literal, a string literal in GLOBALS; a string literal in SANDBOX_GLOBALS))
What this run could not see
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2aa8456cf2d66ec3825d262686fe4218e57cfd27packageMentionDocs.

Which tree this was computed on

This run read content/docs from bcec761c21b97b1dc8ed9cd61f527e6eb4fa6653 — the merge of head f97d3b85c46399f2ccd02ec4c084a6278be20970 into base 2aa8456cf2d66ec3825d262686fe4218e57cfd27, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin bcec761c21b97b1dc8ed9cd61f527e6eb4fa6653 && git checkout bcec761c21b97b1dc8ed9cd61f527e6eb4fa6653
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2aa8456cf2d66ec3825d262686fe4218e57cfd27 f97d3b85c46399f2ccd02ec4c084a6278be20970 && git checkout -B drift-repro 2aa8456cf2d66ec3825d262686fe4218e57cfd27 && git merge --no-ff f97d3b85c46399f2ccd02ec4c084a6278be20970
node scripts/docs-audit/affected-docs.mjs --json 2aa8456cf2d66ec3825d262686fe4218e57cfd27

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2aa8456cf2d66ec3825d262686fe4218e57cfd27 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-trump@claude
, '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

fix(cli): refuse to lower hook bodies that reference globals the sandbox does not provide (#14301) - #14661

Merged
os-trump merged 2 commits into
mainfrom
claude/issue-14301-sandbox-globals-allowlist
Sep 2, 2026
Merged

fix(cli): refuse to lower hook bodies that reference globals the sandbox does not provide (#14301)#14661
os-trump merged 2 commits into
mainfrom
claude/issue-14301-sandbox-globals-allowlist

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#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 the reported handler had no free identifier at all, lowered into body.source, passed every local gate, and threw ReferenceError in production.

This splits the allowlist in two, measures the membership inside the real sandbox, and turns a free reference to a host-only name into a lowering refusal that names the identifier and a remedy that is actually possible.

The measurement

packages/cli/src/utils/sandbox-globals-probe.test.ts evaluates, for every member of both sets, typeof X !== 'undefined' || 'X' in globalThisinside a real QuickJSScriptRunner — the same ScriptRunnerAppPlugin wires for hook and action bodies, on the same runScript path, with the same empty capability set a body with no inferred capabilities gets. The pin fails unless each set is exactly the probe's partition, so a name added from memory reddens it.

Two limbs per name, not one: typeof X alone reports the single global whose VALUE is undefinedundefined itself — as absent, and would have demanded it be listed host-only. 'X' in globalThis asks existence instead of value.

Measured present — SANDBOX_GLOBALS (53):

Math JSON Date Object Array String Number Boolean RegExp Map Set WeakMap WeakSet
Promise Symbol BigInt Function Reflect Proxy
ArrayBuffer SharedArrayBuffer DataView
Int8Array Uint8Array Uint8ClampedArray Int16Array Uint16Array Int32Array
Uint32Array Float32Array Float64Array BigInt64Array BigUint64Array
Error TypeError RangeError SyntaxError ReferenceError EvalError URIError AggregateError
parseInt parseFloat isNaN isFinite encodeURIComponent decodeURIComponent encodeURI decodeURI
undefined NaN Infinity globalThis

Measured absent — NODE_ONLY_GLOBALS (15):

Intl
structuredClone queueMicrotask atob btoa
setTimeout clearTimeout setInterval clearInterval
URL URLSearchParams TextEncoder TextDecoder
console
arguments

53 + 15 = 68 = the size of the GLOBALS set this replaces, and the two sets are disjoint — no name was dropped or invented in the split. Three of the readings are worth stating out loud:

  • Intl is the reported one: ECMA-402, standard in every browser and in Node, absent here.
  • console is a HOST object; the sandbox deliberately routes logging through the capability-gated ctx.log (buildBodyLogSurface in the runtime's body runner) and installs no global. So the refusal for console also names ctx.log — the one entry in a deliberately closed replacement table, because "keep it in a string handler ref" is poor advice for a log line when the platform's own answer is one capability away.
  • arguments is the one member that is not a global at all. It is an implicit binding of ordinary function scope, and the runner wraps a lowered body in an ARROW ((async (ctx) => { … })(ctx)), which provides none. It measures absent for a different reason than the rest and is refused for the same one: a function (ctx) { … arguments … } handler works in-process and throws once lowered.

Red-first

The card's reproduction, run against byte-for-byte copies of the two base (HEAD) sources so the reading was about the tree as it was rather than the working tree:

BASE_FREE=[] unparsed=false
BASE_OUTCOME=LOWERED
BASE_BODY_SOURCE="\n\tconst fmt = new Intl.DateTimeFormat(\"en-US\", { timeZone: \"UTC\" });\n\tctx.input.due_label = fmt.format(new Date(ctx.input.due_at));\n"

No free identifier, extraction succeeded, and the emitted body.source is the Intl call verbatim. After the change the same handler is REFUSED (kind: 'free-identifiers', freeIdentifiers: ['Intl'], nodeOnlyIdentifiers: ['Intl']), pinned in test/extract-hook-body.test.ts.

The named refusal

[hook-body-extract] hook 'stamp_due_label': handler references identifier(s) not in scope at
runtime: Intl. Intl is not available in the hook sandbox — the QuickJS build the runtime
evaluates a lowered body in does not provide it, so the body would throw ReferenceError in
production while validate, typecheck, test and build all stay green (they run the raw function
in Node, where it exists). This handler will be BUNDLED instead (no behavior change). To keep
it as metadata, keep the check in a string handler ref — put the function in the top-level
`functions:` map and write `handler: 'fn_name'` — or move it to a validation rule.

The module-scope sentence is unchanged and pinned byte-identical (content/docs/automation/hook-bodies.mdx, os build's warn-and-bundle line and --strict-body's diagnostic all quote it); the host-only branch is additional prose for a case that could not previously arrive here at all.

Ablation — the probe pin can fail

One measured-absent global moved into the sandbox set on disk, on the committed tree:

HEAD_BLOB=e8c163f85d2bf3d42a30840ff4f6e6f81abbc7be
PRE_WORKTREE_HASH=e8c163f85d2bf3d42a30840ff4f6e6f81abbc7be
PRE_removed_anchor=1 PRE_injected_marker=0 # the exact text about to change
POST_removed_anchor=0 POST_injected_marker=1 # the mutation, observed on disk
POST_WORKTREE_HASH=5ae13309b3124bedfcc7d12fd5743dde5c737334
ABLATION_PIN_EXIT=1
FAIL src/utils/sandbox-globals-probe.test.ts > the two sets are exactly the sandbox
present/absent partition
AssertionError: expected [ …(51) ] to deeply equal [ …(52) ]
POST_RESTORE_HASH=e8c163f85d2bf3d42a30840ff4f6e6f81abbc7be # == HEAD_BLOB
RESTORE_DIFF_LINES=0 RESTORE_marker_left=0

No rebuild leg: the mutated module is CLI source, imported relatively by the pin, so vitest reads it straight from src — and the red is itself the proof the mutation reached the running code. Restore is trapped (trap … EXIT INT TERM, absolute paths) and proved by bytes, not by the trap firing.

Why free-identifiers and not a new refusal kind

forbidden-token's prose fits the sandbox ("the body uses something the sandbox cannot provide") but its severity does not: that class means "writing fetch( IS choosing a bundled closure", and os lint keeps it a warning so the legitimate path is not punished. Intl is a standard global in every browser and in Node — writing it is not the recognisable "I am reaching for the host" act. It is the ACCIDENTAL class #13651 already defines, so it is an error a gate can fail on, travelling the path that already exists. No new kind, no change to os build's accept set: lowerCallables still catches, still bundles, still exits 0.

The one lint edit is the remedy sentence, and it is load-bearing rather than cosmetic: "inline the value(s) into the handler" is impossible for a host global, and an author — or a code-writing model — told to inline Intl lands on a second broken shape. The sentence is chosen from the refusal's own nodeOnlyIdentifiers, never re-derived in the lint, so the rule cannot disagree with what os build did to the same handler. The module-scope sentence is pinned unchanged in the same file.

Corpus reading (measured, no edit)

Whether any in-repo hook body would be newly refused: none. Scanned the corpus checkHookBodyLowering walks — all 10 objectstack.config.ts roots plus every handler: / target: under examples/ and apps/. Two inline function handlers exist (examples/app-crm/src/hooks/opportunity.hook.ts, examples/app-todo/src/objects/task.hook.ts); they use Date, Error, String only — all measured present. git grep -n "\bIntl\b" -- examples apps returns nothing, and the only URL hits in examples/app-showcase/objectstack.config.ts are in comments. examples/app-showcase already ships its callables through the functions: map by name, which is never lowered.

Changeset level

patch on @objectstack/cli. No published accept-set moves — the metadata a valid app may declare is identical, HookBodySchema is untouched, 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 affected app gains a warning plus a working bundled closure in place of a production ReferenceError. The deployment shape it "loses" was never one it had in working order, and the measured corpus of affected in-repo sources is zero.

Verification

All at f97d3b85c4 (the final commit; the gate union was derived and run on this same tree).

whatcommandresult
buildpnpm exec turbo run build --concurrency=2 --filter=@objectstack/cli + the 13 example/plugin filters the i18n gate namesexit 0
typecheckpnpm --filter @objectstack/cli run typecheckexit 0
affected filespnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 over detect-free-identifiers, sandbox-globals-probe, hook-body-lowering, extract-hook-body, lower-callables, hook-body-build-reach.e2e, vitest-tiers-partition7 files, 94 tests, all passed
package tierpnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2159 files, 2093 tests, all passed
gate unionnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands -> 37 commands, each run with its exit captured before any pipe35 exit 0, 2 NOT MEASURED
repo lintpnpm lint (eslint . --no-inline-config)exit 0

--ran reconciliation: the 37 derived commands were run 1:1, no additions. pnpm check:nul-bytes was run beyond the derived union (any edit implies it) and is green, plus a direct control-byte scan of the eight changed files (no hits).

NOT MEASURED (2), in the gates' own words — neither is a red:

  • node scripts/check-test-completeness.mjs — exit 3. "Nothing was measured: this gate exited before parsing a single summary line ... ⛔ It is not a red, and there is nothing here to fix. Fix: pass a saved turbo run test log — or, running the family locally, record this gate as NOT MEASURED."
  • node scripts/pm/check-half-states.mjs — exit 3. "Treat this exit as an unread instrument, never as a quiet board" — a PM-board census over the GitHub API, unrelated to this diff. Its sibling pnpm check:pm-half-states (also in the union) exits 0.

Two more first returned a prerequisite verdict rather than a result on a worktree built for the cli graph alone, and are reported green after building the closure they name: pnpm check:dual-build-cjs-loads ("PREREQUISITE NOT MET ... ⛔ This is NOT a pass: nothing was measured") and pnpm check:i18n-coverage ("COULD NOT MEASURE — 1 of 13 config(s) failed to lint", a missing @objectstack/connector-mcp dist). The second one matters here beyond bookkeeping: it runs os lint over all 13 in-repo configs, so its green is a direct reading that this change makes os lint report nothing new on any of them.

Deviations from the dispatch

  1. packages/cli/src/utils/extract-hook-body.ts is in the diff. The dispatch's IN list named the detector, its test and the lint rule; the refusal is thrown in extract-hook-body, and "routed through the existing refusal path" cannot be done without composing the reason there. No new refusal kind, no change to what os build accepts, and the pre-existing sentence is pinned byte-identical.
  2. The lint rule was touched, which the dispatch preferred to avoid. Only the remedy sentence, and only because the existing one ("inline the value(s)") is impossible to follow for a host global — argued above. The module-scope remedy is pinned unchanged in the same file.
  3. The probe pin is its own file rather than an addition to detect-free-identifiers.test.ts: it boots a WASM VM and imports @objectstack/runtime, and keeping it separate leaves the pure-AST unit file fast and independent of build state. It classifies as unit under the test.projects partition that landed today (pnpm --filter @objectstack/cli test is a ~24-minute serialized run, and on a shared agent container it holds the verify lock for the whole of it #13504's predicate is SPAWN ∨ KERNEL; this file is neither), and test/vitest-tiers-partition.test.ts passes on this branch.
  4. Two runs were NARROWED and executed outside the shared verify lock, declared here: (a) the residual build after a locked run was cut short by the container's foreground cap — 26 of 28 turbo tasks were already cached — plus the two temporary measurement harnesses; (b) the first typecheck and targeted six-file vitest run. Cumulative queue time on the lock was about 45 minutes, with one holder resident 1190s and another 611s. Everything in the verification table above was re-run at f97d3b85c4 with the build and the two test runs UNDER the lock.check:* gate scripts and pnpm lint are outside the lock's coverage by its own definition, so those ran unlocked by design.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

…lobals (#14301)
`detect-free-identifiers`' ambient allowlist was one generous list documented
as "assume the runtime has it". The runtime a lowered body runs in is the
QuickJS sandbox, not the Node process that runs `objectstack build` — and the
list named `Intl` beside `JSON`. A handler calling `Intl.DateTimeFormat`
therefore had no free identifier: it lowered into `body.source`, the #13651
lint rule had nothing to report (it fires only on a refused lowering), every
local gate was green because the in-process test runs the raw function in Node,
and production threw `ReferenceError: Intl is not defined`.
Split the allowlist into `SANDBOX_GLOBALS` (53) and `NODE_ONLY_GLOBALS` (15),
with membership MEASURED by a `typeof`/`in globalThis` probe evaluated inside
the same `QuickJSScriptRunner` the runtime uses and pinned by a test that reads
that probe. A free reference to a host-only name is now reported and refused
with a reason that names the identifier and the remedy — a string handler ref
or a validation rule, and `ctx.log` for `console` — travelling the existing
`free-identifiers` path, so `lowerCallables` still bundles the callable and
`os build` still exits 0 with a warning.
Not changed: whether `os build` fails on the lowering class (#13838), what the
sandbox provides, and anything under `packages/runtime/**`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 13 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/hook-bodies.mdx(via globalThis (literal, a string literal in GLOBALS; a string literal in SANDBOX_GLOBALS))
What this run could not see
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2aa8456cf2d66ec3825d262686fe4218e57cfd27packageMentionDocs.

Which tree this was computed on

This run read content/docs from bcec761c21b97b1dc8ed9cd61f527e6eb4fa6653 — the merge of head f97d3b85c46399f2ccd02ec4c084a6278be20970 into base 2aa8456cf2d66ec3825d262686fe4218e57cfd27, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin bcec761c21b97b1dc8ed9cd61f527e6eb4fa6653 && git checkout bcec761c21b97b1dc8ed9cd61f527e6eb4fa6653
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2aa8456cf2d66ec3825d262686fe4218e57cfd27 f97d3b85c46399f2ccd02ec4c084a6278be20970 && git checkout -B drift-repro 2aa8456cf2d66ec3825d262686fe4218e57cfd27 && git merge --no-ff f97d3b85c46399f2ccd02ec4c084a6278be20970
node scripts/docs-audit/affected-docs.mjs --json 2aa8456cf2d66ec3825d262686fe4218e57cfd27

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2aa8456cf2d66ec3825d262686fe4218e57cfd27 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-trump@claude
, '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

fix(cli): refuse to lower hook bodies that reference globals the sandbox does not provide (#14301) - #14661

Merged
os-trump merged 2 commits into
mainfrom
claude/issue-14301-sandbox-globals-allowlist
Sep 2, 2026
Merged

fix(cli): refuse to lower hook bodies that reference globals the sandbox does not provide (#14301)#14661
os-trump merged 2 commits into
mainfrom
claude/issue-14301-sandbox-globals-allowlist

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#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 the reported handler had no free identifier at all, lowered into body.source, passed every local gate, and threw ReferenceError in production.

This splits the allowlist in two, measures the membership inside the real sandbox, and turns a free reference to a host-only name into a lowering refusal that names the identifier and a remedy that is actually possible.

The measurement

packages/cli/src/utils/sandbox-globals-probe.test.ts evaluates, for every member of both sets, typeof X !== 'undefined' || 'X' in globalThisinside a real QuickJSScriptRunner — the same ScriptRunnerAppPlugin wires for hook and action bodies, on the same runScript path, with the same empty capability set a body with no inferred capabilities gets. The pin fails unless each set is exactly the probe's partition, so a name added from memory reddens it.

Two limbs per name, not one: typeof X alone reports the single global whose VALUE is undefinedundefined itself — as absent, and would have demanded it be listed host-only. 'X' in globalThis asks existence instead of value.

Measured present — SANDBOX_GLOBALS (53):

Math JSON Date Object Array String Number Boolean RegExp Map Set WeakMap WeakSet
Promise Symbol BigInt Function Reflect Proxy
ArrayBuffer SharedArrayBuffer DataView
Int8Array Uint8Array Uint8ClampedArray Int16Array Uint16Array Int32Array
Uint32Array Float32Array Float64Array BigInt64Array BigUint64Array
Error TypeError RangeError SyntaxError ReferenceError EvalError URIError AggregateError
parseInt parseFloat isNaN isFinite encodeURIComponent decodeURIComponent encodeURI decodeURI
undefined NaN Infinity globalThis

Measured absent — NODE_ONLY_GLOBALS (15):

Intl
structuredClone queueMicrotask atob btoa
setTimeout clearTimeout setInterval clearInterval
URL URLSearchParams TextEncoder TextDecoder
console
arguments

53 + 15 = 68 = the size of the GLOBALS set this replaces, and the two sets are disjoint — no name was dropped or invented in the split. Three of the readings are worth stating out loud:

  • Intl is the reported one: ECMA-402, standard in every browser and in Node, absent here.
  • console is a HOST object; the sandbox deliberately routes logging through the capability-gated ctx.log (buildBodyLogSurface in the runtime's body runner) and installs no global. So the refusal for console also names ctx.log — the one entry in a deliberately closed replacement table, because "keep it in a string handler ref" is poor advice for a log line when the platform's own answer is one capability away.
  • arguments is the one member that is not a global at all. It is an implicit binding of ordinary function scope, and the runner wraps a lowered body in an ARROW ((async (ctx) => { … })(ctx)), which provides none. It measures absent for a different reason than the rest and is refused for the same one: a function (ctx) { … arguments … } handler works in-process and throws once lowered.

Red-first

The card's reproduction, run against byte-for-byte copies of the two base (HEAD) sources so the reading was about the tree as it was rather than the working tree:

BASE_FREE=[] unparsed=false
BASE_OUTCOME=LOWERED
BASE_BODY_SOURCE="\n\tconst fmt = new Intl.DateTimeFormat(\"en-US\", { timeZone: \"UTC\" });\n\tctx.input.due_label = fmt.format(new Date(ctx.input.due_at));\n"

No free identifier, extraction succeeded, and the emitted body.source is the Intl call verbatim. After the change the same handler is REFUSED (kind: 'free-identifiers', freeIdentifiers: ['Intl'], nodeOnlyIdentifiers: ['Intl']), pinned in test/extract-hook-body.test.ts.

The named refusal

[hook-body-extract] hook 'stamp_due_label': handler references identifier(s) not in scope at
runtime: Intl. Intl is not available in the hook sandbox — the QuickJS build the runtime
evaluates a lowered body in does not provide it, so the body would throw ReferenceError in
production while validate, typecheck, test and build all stay green (they run the raw function
in Node, where it exists). This handler will be BUNDLED instead (no behavior change). To keep
it as metadata, keep the check in a string handler ref — put the function in the top-level
`functions:` map and write `handler: 'fn_name'` — or move it to a validation rule.

The module-scope sentence is unchanged and pinned byte-identical (content/docs/automation/hook-bodies.mdx, os build's warn-and-bundle line and --strict-body's diagnostic all quote it); the host-only branch is additional prose for a case that could not previously arrive here at all.

Ablation — the probe pin can fail

One measured-absent global moved into the sandbox set on disk, on the committed tree:

HEAD_BLOB=e8c163f85d2bf3d42a30840ff4f6e6f81abbc7be
PRE_WORKTREE_HASH=e8c163f85d2bf3d42a30840ff4f6e6f81abbc7be
PRE_removed_anchor=1 PRE_injected_marker=0 # the exact text about to change
POST_removed_anchor=0 POST_injected_marker=1 # the mutation, observed on disk
POST_WORKTREE_HASH=5ae13309b3124bedfcc7d12fd5743dde5c737334
ABLATION_PIN_EXIT=1
FAIL src/utils/sandbox-globals-probe.test.ts > the two sets are exactly the sandbox
present/absent partition
AssertionError: expected [ …(51) ] to deeply equal [ …(52) ]
POST_RESTORE_HASH=e8c163f85d2bf3d42a30840ff4f6e6f81abbc7be # == HEAD_BLOB
RESTORE_DIFF_LINES=0 RESTORE_marker_left=0

No rebuild leg: the mutated module is CLI source, imported relatively by the pin, so vitest reads it straight from src — and the red is itself the proof the mutation reached the running code. Restore is trapped (trap … EXIT INT TERM, absolute paths) and proved by bytes, not by the trap firing.

Why free-identifiers and not a new refusal kind

forbidden-token's prose fits the sandbox ("the body uses something the sandbox cannot provide") but its severity does not: that class means "writing fetch( IS choosing a bundled closure", and os lint keeps it a warning so the legitimate path is not punished. Intl is a standard global in every browser and in Node — writing it is not the recognisable "I am reaching for the host" act. It is the ACCIDENTAL class #13651 already defines, so it is an error a gate can fail on, travelling the path that already exists. No new kind, no change to os build's accept set: lowerCallables still catches, still bundles, still exits 0.

The one lint edit is the remedy sentence, and it is load-bearing rather than cosmetic: "inline the value(s) into the handler" is impossible for a host global, and an author — or a code-writing model — told to inline Intl lands on a second broken shape. The sentence is chosen from the refusal's own nodeOnlyIdentifiers, never re-derived in the lint, so the rule cannot disagree with what os build did to the same handler. The module-scope sentence is pinned unchanged in the same file.

Corpus reading (measured, no edit)

Whether any in-repo hook body would be newly refused: none. Scanned the corpus checkHookBodyLowering walks — all 10 objectstack.config.ts roots plus every handler: / target: under examples/ and apps/. Two inline function handlers exist (examples/app-crm/src/hooks/opportunity.hook.ts, examples/app-todo/src/objects/task.hook.ts); they use Date, Error, String only — all measured present. git grep -n "\bIntl\b" -- examples apps returns nothing, and the only URL hits in examples/app-showcase/objectstack.config.ts are in comments. examples/app-showcase already ships its callables through the functions: map by name, which is never lowered.

Changeset level

patch on @objectstack/cli. No published accept-set moves — the metadata a valid app may declare is identical, HookBodySchema is untouched, 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 affected app gains a warning plus a working bundled closure in place of a production ReferenceError. The deployment shape it "loses" was never one it had in working order, and the measured corpus of affected in-repo sources is zero.

Verification

All at f97d3b85c4 (the final commit; the gate union was derived and run on this same tree).

whatcommandresult
buildpnpm exec turbo run build --concurrency=2 --filter=@objectstack/cli + the 13 example/plugin filters the i18n gate namesexit 0
typecheckpnpm --filter @objectstack/cli run typecheckexit 0
affected filespnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 over detect-free-identifiers, sandbox-globals-probe, hook-body-lowering, extract-hook-body, lower-callables, hook-body-build-reach.e2e, vitest-tiers-partition7 files, 94 tests, all passed
package tierpnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2159 files, 2093 tests, all passed
gate unionnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands -> 37 commands, each run with its exit captured before any pipe35 exit 0, 2 NOT MEASURED
repo lintpnpm lint (eslint . --no-inline-config)exit 0

--ran reconciliation: the 37 derived commands were run 1:1, no additions. pnpm check:nul-bytes was run beyond the derived union (any edit implies it) and is green, plus a direct control-byte scan of the eight changed files (no hits).

NOT MEASURED (2), in the gates' own words — neither is a red:

  • node scripts/check-test-completeness.mjs — exit 3. "Nothing was measured: this gate exited before parsing a single summary line ... ⛔ It is not a red, and there is nothing here to fix. Fix: pass a saved turbo run test log — or, running the family locally, record this gate as NOT MEASURED."
  • node scripts/pm/check-half-states.mjs — exit 3. "Treat this exit as an unread instrument, never as a quiet board" — a PM-board census over the GitHub API, unrelated to this diff. Its sibling pnpm check:pm-half-states (also in the union) exits 0.

Two more first returned a prerequisite verdict rather than a result on a worktree built for the cli graph alone, and are reported green after building the closure they name: pnpm check:dual-build-cjs-loads ("PREREQUISITE NOT MET ... ⛔ This is NOT a pass: nothing was measured") and pnpm check:i18n-coverage ("COULD NOT MEASURE — 1 of 13 config(s) failed to lint", a missing @objectstack/connector-mcp dist). The second one matters here beyond bookkeeping: it runs os lint over all 13 in-repo configs, so its green is a direct reading that this change makes os lint report nothing new on any of them.

Deviations from the dispatch

  1. packages/cli/src/utils/extract-hook-body.ts is in the diff. The dispatch's IN list named the detector, its test and the lint rule; the refusal is thrown in extract-hook-body, and "routed through the existing refusal path" cannot be done without composing the reason there. No new refusal kind, no change to what os build accepts, and the pre-existing sentence is pinned byte-identical.
  2. The lint rule was touched, which the dispatch preferred to avoid. Only the remedy sentence, and only because the existing one ("inline the value(s)") is impossible to follow for a host global — argued above. The module-scope remedy is pinned unchanged in the same file.
  3. The probe pin is its own file rather than an addition to detect-free-identifiers.test.ts: it boots a WASM VM and imports @objectstack/runtime, and keeping it separate leaves the pure-AST unit file fast and independent of build state. It classifies as unit under the test.projects partition that landed today (pnpm --filter @objectstack/cli test is a ~24-minute serialized run, and on a shared agent container it holds the verify lock for the whole of it #13504's predicate is SPAWN ∨ KERNEL; this file is neither), and test/vitest-tiers-partition.test.ts passes on this branch.
  4. Two runs were NARROWED and executed outside the shared verify lock, declared here: (a) the residual build after a locked run was cut short by the container's foreground cap — 26 of 28 turbo tasks were already cached — plus the two temporary measurement harnesses; (b) the first typecheck and targeted six-file vitest run. Cumulative queue time on the lock was about 45 minutes, with one holder resident 1190s and another 611s. Everything in the verification table above was re-run at f97d3b85c4 with the build and the two test runs UNDER the lock.check:* gate scripts and pnpm lint are outside the lock's coverage by its own definition, so those ran unlocked by design.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

…lobals (#14301)
`detect-free-identifiers`' ambient allowlist was one generous list documented
as "assume the runtime has it". The runtime a lowered body runs in is the
QuickJS sandbox, not the Node process that runs `objectstack build` — and the
list named `Intl` beside `JSON`. A handler calling `Intl.DateTimeFormat`
therefore had no free identifier: it lowered into `body.source`, the #13651
lint rule had nothing to report (it fires only on a refused lowering), every
local gate was green because the in-process test runs the raw function in Node,
and production threw `ReferenceError: Intl is not defined`.
Split the allowlist into `SANDBOX_GLOBALS` (53) and `NODE_ONLY_GLOBALS` (15),
with membership MEASURED by a `typeof`/`in globalThis` probe evaluated inside
the same `QuickJSScriptRunner` the runtime uses and pinned by a test that reads
that probe. A free reference to a host-only name is now reported and refused
with a reason that names the identifier and the remedy — a string handler ref
or a validation rule, and `ctx.log` for `console` — travelling the existing
`free-identifiers` path, so `lowerCallables` still bundles the callable and
`os build` still exits 0 with a warning.
Not changed: whether `os build` fails on the lowering class (#13838), what the
sandbox provides, and anything under `packages/runtime/**`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 13 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/hook-bodies.mdx(via globalThis (literal, a string literal in GLOBALS; a string literal in SANDBOX_GLOBALS))
What this run could not see
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2aa8456cf2d66ec3825d262686fe4218e57cfd27packageMentionDocs.

Which tree this was computed on

This run read content/docs from bcec761c21b97b1dc8ed9cd61f527e6eb4fa6653 — the merge of head f97d3b85c46399f2ccd02ec4c084a6278be20970 into base 2aa8456cf2d66ec3825d262686fe4218e57cfd27, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin bcec761c21b97b1dc8ed9cd61f527e6eb4fa6653 && git checkout bcec761c21b97b1dc8ed9cd61f527e6eb4fa6653
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2aa8456cf2d66ec3825d262686fe4218e57cfd27 f97d3b85c46399f2ccd02ec4c084a6278be20970 && git checkout -B drift-repro 2aa8456cf2d66ec3825d262686fe4218e57cfd27 && git merge --no-ff f97d3b85c46399f2ccd02ec4c084a6278be20970
node scripts/docs-audit/affected-docs.mjs --json 2aa8456cf2d66ec3825d262686fe4218e57cfd27

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2aa8456cf2d66ec3825d262686fe4218e57cfd27 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-trump@claude
, '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

fix(cli): refuse to lower hook bodies that reference globals the sandbox does not provide (#14301) - #14661

Merged
os-trump merged 2 commits into
mainfrom
claude/issue-14301-sandbox-globals-allowlist
Sep 2, 2026
Merged

fix(cli): refuse to lower hook bodies that reference globals the sandbox does not provide (#14301)#14661
os-trump merged 2 commits into
mainfrom
claude/issue-14301-sandbox-globals-allowlist

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#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 the reported handler had no free identifier at all, lowered into body.source, passed every local gate, and threw ReferenceError in production.

This splits the allowlist in two, measures the membership inside the real sandbox, and turns a free reference to a host-only name into a lowering refusal that names the identifier and a remedy that is actually possible.

The measurement

packages/cli/src/utils/sandbox-globals-probe.test.ts evaluates, for every member of both sets, typeof X !== 'undefined' || 'X' in globalThisinside a real QuickJSScriptRunner — the same ScriptRunnerAppPlugin wires for hook and action bodies, on the same runScript path, with the same empty capability set a body with no inferred capabilities gets. The pin fails unless each set is exactly the probe's partition, so a name added from memory reddens it.

Two limbs per name, not one: typeof X alone reports the single global whose VALUE is undefinedundefined itself — as absent, and would have demanded it be listed host-only. 'X' in globalThis asks existence instead of value.

Measured present — SANDBOX_GLOBALS (53):

Math JSON Date Object Array String Number Boolean RegExp Map Set WeakMap WeakSet
Promise Symbol BigInt Function Reflect Proxy
ArrayBuffer SharedArrayBuffer DataView
Int8Array Uint8Array Uint8ClampedArray Int16Array Uint16Array Int32Array
Uint32Array Float32Array Float64Array BigInt64Array BigUint64Array
Error TypeError RangeError SyntaxError ReferenceError EvalError URIError AggregateError
parseInt parseFloat isNaN isFinite encodeURIComponent decodeURIComponent encodeURI decodeURI
undefined NaN Infinity globalThis

Measured absent — NODE_ONLY_GLOBALS (15):

Intl
structuredClone queueMicrotask atob btoa
setTimeout clearTimeout setInterval clearInterval
URL URLSearchParams TextEncoder TextDecoder
console
arguments

53 + 15 = 68 = the size of the GLOBALS set this replaces, and the two sets are disjoint — no name was dropped or invented in the split. Three of the readings are worth stating out loud:

  • Intl is the reported one: ECMA-402, standard in every browser and in Node, absent here.
  • console is a HOST object; the sandbox deliberately routes logging through the capability-gated ctx.log (buildBodyLogSurface in the runtime's body runner) and installs no global. So the refusal for console also names ctx.log — the one entry in a deliberately closed replacement table, because "keep it in a string handler ref" is poor advice for a log line when the platform's own answer is one capability away.
  • arguments is the one member that is not a global at all. It is an implicit binding of ordinary function scope, and the runner wraps a lowered body in an ARROW ((async (ctx) => { … })(ctx)), which provides none. It measures absent for a different reason than the rest and is refused for the same one: a function (ctx) { … arguments … } handler works in-process and throws once lowered.

Red-first

The card's reproduction, run against byte-for-byte copies of the two base (HEAD) sources so the reading was about the tree as it was rather than the working tree:

BASE_FREE=[] unparsed=false
BASE_OUTCOME=LOWERED
BASE_BODY_SOURCE="\n\tconst fmt = new Intl.DateTimeFormat(\"en-US\", { timeZone: \"UTC\" });\n\tctx.input.due_label = fmt.format(new Date(ctx.input.due_at));\n"

No free identifier, extraction succeeded, and the emitted body.source is the Intl call verbatim. After the change the same handler is REFUSED (kind: 'free-identifiers', freeIdentifiers: ['Intl'], nodeOnlyIdentifiers: ['Intl']), pinned in test/extract-hook-body.test.ts.

The named refusal

[hook-body-extract] hook 'stamp_due_label': handler references identifier(s) not in scope at
runtime: Intl. Intl is not available in the hook sandbox — the QuickJS build the runtime
evaluates a lowered body in does not provide it, so the body would throw ReferenceError in
production while validate, typecheck, test and build all stay green (they run the raw function
in Node, where it exists). This handler will be BUNDLED instead (no behavior change). To keep
it as metadata, keep the check in a string handler ref — put the function in the top-level
`functions:` map and write `handler: 'fn_name'` — or move it to a validation rule.

The module-scope sentence is unchanged and pinned byte-identical (content/docs/automation/hook-bodies.mdx, os build's warn-and-bundle line and --strict-body's diagnostic all quote it); the host-only branch is additional prose for a case that could not previously arrive here at all.

Ablation — the probe pin can fail

One measured-absent global moved into the sandbox set on disk, on the committed tree:

HEAD_BLOB=e8c163f85d2bf3d42a30840ff4f6e6f81abbc7be
PRE_WORKTREE_HASH=e8c163f85d2bf3d42a30840ff4f6e6f81abbc7be
PRE_removed_anchor=1 PRE_injected_marker=0 # the exact text about to change
POST_removed_anchor=0 POST_injected_marker=1 # the mutation, observed on disk
POST_WORKTREE_HASH=5ae13309b3124bedfcc7d12fd5743dde5c737334
ABLATION_PIN_EXIT=1
FAIL src/utils/sandbox-globals-probe.test.ts > the two sets are exactly the sandbox
present/absent partition
AssertionError: expected [ …(51) ] to deeply equal [ …(52) ]
POST_RESTORE_HASH=e8c163f85d2bf3d42a30840ff4f6e6f81abbc7be # == HEAD_BLOB
RESTORE_DIFF_LINES=0 RESTORE_marker_left=0

No rebuild leg: the mutated module is CLI source, imported relatively by the pin, so vitest reads it straight from src — and the red is itself the proof the mutation reached the running code. Restore is trapped (trap … EXIT INT TERM, absolute paths) and proved by bytes, not by the trap firing.

Why free-identifiers and not a new refusal kind

forbidden-token's prose fits the sandbox ("the body uses something the sandbox cannot provide") but its severity does not: that class means "writing fetch( IS choosing a bundled closure", and os lint keeps it a warning so the legitimate path is not punished. Intl is a standard global in every browser and in Node — writing it is not the recognisable "I am reaching for the host" act. It is the ACCIDENTAL class #13651 already defines, so it is an error a gate can fail on, travelling the path that already exists. No new kind, no change to os build's accept set: lowerCallables still catches, still bundles, still exits 0.

The one lint edit is the remedy sentence, and it is load-bearing rather than cosmetic: "inline the value(s) into the handler" is impossible for a host global, and an author — or a code-writing model — told to inline Intl lands on a second broken shape. The sentence is chosen from the refusal's own nodeOnlyIdentifiers, never re-derived in the lint, so the rule cannot disagree with what os build did to the same handler. The module-scope sentence is pinned unchanged in the same file.

Corpus reading (measured, no edit)

Whether any in-repo hook body would be newly refused: none. Scanned the corpus checkHookBodyLowering walks — all 10 objectstack.config.ts roots plus every handler: / target: under examples/ and apps/. Two inline function handlers exist (examples/app-crm/src/hooks/opportunity.hook.ts, examples/app-todo/src/objects/task.hook.ts); they use Date, Error, String only — all measured present. git grep -n "\bIntl\b" -- examples apps returns nothing, and the only URL hits in examples/app-showcase/objectstack.config.ts are in comments. examples/app-showcase already ships its callables through the functions: map by name, which is never lowered.

Changeset level

patch on @objectstack/cli. No published accept-set moves — the metadata a valid app may declare is identical, HookBodySchema is untouched, 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 affected app gains a warning plus a working bundled closure in place of a production ReferenceError. The deployment shape it "loses" was never one it had in working order, and the measured corpus of affected in-repo sources is zero.

Verification

All at f97d3b85c4 (the final commit; the gate union was derived and run on this same tree).

whatcommandresult
buildpnpm exec turbo run build --concurrency=2 --filter=@objectstack/cli + the 13 example/plugin filters the i18n gate namesexit 0
typecheckpnpm --filter @objectstack/cli run typecheckexit 0
affected filespnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 over detect-free-identifiers, sandbox-globals-probe, hook-body-lowering, extract-hook-body, lower-callables, hook-body-build-reach.e2e, vitest-tiers-partition7 files, 94 tests, all passed
package tierpnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2159 files, 2093 tests, all passed
gate unionnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands -> 37 commands, each run with its exit captured before any pipe35 exit 0, 2 NOT MEASURED
repo lintpnpm lint (eslint . --no-inline-config)exit 0

--ran reconciliation: the 37 derived commands were run 1:1, no additions. pnpm check:nul-bytes was run beyond the derived union (any edit implies it) and is green, plus a direct control-byte scan of the eight changed files (no hits).

NOT MEASURED (2), in the gates' own words — neither is a red:

  • node scripts/check-test-completeness.mjs — exit 3. "Nothing was measured: this gate exited before parsing a single summary line ... ⛔ It is not a red, and there is nothing here to fix. Fix: pass a saved turbo run test log — or, running the family locally, record this gate as NOT MEASURED."
  • node scripts/pm/check-half-states.mjs — exit 3. "Treat this exit as an unread instrument, never as a quiet board" — a PM-board census over the GitHub API, unrelated to this diff. Its sibling pnpm check:pm-half-states (also in the union) exits 0.

Two more first returned a prerequisite verdict rather than a result on a worktree built for the cli graph alone, and are reported green after building the closure they name: pnpm check:dual-build-cjs-loads ("PREREQUISITE NOT MET ... ⛔ This is NOT a pass: nothing was measured") and pnpm check:i18n-coverage ("COULD NOT MEASURE — 1 of 13 config(s) failed to lint", a missing @objectstack/connector-mcp dist). The second one matters here beyond bookkeeping: it runs os lint over all 13 in-repo configs, so its green is a direct reading that this change makes os lint report nothing new on any of them.

Deviations from the dispatch

  1. packages/cli/src/utils/extract-hook-body.ts is in the diff. The dispatch's IN list named the detector, its test and the lint rule; the refusal is thrown in extract-hook-body, and "routed through the existing refusal path" cannot be done without composing the reason there. No new refusal kind, no change to what os build accepts, and the pre-existing sentence is pinned byte-identical.
  2. The lint rule was touched, which the dispatch preferred to avoid. Only the remedy sentence, and only because the existing one ("inline the value(s)") is impossible to follow for a host global — argued above. The module-scope remedy is pinned unchanged in the same file.
  3. The probe pin is its own file rather than an addition to detect-free-identifiers.test.ts: it boots a WASM VM and imports @objectstack/runtime, and keeping it separate leaves the pure-AST unit file fast and independent of build state. It classifies as unit under the test.projects partition that landed today (pnpm --filter @objectstack/cli test is a ~24-minute serialized run, and on a shared agent container it holds the verify lock for the whole of it #13504's predicate is SPAWN ∨ KERNEL; this file is neither), and test/vitest-tiers-partition.test.ts passes on this branch.
  4. Two runs were NARROWED and executed outside the shared verify lock, declared here: (a) the residual build after a locked run was cut short by the container's foreground cap — 26 of 28 turbo tasks were already cached — plus the two temporary measurement harnesses; (b) the first typecheck and targeted six-file vitest run. Cumulative queue time on the lock was about 45 minutes, with one holder resident 1190s and another 611s. Everything in the verification table above was re-run at f97d3b85c4 with the build and the two test runs UNDER the lock.check:* gate scripts and pnpm lint are outside the lock's coverage by its own definition, so those ran unlocked by design.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

…lobals (#14301)
`detect-free-identifiers`' ambient allowlist was one generous list documented
as "assume the runtime has it". The runtime a lowered body runs in is the
QuickJS sandbox, not the Node process that runs `objectstack build` — and the
list named `Intl` beside `JSON`. A handler calling `Intl.DateTimeFormat`
therefore had no free identifier: it lowered into `body.source`, the #13651
lint rule had nothing to report (it fires only on a refused lowering), every
local gate was green because the in-process test runs the raw function in Node,
and production threw `ReferenceError: Intl is not defined`.
Split the allowlist into `SANDBOX_GLOBALS` (53) and `NODE_ONLY_GLOBALS` (15),
with membership MEASURED by a `typeof`/`in globalThis` probe evaluated inside
the same `QuickJSScriptRunner` the runtime uses and pinned by a test that reads
that probe. A free reference to a host-only name is now reported and refused
with a reason that names the identifier and the remedy — a string handler ref
or a validation rule, and `ctx.log` for `console` — travelling the existing
`free-identifiers` path, so `lowerCallables` still bundles the callable and
`os build` still exits 0 with a warning.
Not changed: whether `os build` fails on the lowering class (#13838), what the
sandbox provides, and anything under `packages/runtime/**`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 13 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/hook-bodies.mdx(via globalThis (literal, a string literal in GLOBALS; a string literal in SANDBOX_GLOBALS))
What this run could not see
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2aa8456cf2d66ec3825d262686fe4218e57cfd27packageMentionDocs.

Which tree this was computed on

This run read content/docs from bcec761c21b97b1dc8ed9cd61f527e6eb4fa6653 — the merge of head f97d3b85c46399f2ccd02ec4c084a6278be20970 into base 2aa8456cf2d66ec3825d262686fe4218e57cfd27, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin bcec761c21b97b1dc8ed9cd61f527e6eb4fa6653 && git checkout bcec761c21b97b1dc8ed9cd61f527e6eb4fa6653
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2aa8456cf2d66ec3825d262686fe4218e57cfd27 f97d3b85c46399f2ccd02ec4c084a6278be20970 && git checkout -B drift-repro 2aa8456cf2d66ec3825d262686fe4218e57cfd27 && git merge --no-ff f97d3b85c46399f2ccd02ec4c084a6278be20970
node scripts/docs-audit/affected-docs.mjs --json 2aa8456cf2d66ec3825d262686fe4218e57cfd27

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2aa8456cf2d66ec3825d262686fe4218e57cfd27 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-trump@claude
, '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

fix(cli): refuse to lower hook bodies that reference globals the sandbox does not provide (#14301) - #14661

Merged
os-trump merged 2 commits into
mainfrom
claude/issue-14301-sandbox-globals-allowlist
Sep 2, 2026
Merged

fix(cli): refuse to lower hook bodies that reference globals the sandbox does not provide (#14301)#14661
os-trump merged 2 commits into
mainfrom
claude/issue-14301-sandbox-globals-allowlist

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#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 the reported handler had no free identifier at all, lowered into body.source, passed every local gate, and threw ReferenceError in production.

This splits the allowlist in two, measures the membership inside the real sandbox, and turns a free reference to a host-only name into a lowering refusal that names the identifier and a remedy that is actually possible.

The measurement

packages/cli/src/utils/sandbox-globals-probe.test.ts evaluates, for every member of both sets, typeof X !== 'undefined' || 'X' in globalThisinside a real QuickJSScriptRunner — the same ScriptRunnerAppPlugin wires for hook and action bodies, on the same runScript path, with the same empty capability set a body with no inferred capabilities gets. The pin fails unless each set is exactly the probe's partition, so a name added from memory reddens it.

Two limbs per name, not one: typeof X alone reports the single global whose VALUE is undefinedundefined itself — as absent, and would have demanded it be listed host-only. 'X' in globalThis asks existence instead of value.

Measured present — SANDBOX_GLOBALS (53):

Math JSON Date Object Array String Number Boolean RegExp Map Set WeakMap WeakSet
Promise Symbol BigInt Function Reflect Proxy
ArrayBuffer SharedArrayBuffer DataView
Int8Array Uint8Array Uint8ClampedArray Int16Array Uint16Array Int32Array
Uint32Array Float32Array Float64Array BigInt64Array BigUint64Array
Error TypeError RangeError SyntaxError ReferenceError EvalError URIError AggregateError
parseInt parseFloat isNaN isFinite encodeURIComponent decodeURIComponent encodeURI decodeURI
undefined NaN Infinity globalThis

Measured absent — NODE_ONLY_GLOBALS (15):

Intl
structuredClone queueMicrotask atob btoa
setTimeout clearTimeout setInterval clearInterval
URL URLSearchParams TextEncoder TextDecoder
console
arguments

53 + 15 = 68 = the size of the GLOBALS set this replaces, and the two sets are disjoint — no name was dropped or invented in the split. Three of the readings are worth stating out loud:

  • Intl is the reported one: ECMA-402, standard in every browser and in Node, absent here.
  • console is a HOST object; the sandbox deliberately routes logging through the capability-gated ctx.log (buildBodyLogSurface in the runtime's body runner) and installs no global. So the refusal for console also names ctx.log — the one entry in a deliberately closed replacement table, because "keep it in a string handler ref" is poor advice for a log line when the platform's own answer is one capability away.
  • arguments is the one member that is not a global at all. It is an implicit binding of ordinary function scope, and the runner wraps a lowered body in an ARROW ((async (ctx) => { … })(ctx)), which provides none. It measures absent for a different reason than the rest and is refused for the same one: a function (ctx) { … arguments … } handler works in-process and throws once lowered.

Red-first

The card's reproduction, run against byte-for-byte copies of the two base (HEAD) sources so the reading was about the tree as it was rather than the working tree:

BASE_FREE=[] unparsed=false
BASE_OUTCOME=LOWERED
BASE_BODY_SOURCE="\n\tconst fmt = new Intl.DateTimeFormat(\"en-US\", { timeZone: \"UTC\" });\n\tctx.input.due_label = fmt.format(new Date(ctx.input.due_at));\n"

No free identifier, extraction succeeded, and the emitted body.source is the Intl call verbatim. After the change the same handler is REFUSED (kind: 'free-identifiers', freeIdentifiers: ['Intl'], nodeOnlyIdentifiers: ['Intl']), pinned in test/extract-hook-body.test.ts.

The named refusal

[hook-body-extract] hook 'stamp_due_label': handler references identifier(s) not in scope at
runtime: Intl. Intl is not available in the hook sandbox — the QuickJS build the runtime
evaluates a lowered body in does not provide it, so the body would throw ReferenceError in
production while validate, typecheck, test and build all stay green (they run the raw function
in Node, where it exists). This handler will be BUNDLED instead (no behavior change). To keep
it as metadata, keep the check in a string handler ref — put the function in the top-level
`functions:` map and write `handler: 'fn_name'` — or move it to a validation rule.

The module-scope sentence is unchanged and pinned byte-identical (content/docs/automation/hook-bodies.mdx, os build's warn-and-bundle line and --strict-body's diagnostic all quote it); the host-only branch is additional prose for a case that could not previously arrive here at all.

Ablation — the probe pin can fail

One measured-absent global moved into the sandbox set on disk, on the committed tree:

HEAD_BLOB=e8c163f85d2bf3d42a30840ff4f6e6f81abbc7be
PRE_WORKTREE_HASH=e8c163f85d2bf3d42a30840ff4f6e6f81abbc7be
PRE_removed_anchor=1 PRE_injected_marker=0 # the exact text about to change
POST_removed_anchor=0 POST_injected_marker=1 # the mutation, observed on disk
POST_WORKTREE_HASH=5ae13309b3124bedfcc7d12fd5743dde5c737334
ABLATION_PIN_EXIT=1
FAIL src/utils/sandbox-globals-probe.test.ts > the two sets are exactly the sandbox
present/absent partition
AssertionError: expected [ …(51) ] to deeply equal [ …(52) ]
POST_RESTORE_HASH=e8c163f85d2bf3d42a30840ff4f6e6f81abbc7be # == HEAD_BLOB
RESTORE_DIFF_LINES=0 RESTORE_marker_left=0

No rebuild leg: the mutated module is CLI source, imported relatively by the pin, so vitest reads it straight from src — and the red is itself the proof the mutation reached the running code. Restore is trapped (trap … EXIT INT TERM, absolute paths) and proved by bytes, not by the trap firing.

Why free-identifiers and not a new refusal kind

forbidden-token's prose fits the sandbox ("the body uses something the sandbox cannot provide") but its severity does not: that class means "writing fetch( IS choosing a bundled closure", and os lint keeps it a warning so the legitimate path is not punished. Intl is a standard global in every browser and in Node — writing it is not the recognisable "I am reaching for the host" act. It is the ACCIDENTAL class #13651 already defines, so it is an error a gate can fail on, travelling the path that already exists. No new kind, no change to os build's accept set: lowerCallables still catches, still bundles, still exits 0.

The one lint edit is the remedy sentence, and it is load-bearing rather than cosmetic: "inline the value(s) into the handler" is impossible for a host global, and an author — or a code-writing model — told to inline Intl lands on a second broken shape. The sentence is chosen from the refusal's own nodeOnlyIdentifiers, never re-derived in the lint, so the rule cannot disagree with what os build did to the same handler. The module-scope sentence is pinned unchanged in the same file.

Corpus reading (measured, no edit)

Whether any in-repo hook body would be newly refused: none. Scanned the corpus checkHookBodyLowering walks — all 10 objectstack.config.ts roots plus every handler: / target: under examples/ and apps/. Two inline function handlers exist (examples/app-crm/src/hooks/opportunity.hook.ts, examples/app-todo/src/objects/task.hook.ts); they use Date, Error, String only — all measured present. git grep -n "\bIntl\b" -- examples apps returns nothing, and the only URL hits in examples/app-showcase/objectstack.config.ts are in comments. examples/app-showcase already ships its callables through the functions: map by name, which is never lowered.

Changeset level

patch on @objectstack/cli. No published accept-set moves — the metadata a valid app may declare is identical, HookBodySchema is untouched, 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 affected app gains a warning plus a working bundled closure in place of a production ReferenceError. The deployment shape it "loses" was never one it had in working order, and the measured corpus of affected in-repo sources is zero.

Verification

All at f97d3b85c4 (the final commit; the gate union was derived and run on this same tree).

whatcommandresult
buildpnpm exec turbo run build --concurrency=2 --filter=@objectstack/cli + the 13 example/plugin filters the i18n gate namesexit 0
typecheckpnpm --filter @objectstack/cli run typecheckexit 0
affected filespnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 over detect-free-identifiers, sandbox-globals-probe, hook-body-lowering, extract-hook-body, lower-callables, hook-body-build-reach.e2e, vitest-tiers-partition7 files, 94 tests, all passed
package tierpnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2159 files, 2093 tests, all passed
gate unionnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands -> 37 commands, each run with its exit captured before any pipe35 exit 0, 2 NOT MEASURED
repo lintpnpm lint (eslint . --no-inline-config)exit 0

--ran reconciliation: the 37 derived commands were run 1:1, no additions. pnpm check:nul-bytes was run beyond the derived union (any edit implies it) and is green, plus a direct control-byte scan of the eight changed files (no hits).

NOT MEASURED (2), in the gates' own words — neither is a red:

  • node scripts/check-test-completeness.mjs — exit 3. "Nothing was measured: this gate exited before parsing a single summary line ... ⛔ It is not a red, and there is nothing here to fix. Fix: pass a saved turbo run test log — or, running the family locally, record this gate as NOT MEASURED."
  • node scripts/pm/check-half-states.mjs — exit 3. "Treat this exit as an unread instrument, never as a quiet board" — a PM-board census over the GitHub API, unrelated to this diff. Its sibling pnpm check:pm-half-states (also in the union) exits 0.

Two more first returned a prerequisite verdict rather than a result on a worktree built for the cli graph alone, and are reported green after building the closure they name: pnpm check:dual-build-cjs-loads ("PREREQUISITE NOT MET ... ⛔ This is NOT a pass: nothing was measured") and pnpm check:i18n-coverage ("COULD NOT MEASURE — 1 of 13 config(s) failed to lint", a missing @objectstack/connector-mcp dist). The second one matters here beyond bookkeeping: it runs os lint over all 13 in-repo configs, so its green is a direct reading that this change makes os lint report nothing new on any of them.

Deviations from the dispatch

  1. packages/cli/src/utils/extract-hook-body.ts is in the diff. The dispatch's IN list named the detector, its test and the lint rule; the refusal is thrown in extract-hook-body, and "routed through the existing refusal path" cannot be done without composing the reason there. No new refusal kind, no change to what os build accepts, and the pre-existing sentence is pinned byte-identical.
  2. The lint rule was touched, which the dispatch preferred to avoid. Only the remedy sentence, and only because the existing one ("inline the value(s)") is impossible to follow for a host global — argued above. The module-scope remedy is pinned unchanged in the same file.
  3. The probe pin is its own file rather than an addition to detect-free-identifiers.test.ts: it boots a WASM VM and imports @objectstack/runtime, and keeping it separate leaves the pure-AST unit file fast and independent of build state. It classifies as unit under the test.projects partition that landed today (pnpm --filter @objectstack/cli test is a ~24-minute serialized run, and on a shared agent container it holds the verify lock for the whole of it #13504's predicate is SPAWN ∨ KERNEL; this file is neither), and test/vitest-tiers-partition.test.ts passes on this branch.
  4. Two runs were NARROWED and executed outside the shared verify lock, declared here: (a) the residual build after a locked run was cut short by the container's foreground cap — 26 of 28 turbo tasks were already cached — plus the two temporary measurement harnesses; (b) the first typecheck and targeted six-file vitest run. Cumulative queue time on the lock was about 45 minutes, with one holder resident 1190s and another 611s. Everything in the verification table above was re-run at f97d3b85c4 with the build and the two test runs UNDER the lock.check:* gate scripts and pnpm lint are outside the lock's coverage by its own definition, so those ran unlocked by design.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

…lobals (#14301)
`detect-free-identifiers`' ambient allowlist was one generous list documented
as "assume the runtime has it". The runtime a lowered body runs in is the
QuickJS sandbox, not the Node process that runs `objectstack build` — and the
list named `Intl` beside `JSON`. A handler calling `Intl.DateTimeFormat`
therefore had no free identifier: it lowered into `body.source`, the #13651
lint rule had nothing to report (it fires only on a refused lowering), every
local gate was green because the in-process test runs the raw function in Node,
and production threw `ReferenceError: Intl is not defined`.
Split the allowlist into `SANDBOX_GLOBALS` (53) and `NODE_ONLY_GLOBALS` (15),
with membership MEASURED by a `typeof`/`in globalThis` probe evaluated inside
the same `QuickJSScriptRunner` the runtime uses and pinned by a test that reads
that probe. A free reference to a host-only name is now reported and refused
with a reason that names the identifier and the remedy — a string handler ref
or a validation rule, and `ctx.log` for `console` — travelling the existing
`free-identifiers` path, so `lowerCallables` still bundles the callable and
`os build` still exits 0 with a warning.
Not changed: whether `os build` fails on the lowering class (#13838), what the
sandbox provides, and anything under `packages/runtime/**`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 13 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/hook-bodies.mdx(via globalThis (literal, a string literal in GLOBALS; a string literal in SANDBOX_GLOBALS))
What this run could not see
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2aa8456cf2d66ec3825d262686fe4218e57cfd27packageMentionDocs.

Which tree this was computed on

This run read content/docs from bcec761c21b97b1dc8ed9cd61f527e6eb4fa6653 — the merge of head f97d3b85c46399f2ccd02ec4c084a6278be20970 into base 2aa8456cf2d66ec3825d262686fe4218e57cfd27, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin bcec761c21b97b1dc8ed9cd61f527e6eb4fa6653 && git checkout bcec761c21b97b1dc8ed9cd61f527e6eb4fa6653
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2aa8456cf2d66ec3825d262686fe4218e57cfd27 f97d3b85c46399f2ccd02ec4c084a6278be20970 && git checkout -B drift-repro 2aa8456cf2d66ec3825d262686fe4218e57cfd27 && git merge --no-ff f97d3b85c46399f2ccd02ec4c084a6278be20970
node scripts/docs-audit/affected-docs.mjs --json 2aa8456cf2d66ec3825d262686fe4218e57cfd27

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2aa8456cf2d66ec3825d262686fe4218e57cfd27 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-trump@claude
, '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

fix(cli): refuse to lower hook bodies that reference globals the sandbox does not provide (#14301) - #14661

Merged
os-trump merged 2 commits into
mainfrom
claude/issue-14301-sandbox-globals-allowlist
Sep 2, 2026
Merged

fix(cli): refuse to lower hook bodies that reference globals the sandbox does not provide (#14301)#14661
os-trump merged 2 commits into
mainfrom
claude/issue-14301-sandbox-globals-allowlist

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#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 the reported handler had no free identifier at all, lowered into body.source, passed every local gate, and threw ReferenceError in production.

This splits the allowlist in two, measures the membership inside the real sandbox, and turns a free reference to a host-only name into a lowering refusal that names the identifier and a remedy that is actually possible.

The measurement

packages/cli/src/utils/sandbox-globals-probe.test.ts evaluates, for every member of both sets, typeof X !== 'undefined' || 'X' in globalThisinside a real QuickJSScriptRunner — the same ScriptRunnerAppPlugin wires for hook and action bodies, on the same runScript path, with the same empty capability set a body with no inferred capabilities gets. The pin fails unless each set is exactly the probe's partition, so a name added from memory reddens it.

Two limbs per name, not one: typeof X alone reports the single global whose VALUE is undefinedundefined itself — as absent, and would have demanded it be listed host-only. 'X' in globalThis asks existence instead of value.

Measured present — SANDBOX_GLOBALS (53):

Math JSON Date Object Array String Number Boolean RegExp Map Set WeakMap WeakSet
Promise Symbol BigInt Function Reflect Proxy
ArrayBuffer SharedArrayBuffer DataView
Int8Array Uint8Array Uint8ClampedArray Int16Array Uint16Array Int32Array
Uint32Array Float32Array Float64Array BigInt64Array BigUint64Array
Error TypeError RangeError SyntaxError ReferenceError EvalError URIError AggregateError
parseInt parseFloat isNaN isFinite encodeURIComponent decodeURIComponent encodeURI decodeURI
undefined NaN Infinity globalThis

Measured absent — NODE_ONLY_GLOBALS (15):

Intl
structuredClone queueMicrotask atob btoa
setTimeout clearTimeout setInterval clearInterval
URL URLSearchParams TextEncoder TextDecoder
console
arguments

53 + 15 = 68 = the size of the GLOBALS set this replaces, and the two sets are disjoint — no name was dropped or invented in the split. Three of the readings are worth stating out loud:

  • Intl is the reported one: ECMA-402, standard in every browser and in Node, absent here.
  • console is a HOST object; the sandbox deliberately routes logging through the capability-gated ctx.log (buildBodyLogSurface in the runtime's body runner) and installs no global. So the refusal for console also names ctx.log — the one entry in a deliberately closed replacement table, because "keep it in a string handler ref" is poor advice for a log line when the platform's own answer is one capability away.
  • arguments is the one member that is not a global at all. It is an implicit binding of ordinary function scope, and the runner wraps a lowered body in an ARROW ((async (ctx) => { … })(ctx)), which provides none. It measures absent for a different reason than the rest and is refused for the same one: a function (ctx) { … arguments … } handler works in-process and throws once lowered.

Red-first

The card's reproduction, run against byte-for-byte copies of the two base (HEAD) sources so the reading was about the tree as it was rather than the working tree:

BASE_FREE=[] unparsed=false
BASE_OUTCOME=LOWERED
BASE_BODY_SOURCE="\n\tconst fmt = new Intl.DateTimeFormat(\"en-US\", { timeZone: \"UTC\" });\n\tctx.input.due_label = fmt.format(new Date(ctx.input.due_at));\n"

No free identifier, extraction succeeded, and the emitted body.source is the Intl call verbatim. After the change the same handler is REFUSED (kind: 'free-identifiers', freeIdentifiers: ['Intl'], nodeOnlyIdentifiers: ['Intl']), pinned in test/extract-hook-body.test.ts.

The named refusal

[hook-body-extract] hook 'stamp_due_label': handler references identifier(s) not in scope at
runtime: Intl. Intl is not available in the hook sandbox — the QuickJS build the runtime
evaluates a lowered body in does not provide it, so the body would throw ReferenceError in
production while validate, typecheck, test and build all stay green (they run the raw function
in Node, where it exists). This handler will be BUNDLED instead (no behavior change). To keep
it as metadata, keep the check in a string handler ref — put the function in the top-level
`functions:` map and write `handler: 'fn_name'` — or move it to a validation rule.

The module-scope sentence is unchanged and pinned byte-identical (content/docs/automation/hook-bodies.mdx, os build's warn-and-bundle line and --strict-body's diagnostic all quote it); the host-only branch is additional prose for a case that could not previously arrive here at all.

Ablation — the probe pin can fail

One measured-absent global moved into the sandbox set on disk, on the committed tree:

HEAD_BLOB=e8c163f85d2bf3d42a30840ff4f6e6f81abbc7be
PRE_WORKTREE_HASH=e8c163f85d2bf3d42a30840ff4f6e6f81abbc7be
PRE_removed_anchor=1 PRE_injected_marker=0 # the exact text about to change
POST_removed_anchor=0 POST_injected_marker=1 # the mutation, observed on disk
POST_WORKTREE_HASH=5ae13309b3124bedfcc7d12fd5743dde5c737334
ABLATION_PIN_EXIT=1
FAIL src/utils/sandbox-globals-probe.test.ts > the two sets are exactly the sandbox
present/absent partition
AssertionError: expected [ …(51) ] to deeply equal [ …(52) ]
POST_RESTORE_HASH=e8c163f85d2bf3d42a30840ff4f6e6f81abbc7be # == HEAD_BLOB
RESTORE_DIFF_LINES=0 RESTORE_marker_left=0

No rebuild leg: the mutated module is CLI source, imported relatively by the pin, so vitest reads it straight from src — and the red is itself the proof the mutation reached the running code. Restore is trapped (trap … EXIT INT TERM, absolute paths) and proved by bytes, not by the trap firing.

Why free-identifiers and not a new refusal kind

forbidden-token's prose fits the sandbox ("the body uses something the sandbox cannot provide") but its severity does not: that class means "writing fetch( IS choosing a bundled closure", and os lint keeps it a warning so the legitimate path is not punished. Intl is a standard global in every browser and in Node — writing it is not the recognisable "I am reaching for the host" act. It is the ACCIDENTAL class #13651 already defines, so it is an error a gate can fail on, travelling the path that already exists. No new kind, no change to os build's accept set: lowerCallables still catches, still bundles, still exits 0.

The one lint edit is the remedy sentence, and it is load-bearing rather than cosmetic: "inline the value(s) into the handler" is impossible for a host global, and an author — or a code-writing model — told to inline Intl lands on a second broken shape. The sentence is chosen from the refusal's own nodeOnlyIdentifiers, never re-derived in the lint, so the rule cannot disagree with what os build did to the same handler. The module-scope sentence is pinned unchanged in the same file.

Corpus reading (measured, no edit)

Whether any in-repo hook body would be newly refused: none. Scanned the corpus checkHookBodyLowering walks — all 10 objectstack.config.ts roots plus every handler: / target: under examples/ and apps/. Two inline function handlers exist (examples/app-crm/src/hooks/opportunity.hook.ts, examples/app-todo/src/objects/task.hook.ts); they use Date, Error, String only — all measured present. git grep -n "\bIntl\b" -- examples apps returns nothing, and the only URL hits in examples/app-showcase/objectstack.config.ts are in comments. examples/app-showcase already ships its callables through the functions: map by name, which is never lowered.

Changeset level

patch on @objectstack/cli. No published accept-set moves — the metadata a valid app may declare is identical, HookBodySchema is untouched, 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 affected app gains a warning plus a working bundled closure in place of a production ReferenceError. The deployment shape it "loses" was never one it had in working order, and the measured corpus of affected in-repo sources is zero.

Verification

All at f97d3b85c4 (the final commit; the gate union was derived and run on this same tree).

whatcommandresult
buildpnpm exec turbo run build --concurrency=2 --filter=@objectstack/cli + the 13 example/plugin filters the i18n gate namesexit 0
typecheckpnpm --filter @objectstack/cli run typecheckexit 0
affected filespnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 over detect-free-identifiers, sandbox-globals-probe, hook-body-lowering, extract-hook-body, lower-callables, hook-body-build-reach.e2e, vitest-tiers-partition7 files, 94 tests, all passed
package tierpnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2159 files, 2093 tests, all passed
gate unionnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands -> 37 commands, each run with its exit captured before any pipe35 exit 0, 2 NOT MEASURED
repo lintpnpm lint (eslint . --no-inline-config)exit 0

--ran reconciliation: the 37 derived commands were run 1:1, no additions. pnpm check:nul-bytes was run beyond the derived union (any edit implies it) and is green, plus a direct control-byte scan of the eight changed files (no hits).

NOT MEASURED (2), in the gates' own words — neither is a red:

  • node scripts/check-test-completeness.mjs — exit 3. "Nothing was measured: this gate exited before parsing a single summary line ... ⛔ It is not a red, and there is nothing here to fix. Fix: pass a saved turbo run test log — or, running the family locally, record this gate as NOT MEASURED."
  • node scripts/pm/check-half-states.mjs — exit 3. "Treat this exit as an unread instrument, never as a quiet board" — a PM-board census over the GitHub API, unrelated to this diff. Its sibling pnpm check:pm-half-states (also in the union) exits 0.

Two more first returned a prerequisite verdict rather than a result on a worktree built for the cli graph alone, and are reported green after building the closure they name: pnpm check:dual-build-cjs-loads ("PREREQUISITE NOT MET ... ⛔ This is NOT a pass: nothing was measured") and pnpm check:i18n-coverage ("COULD NOT MEASURE — 1 of 13 config(s) failed to lint", a missing @objectstack/connector-mcp dist). The second one matters here beyond bookkeeping: it runs os lint over all 13 in-repo configs, so its green is a direct reading that this change makes os lint report nothing new on any of them.

Deviations from the dispatch

  1. packages/cli/src/utils/extract-hook-body.ts is in the diff. The dispatch's IN list named the detector, its test and the lint rule; the refusal is thrown in extract-hook-body, and "routed through the existing refusal path" cannot be done without composing the reason there. No new refusal kind, no change to what os build accepts, and the pre-existing sentence is pinned byte-identical.
  2. The lint rule was touched, which the dispatch preferred to avoid. Only the remedy sentence, and only because the existing one ("inline the value(s)") is impossible to follow for a host global — argued above. The module-scope remedy is pinned unchanged in the same file.
  3. The probe pin is its own file rather than an addition to detect-free-identifiers.test.ts: it boots a WASM VM and imports @objectstack/runtime, and keeping it separate leaves the pure-AST unit file fast and independent of build state. It classifies as unit under the test.projects partition that landed today (pnpm --filter @objectstack/cli test is a ~24-minute serialized run, and on a shared agent container it holds the verify lock for the whole of it #13504's predicate is SPAWN ∨ KERNEL; this file is neither), and test/vitest-tiers-partition.test.ts passes on this branch.
  4. Two runs were NARROWED and executed outside the shared verify lock, declared here: (a) the residual build after a locked run was cut short by the container's foreground cap — 26 of 28 turbo tasks were already cached — plus the two temporary measurement harnesses; (b) the first typecheck and targeted six-file vitest run. Cumulative queue time on the lock was about 45 minutes, with one holder resident 1190s and another 611s. Everything in the verification table above was re-run at f97d3b85c4 with the build and the two test runs UNDER the lock.check:* gate scripts and pnpm lint are outside the lock's coverage by its own definition, so those ran unlocked by design.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

…lobals (#14301)
`detect-free-identifiers`' ambient allowlist was one generous list documented
as "assume the runtime has it". The runtime a lowered body runs in is the
QuickJS sandbox, not the Node process that runs `objectstack build` — and the
list named `Intl` beside `JSON`. A handler calling `Intl.DateTimeFormat`
therefore had no free identifier: it lowered into `body.source`, the #13651
lint rule had nothing to report (it fires only on a refused lowering), every
local gate was green because the in-process test runs the raw function in Node,
and production threw `ReferenceError: Intl is not defined`.
Split the allowlist into `SANDBOX_GLOBALS` (53) and `NODE_ONLY_GLOBALS` (15),
with membership MEASURED by a `typeof`/`in globalThis` probe evaluated inside
the same `QuickJSScriptRunner` the runtime uses and pinned by a test that reads
that probe. A free reference to a host-only name is now reported and refused
with a reason that names the identifier and the remedy — a string handler ref
or a validation rule, and `ctx.log` for `console` — travelling the existing
`free-identifiers` path, so `lowerCallables` still bundles the callable and
`os build` still exits 0 with a warning.
Not changed: whether `os build` fails on the lowering class (#13838), what the
sandbox provides, and anything under `packages/runtime/**`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 13 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/hook-bodies.mdx(via globalThis (literal, a string literal in GLOBALS; a string literal in SANDBOX_GLOBALS))
What this run could not see
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2aa8456cf2d66ec3825d262686fe4218e57cfd27packageMentionDocs.

Which tree this was computed on

This run read content/docs from bcec761c21b97b1dc8ed9cd61f527e6eb4fa6653 — the merge of head f97d3b85c46399f2ccd02ec4c084a6278be20970 into base 2aa8456cf2d66ec3825d262686fe4218e57cfd27, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin bcec761c21b97b1dc8ed9cd61f527e6eb4fa6653 && git checkout bcec761c21b97b1dc8ed9cd61f527e6eb4fa6653
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2aa8456cf2d66ec3825d262686fe4218e57cfd27 f97d3b85c46399f2ccd02ec4c084a6278be20970 && git checkout -B drift-repro 2aa8456cf2d66ec3825d262686fe4218e57cfd27 && git merge --no-ff f97d3b85c46399f2ccd02ec4c084a6278be20970
node scripts/docs-audit/affected-docs.mjs --json 2aa8456cf2d66ec3825d262686fe4218e57cfd27

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2aa8456cf2d66ec3825d262686fe4218e57cfd27 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-trump@claude
, '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

fix(cli): refuse to lower hook bodies that reference globals the sandbox does not provide (#14301) - #14661

Merged
os-trump merged 2 commits into
mainfrom
claude/issue-14301-sandbox-globals-allowlist
Sep 2, 2026
Merged

fix(cli): refuse to lower hook bodies that reference globals the sandbox does not provide (#14301)#14661
os-trump merged 2 commits into
mainfrom
claude/issue-14301-sandbox-globals-allowlist

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#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 the reported handler had no free identifier at all, lowered into body.source, passed every local gate, and threw ReferenceError in production.

This splits the allowlist in two, measures the membership inside the real sandbox, and turns a free reference to a host-only name into a lowering refusal that names the identifier and a remedy that is actually possible.

The measurement

packages/cli/src/utils/sandbox-globals-probe.test.ts evaluates, for every member of both sets, typeof X !== 'undefined' || 'X' in globalThisinside a real QuickJSScriptRunner — the same ScriptRunnerAppPlugin wires for hook and action bodies, on the same runScript path, with the same empty capability set a body with no inferred capabilities gets. The pin fails unless each set is exactly the probe's partition, so a name added from memory reddens it.

Two limbs per name, not one: typeof X alone reports the single global whose VALUE is undefinedundefined itself — as absent, and would have demanded it be listed host-only. 'X' in globalThis asks existence instead of value.

Measured present — SANDBOX_GLOBALS (53):

Math JSON Date Object Array String Number Boolean RegExp Map Set WeakMap WeakSet
Promise Symbol BigInt Function Reflect Proxy
ArrayBuffer SharedArrayBuffer DataView
Int8Array Uint8Array Uint8ClampedArray Int16Array Uint16Array Int32Array
Uint32Array Float32Array Float64Array BigInt64Array BigUint64Array
Error TypeError RangeError SyntaxError ReferenceError EvalError URIError AggregateError
parseInt parseFloat isNaN isFinite encodeURIComponent decodeURIComponent encodeURI decodeURI
undefined NaN Infinity globalThis

Measured absent — NODE_ONLY_GLOBALS (15):

Intl
structuredClone queueMicrotask atob btoa
setTimeout clearTimeout setInterval clearInterval
URL URLSearchParams TextEncoder TextDecoder
console
arguments

53 + 15 = 68 = the size of the GLOBALS set this replaces, and the two sets are disjoint — no name was dropped or invented in the split. Three of the readings are worth stating out loud:

  • Intl is the reported one: ECMA-402, standard in every browser and in Node, absent here.
  • console is a HOST object; the sandbox deliberately routes logging through the capability-gated ctx.log (buildBodyLogSurface in the runtime's body runner) and installs no global. So the refusal for console also names ctx.log — the one entry in a deliberately closed replacement table, because "keep it in a string handler ref" is poor advice for a log line when the platform's own answer is one capability away.
  • arguments is the one member that is not a global at all. It is an implicit binding of ordinary function scope, and the runner wraps a lowered body in an ARROW ((async (ctx) => { … })(ctx)), which provides none. It measures absent for a different reason than the rest and is refused for the same one: a function (ctx) { … arguments … } handler works in-process and throws once lowered.

Red-first

The card's reproduction, run against byte-for-byte copies of the two base (HEAD) sources so the reading was about the tree as it was rather than the working tree:

BASE_FREE=[] unparsed=false
BASE_OUTCOME=LOWERED
BASE_BODY_SOURCE="\n\tconst fmt = new Intl.DateTimeFormat(\"en-US\", { timeZone: \"UTC\" });\n\tctx.input.due_label = fmt.format(new Date(ctx.input.due_at));\n"

No free identifier, extraction succeeded, and the emitted body.source is the Intl call verbatim. After the change the same handler is REFUSED (kind: 'free-identifiers', freeIdentifiers: ['Intl'], nodeOnlyIdentifiers: ['Intl']), pinned in test/extract-hook-body.test.ts.

The named refusal

[hook-body-extract] hook 'stamp_due_label': handler references identifier(s) not in scope at
runtime: Intl. Intl is not available in the hook sandbox — the QuickJS build the runtime
evaluates a lowered body in does not provide it, so the body would throw ReferenceError in
production while validate, typecheck, test and build all stay green (they run the raw function
in Node, where it exists). This handler will be BUNDLED instead (no behavior change). To keep
it as metadata, keep the check in a string handler ref — put the function in the top-level
`functions:` map and write `handler: 'fn_name'` — or move it to a validation rule.

The module-scope sentence is unchanged and pinned byte-identical (content/docs/automation/hook-bodies.mdx, os build's warn-and-bundle line and --strict-body's diagnostic all quote it); the host-only branch is additional prose for a case that could not previously arrive here at all.

Ablation — the probe pin can fail

One measured-absent global moved into the sandbox set on disk, on the committed tree:

HEAD_BLOB=e8c163f85d2bf3d42a30840ff4f6e6f81abbc7be
PRE_WORKTREE_HASH=e8c163f85d2bf3d42a30840ff4f6e6f81abbc7be
PRE_removed_anchor=1 PRE_injected_marker=0 # the exact text about to change
POST_removed_anchor=0 POST_injected_marker=1 # the mutation, observed on disk
POST_WORKTREE_HASH=5ae13309b3124bedfcc7d12fd5743dde5c737334
ABLATION_PIN_EXIT=1
FAIL src/utils/sandbox-globals-probe.test.ts > the two sets are exactly the sandbox
present/absent partition
AssertionError: expected [ …(51) ] to deeply equal [ …(52) ]
POST_RESTORE_HASH=e8c163f85d2bf3d42a30840ff4f6e6f81abbc7be # == HEAD_BLOB
RESTORE_DIFF_LINES=0 RESTORE_marker_left=0

No rebuild leg: the mutated module is CLI source, imported relatively by the pin, so vitest reads it straight from src — and the red is itself the proof the mutation reached the running code. Restore is trapped (trap … EXIT INT TERM, absolute paths) and proved by bytes, not by the trap firing.

Why free-identifiers and not a new refusal kind

forbidden-token's prose fits the sandbox ("the body uses something the sandbox cannot provide") but its severity does not: that class means "writing fetch( IS choosing a bundled closure", and os lint keeps it a warning so the legitimate path is not punished. Intl is a standard global in every browser and in Node — writing it is not the recognisable "I am reaching for the host" act. It is the ACCIDENTAL class #13651 already defines, so it is an error a gate can fail on, travelling the path that already exists. No new kind, no change to os build's accept set: lowerCallables still catches, still bundles, still exits 0.

The one lint edit is the remedy sentence, and it is load-bearing rather than cosmetic: "inline the value(s) into the handler" is impossible for a host global, and an author — or a code-writing model — told to inline Intl lands on a second broken shape. The sentence is chosen from the refusal's own nodeOnlyIdentifiers, never re-derived in the lint, so the rule cannot disagree with what os build did to the same handler. The module-scope sentence is pinned unchanged in the same file.

Corpus reading (measured, no edit)

Whether any in-repo hook body would be newly refused: none. Scanned the corpus checkHookBodyLowering walks — all 10 objectstack.config.ts roots plus every handler: / target: under examples/ and apps/. Two inline function handlers exist (examples/app-crm/src/hooks/opportunity.hook.ts, examples/app-todo/src/objects/task.hook.ts); they use Date, Error, String only — all measured present. git grep -n "\bIntl\b" -- examples apps returns nothing, and the only URL hits in examples/app-showcase/objectstack.config.ts are in comments. examples/app-showcase already ships its callables through the functions: map by name, which is never lowered.

Changeset level

patch on @objectstack/cli. No published accept-set moves — the metadata a valid app may declare is identical, HookBodySchema is untouched, 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 affected app gains a warning plus a working bundled closure in place of a production ReferenceError. The deployment shape it "loses" was never one it had in working order, and the measured corpus of affected in-repo sources is zero.

Verification

All at f97d3b85c4 (the final commit; the gate union was derived and run on this same tree).

whatcommandresult
buildpnpm exec turbo run build --concurrency=2 --filter=@objectstack/cli + the 13 example/plugin filters the i18n gate namesexit 0
typecheckpnpm --filter @objectstack/cli run typecheckexit 0
affected filespnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 over detect-free-identifiers, sandbox-globals-probe, hook-body-lowering, extract-hook-body, lower-callables, hook-body-build-reach.e2e, vitest-tiers-partition7 files, 94 tests, all passed
package tierpnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2159 files, 2093 tests, all passed
gate unionnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands -> 37 commands, each run with its exit captured before any pipe35 exit 0, 2 NOT MEASURED
repo lintpnpm lint (eslint . --no-inline-config)exit 0

--ran reconciliation: the 37 derived commands were run 1:1, no additions. pnpm check:nul-bytes was run beyond the derived union (any edit implies it) and is green, plus a direct control-byte scan of the eight changed files (no hits).

NOT MEASURED (2), in the gates' own words — neither is a red:

  • node scripts/check-test-completeness.mjs — exit 3. "Nothing was measured: this gate exited before parsing a single summary line ... ⛔ It is not a red, and there is nothing here to fix. Fix: pass a saved turbo run test log — or, running the family locally, record this gate as NOT MEASURED."
  • node scripts/pm/check-half-states.mjs — exit 3. "Treat this exit as an unread instrument, never as a quiet board" — a PM-board census over the GitHub API, unrelated to this diff. Its sibling pnpm check:pm-half-states (also in the union) exits 0.

Two more first returned a prerequisite verdict rather than a result on a worktree built for the cli graph alone, and are reported green after building the closure they name: pnpm check:dual-build-cjs-loads ("PREREQUISITE NOT MET ... ⛔ This is NOT a pass: nothing was measured") and pnpm check:i18n-coverage ("COULD NOT MEASURE — 1 of 13 config(s) failed to lint", a missing @objectstack/connector-mcp dist). The second one matters here beyond bookkeeping: it runs os lint over all 13 in-repo configs, so its green is a direct reading that this change makes os lint report nothing new on any of them.

Deviations from the dispatch

  1. packages/cli/src/utils/extract-hook-body.ts is in the diff. The dispatch's IN list named the detector, its test and the lint rule; the refusal is thrown in extract-hook-body, and "routed through the existing refusal path" cannot be done without composing the reason there. No new refusal kind, no change to what os build accepts, and the pre-existing sentence is pinned byte-identical.
  2. The lint rule was touched, which the dispatch preferred to avoid. Only the remedy sentence, and only because the existing one ("inline the value(s)") is impossible to follow for a host global — argued above. The module-scope remedy is pinned unchanged in the same file.
  3. The probe pin is its own file rather than an addition to detect-free-identifiers.test.ts: it boots a WASM VM and imports @objectstack/runtime, and keeping it separate leaves the pure-AST unit file fast and independent of build state. It classifies as unit under the test.projects partition that landed today (pnpm --filter @objectstack/cli test is a ~24-minute serialized run, and on a shared agent container it holds the verify lock for the whole of it #13504's predicate is SPAWN ∨ KERNEL; this file is neither), and test/vitest-tiers-partition.test.ts passes on this branch.
  4. Two runs were NARROWED and executed outside the shared verify lock, declared here: (a) the residual build after a locked run was cut short by the container's foreground cap — 26 of 28 turbo tasks were already cached — plus the two temporary measurement harnesses; (b) the first typecheck and targeted six-file vitest run. Cumulative queue time on the lock was about 45 minutes, with one holder resident 1190s and another 611s. Everything in the verification table above was re-run at f97d3b85c4 with the build and the two test runs UNDER the lock.check:* gate scripts and pnpm lint are outside the lock's coverage by its own definition, so those ran unlocked by design.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

…lobals (#14301)
`detect-free-identifiers`' ambient allowlist was one generous list documented
as "assume the runtime has it". The runtime a lowered body runs in is the
QuickJS sandbox, not the Node process that runs `objectstack build` — and the
list named `Intl` beside `JSON`. A handler calling `Intl.DateTimeFormat`
therefore had no free identifier: it lowered into `body.source`, the #13651
lint rule had nothing to report (it fires only on a refused lowering), every
local gate was green because the in-process test runs the raw function in Node,
and production threw `ReferenceError: Intl is not defined`.
Split the allowlist into `SANDBOX_GLOBALS` (53) and `NODE_ONLY_GLOBALS` (15),
with membership MEASURED by a `typeof`/`in globalThis` probe evaluated inside
the same `QuickJSScriptRunner` the runtime uses and pinned by a test that reads
that probe. A free reference to a host-only name is now reported and refused
with a reason that names the identifier and the remedy — a string handler ref
or a validation rule, and `ctx.log` for `console` — travelling the existing
`free-identifiers` path, so `lowerCallables` still bundles the callable and
`os build` still exits 0 with a warning.
Not changed: whether `os build` fails on the lowering class (#13838), what the
sandbox provides, and anything under `packages/runtime/**`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 13 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/hook-bodies.mdx(via globalThis (literal, a string literal in GLOBALS; a string literal in SANDBOX_GLOBALS))
What this run could not see
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2aa8456cf2d66ec3825d262686fe4218e57cfd27packageMentionDocs.

Which tree this was computed on

This run read content/docs from bcec761c21b97b1dc8ed9cd61f527e6eb4fa6653 — the merge of head f97d3b85c46399f2ccd02ec4c084a6278be20970 into base 2aa8456cf2d66ec3825d262686fe4218e57cfd27, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin bcec761c21b97b1dc8ed9cd61f527e6eb4fa6653 && git checkout bcec761c21b97b1dc8ed9cd61f527e6eb4fa6653
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2aa8456cf2d66ec3825d262686fe4218e57cfd27 f97d3b85c46399f2ccd02ec4c084a6278be20970 && git checkout -B drift-repro 2aa8456cf2d66ec3825d262686fe4218e57cfd27 && git merge --no-ff f97d3b85c46399f2ccd02ec4c084a6278be20970
node scripts/docs-audit/affected-docs.mjs --json 2aa8456cf2d66ec3825d262686fe4218e57cfd27

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2aa8456cf2d66ec3825d262686fe4218e57cfd27 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-trump@claude