Skip to content

Commit 1eff322

Browse files
fix(cli)!: objectstack build refuses to lower a hook body reaching ctx.dispatch.scope or ctx.submitted (#22810) (#22834)
Fixes #22810 Clause-②: no (narrowing) ## What changes `extractHookBody` (`packages/cli/src/utils/extract-hook-body.ts`) now refuses a hook handler that reaches `ctx.dispatch.scope` or reads `ctx.submitted`. Both are new `FORBIDDEN_PATTERNS` entries beside `.sudo(` and `.create(`, of kind `forbidden-token`. The refusal names the member and the remedy. The `hook-body-lowering` lint rule calls the same function (`judge()` in `packages/cli/src/lint/hook-body-lowering.ts`, read at the call site), so one edit covers `objectstack build` and `os lint`. The premise was verified on `origin/main` `b7cd1af9df` before any code was written: - `buildSandboxContext` (`packages/runtime/src/sandbox/body-runner.ts`) copies `ctx.dispatch` as `{ mode, index }` ("`scope` is deliberately not copied") and assembles no `submitted`. - `HookContextSchema` (`packages/spec/src/data/hook.zod.ts`) says the body face carries `{ mode, index }` "and not `scope`", and that `submitted` is "NOT marshalled into the sandboxed `body` face". So direction (A) holds. There is no runtime change and no spec change. ## What "refused" means on each door (measured) I built a scratch app with 7 hooks through the real CLI (`packages/cli/bin/run-dev.js build`, from source). I then booted it with `bootStack(config, { artifact })` in two ways: on the build's own `dist/`, with its runtime module beside it, and on the dogfood stand-in artifact, which has no runtime module. | door | base `b7cd1af9df` | this PR | |---|---|---| | `objectstack build` | scope and submitted handlers lowered to bodies, exit 0, no warning | bundled instead, exit 0, with a warning that names each hook and the member | | `objectstack build --strict-body` | exit 1, for the `fetch(` control only | exit 1, also naming `hook 'zz_scope'`, `hook 'zz_scope_alias'` and `hook 'zz_sub'` | | `os lint` | silent | a `hook-body/bundled-fallback` warning at `hooks[i].handler`, the rule `.sudo(` lands on; exit 0 | | artifact door, runtime module served | scope: `SandboxError: hook 'zz_scope' threw: TypeError: cannot set property 'stashed' of undefined`, REST `500 INTERNAL_ERROR`, nothing stored. submitted: `typeof ctx.submitted` is `'undefined'` | scope: `201`, the row is stored and stashed. submitted: `'object'`. Both match the source boot | | artifact door, artifact alone (no runtime module) | as above | the hook is not bound (the boot logs `hook refused: its handler names no function of its own package`); the insert answers `201` without the hook | | source boot (in-process control) | `201`, stashed; `'object'` | unchanged | Mechanism assumption 4 was measured before its refusal was pinned: a lowered body reads `ctx.submitted` as `undefined` on an update, both on the stand-in artifact and on the real build's artifact. In-process it reads `'object'`. The last two rows follow the precedent's documented fallback: `.sudo(`, `.create(` and `fetch(` behave the same way. A deployment that must be body-only builds with `--strict-body`. `content/docs/automation/hook-bodies.mdx` now says so. ## Reach (mechanism assumption 3) The precedents are receiver-loose, because `sudo` names nothing except the host method. `scope` and `submitted` are also ordinary field names: platform objects declare a `scope` field, and reading it through `ctx.input.scope` is working, lowerable code. The reach is therefore receiver-loose on ROOT identifiers (`ctx` or any local alias of it), and never on a member reached through a record image: - **`dispatch.scope` is refused when** the body names `dispatch` and reaches `scope`. The spellings covered are `ctx.dispatch.scope` (with or without `?.`), `ctx.dispatch['scope']`, `const d = ctx.dispatch; d.scope`, `const { dispatch } = ctx; dispatch.scope`, `const { scope } = ctx.dispatch` and `const { dispatch: { scope } } = ctx`. - **`submitted` is refused for** `ctx.submitted`, `ctx?.submitted`, `ctx['submitted']`, `const c = ctx; c.submitted`, `const { input, submitted } = ctx` and `const { submitted: s } = ctx`. - **Not refused (pinned controls):** `ctx.dispatch.mode` and `ctx.dispatch.index`, and `ctx.input.scope` / `ctx.previous.submitted`, also beside a `dispatch` read. - **Over-refused, knowingly, in the safe direction** (the handler is bundled and keeps working): `.submitted` off a root-bound row (`const row = ctx.input; row.submitted`), a field of that name destructured from a record, and `x.submitted` / `x.scope` inside a string literal. - **Knowingly not caught:** - computed keys (`ctx.dispatch[k]`); - `Reflect.get(ctx.dispatch, 'scope')`; - a parenthesised receiver (`(ctx).submitted`); - destructuring in the handler's parameter list. The lowering drops the parameter list entirely, so such a handler fails once lowered whatever it touches. That is the out-of-scope finding below. A probe of 29 spellings matched this list exactly. The regex stays linear: a 680 KB body extracts in 480 ms, including the TypeScript parse. ## The dogfood pin: moved, not deleted `packages/qa/dogfood/test/lowered-body-door.dogfood.test.ts` pinned `lbd_stash`, which writes `ctx.dispatch.scope`, as its divergence. Against this change the base version of the file goes red on exactly the three cases the filing seat predicted: the anti-vacuity case and both divergence cases. I ran it once and then deleted the copy. - **The divergence moves** to `lbd_param`, a handler whose parameter is named `hookCtx` rather than `ctx`. The lowering ships the body and drops the parameter list, so the body's `hookCtx` is unbound. Lowered, it throws `ReferenceError` and REST answers `500 INTERNAL_ERROR`. In-process it answers `201`. This is the next real lowered-only divergence measured, and it is a defect in its own right (see the findings below). - **`lbd_stash` is re-pointed at the refusal.** The lowering bundles it and ships no body for it, while the other three hooks still lower. In-process it still stashes, through the handle and over REST. - **`lbd_stash` stays out of the booted artifact.** The stand-in writes no runtime module, so a bundled handler would have nothing to resolve against there. A real build ships it in the `.mjs`. ## Tests All runs below are at `405c2c3c2` unless noted otherwise. - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2`: 281 files and 4185 tests passed (`VERDICT command-exit 0`). The integration tier is declared to CI: this diff touches no spawn entry and no integration-tier file. - `pnpm --filter @objectstack/cli typecheck` (`tsc --noEmit` plus `check:test-typecheck`): exit 0. `pnpm --filter @objectstack/dogfood typecheck`: exit 0. `--listFiles` confirms that both changed test files are in the type-check programs. - `pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/lowered-body-door.dogfood.test.ts`: 11 passed, at `9c47337a2`. The file is unchanged since. - New pins, which assert the kind, the origin and the named member rather than the prose: - `packages/cli/test/extract-hook-body.test.ts`: the scope write, direct and aliased; the submitted read, direct and destructured; the controls for `mode` / `index` and for fields named `scope` / `submitted`; and `lowerCallables` keeping the very function bundled, which still stashes on a host-shaped context. - `packages/cli/src/lint/hook-body-lowering.test.ts`: both refusals land on `hook-body/bundled-fallback` at their paths, the mode/index control stays silent, and lint agrees with what the build records. - **Ablation**, run through `node scripts/ablation-replace.mjs` in wrap mode with the anchor counted, the blob changed and then restored to the HEAD blob with an empty `git diff HEAD`, on committed `9c47337a2`. The subject is reached by relative source import, so no `dist/` leg was needed. - `rx: DISPATCH_SCOPE_RX` replaced with a never-matching regex: 5 cli tests and 1 dogfood test went red. - `rx: SUBMITTED_RX` replaced the same way: 4 cli tests went red. - Both runs went red in the expected direction. - **Gates.** `node scripts/pm/dispatch-gates.mjs --commands` derived 94 commands from the actual diff. That set is a superset of the 58 in the dispatch order apart from `pnpm lint`. All 94 were run and reconciled with `--ran`: 94 run, 0 NOT MEASURED, every one exit 0. Two of them first exited 3 (PREREQUISITE NOT MET, because unrelated packages had no `dist/`): `check:skill-examples` and `check:dual-build-cjs-loads`. Both were re-run green after building those packages. The gates that read `dist/` were re-run after `@objectstack/cli` was rebuilt at HEAD. - **Lint**, as a proven narrowing rather than a full `pnpm lint`: 1. The population comes from eslint's own configuration. `eslint --no-inline-config --format json` over the 7 changed paths reports 4 files linted and 3 ignored as "no matching configuration": the changeset and the two `.mdx` files. 2. The JSON output reports 7 results, with 0 errors and 0 warnings on the 4 linted files. 3. Invariance: `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`). Its only cross-file reads are two baseline JSON files this diff does not touch. So the diff cannot move any untouched file's verdict. ## Acceptance notes - `origin/main` moved 3 commits past the base (`efcbac73c`), and none of them touch these surfaces. I did not merge; the queue rebuilds on merge. - File surface beyond the claim: two documentation edits, both describing this refusal. They are the "What the sandbox forbids" list in `content/docs/automation/hook-bodies.mdx` and the `ctx.submitted` line in `content/docs/protocol/objectql/security.mdx`. - The changeset states each door's behaviour before and now in prose. A `FROM → TO` label line reads to `check:adr-0087-registration` as a code-rewrite prescription, which contradicts the only disposition that fits (`not-required (no-migration-prescription)`: no metadata, export or stored shape moves). This was measured: the gate refused the labelled draft and passed the prose form. - An artifact served without its runtime module leaves any bundled hook unbound. The boot logs it as refused, and writes proceed without the hook. This is the existing fallback for every forbidden token and is not new here. It is documented in `hook-bodies.mdx`. ## Out-of-scope findings (handed to the seat, not filed here) 1. **Class a.** The lowering drops the handler's parameter list. A handler whose parameter is not named `ctx` (`async (hookCtx) => …`), or that destructures it (`async ({ input }) => …`), lowers at exit 0. Its body then throws `ReferenceError` on every run, and REST answers `500 INTERNAL_ERROR` at the artifact door, both with the runtime module served and without it. In-process it answers `201`. `extractHookBody` peels the body, and `detectFreeIdentifiers` counts the parameters as bound. It is the same family as this card, and it is now the dogfood divergence pin. 2. **Class a, misleading text.** `objectstack build --strict-body` counts one more callable than it found. With 4 refused handlers it prints `5 callable(s) lack a metadata body`; on base, 1 refused handler printed `2`. In `packages/cli/src/commands/compile.ts`, `issues.length` counts the extraction warnings plus an aggregate row. The `--json` `issues` list carries that aggregate row as if it were a callable. --- _Generated by [Claude Code](https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a6f3a13 commit 1eff322

7 files changed

Lines changed: 419 additions & 38 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@objectstack/cli": minor
3+
---
4+
5+
fix(cli)!: `objectstack build` refuses to lower a hook body that reaches `ctx.dispatch.scope` or `ctx.submitted` (#22810)
6+
7+
Clause-②: no (narrowing)
8+
9+
The sandboxed body face does not carry either member, and the spec says so: `HookContextSchema.dispatch` gives a body `{ mode, index }` "and not `scope`", and `submitted` is "NOT marshalled into the sandboxed `body` face". The build lowered such a handler anyway, at exit 0, with no warning. Measured through `bootStack`'s artifact door: a body writing `ctx.dispatch.scope.<key>` threw `TypeError: cannot set property … of undefined` on every insert, so REST answered `500 INTERNAL_ERROR` and stored nothing, while the same handler in-process answered `201`. A body reading `ctx.submitted` saw `undefined` on an update, where the in-process handler sees the caller's submission, so a guard written on it evaluated differently with no error at all.
10+
11+
The hook body extractor (`extractHookBody`) now refuses both members as forbidden tokens, the way it already refuses `.sudo(` and `.create(`. `os lint` calls the same extractor, so it reports them too.
12+
13+
**BREAKING — what moves for consumers.**
14+
15+
- **`objectstack build`.** A handler that writes or reads `ctx.dispatch.scope`, or reads `ctx.submitted`, was lowered to a `body` that fails in the sandbox. It is now bundled into the runtime module (`objectstack-runtime.*.mjs`) instead, with a warning naming the hook and the member, and the build still exits 0. Where that module is served beside the artifact the handler runs in-process, so `scope` and `submitted` are real again: measured, the same insert answers `201` and stashes. A deployment that serves `objectstack.json` alone has no function to bind the hook to, so it logs the hook as refused at boot and the hook does not fire.
16+
- **`objectstack build --strict-body`.** Such a handler built at exit 0. It now fails the build (exit 1), naming the hook and the member.
17+
- **`os lint`.** It said nothing. It now reports a `hook-body/bundled-fallback` warning at `hooks[i].handler`.
18+
- **The spellings refused.** Direct and optional (`ctx.dispatch.scope`, `ctx?.submitted`), bracket (`ctx.dispatch['scope']`), through an alias (`const d = ctx.dispatch; d.scope`, `const c = ctx; c.submitted`), and destructured (`const { scope } = ctx.dispatch`, `const { submitted } = ctx`). For `scope` the body must also name `dispatch` somewhere, because `scope` is an ordinary field name.
19+
- **Not refused.** `ctx.dispatch.mode` and `ctx.dispatch.index`, and a field that happens to be named `scope` or `submitted` read through `ctx.input` or `ctx.previous` (`ctx.input.scope`). A body reading such a field off a local row variable (`row.submitted`), or spelling `x.submitted` inside a string, is refused too. That is the over-refusal side: the handler is bundled and keeps working.
20+
21+
**The one-line fix**, for a handler you want shipped as a body: do the cross-dispatch work once at `ctx.dispatch.index === 0`, or keep the state in the record through `ctx.api`, instead of stashing on `ctx.dispatch.scope`; read the stored row on `ctx.previous` instead of `ctx.submitted`. A handler you are content to run bundled needs no change.
22+
23+
**Unchanged.** No spec key, export, type or stored shape moves. The sandbox still marshals exactly what it did: `{ mode, index }` for `ctx.dispatch`, no `ctx.submitted`. An in-process handler, a top-level `functions:` entry referenced by name, and a hook that carries an explicit `body` are not lowered here and are not judged.
24+
25+
<!-- adr-0087: not-required (no-migration-prescription) a refusal at build time of handler code the sandbox already could not run: no authorable key, spelling, export or stored shape moves, and no stored row is read, rewritten or converted. The refused handler keeps running bundled, so no consumer has to edit anything for its build to succeed, and the edit that makes it a body again is a change to handler code, which no ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers the lowering (not already-registered); and the change is a build verdict, not a declaration (not runtime-interface-only or type-surface-only). -->

‎content/docs/automation/hook-bodies.mdx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,9 @@ The CLI builder **rejects** any source that uses:
168168
- `eval`, `new Function`
169169
- references to identifiers from value-only top-level imports
170170
- `.sudo(` and `.create(` — members that are real on the **in-process** `ScopedContext` / `ObjectRepository` and absent from the VM's `ctx.api`, so lowering them would ship a body that `TypeError`s on its first run. Write `.insert({ ... })` instead of `.create({ ... })` — it is the only insert verb the `IScopedObjectRepository` contract declares — and reach for [`runAs: 'system'`](/docs/automation/hooks#elevation--runas) instead of `.sudo()`. `Object.create()` is a real sandbox global and is **not** affected.
171+
- `ctx.dispatch.scope` and `ctx.submitted` — context members the **in-process** `HookContext` carries and the body face deliberately does not: a body's `ctx.dispatch` is `{ mode, index }` only, and it has no `ctx.submitted`. Lowered, a write through `scope` `TypeError`s on its first run and a read of `submitted` sees `undefined`, so a guard on it evaluates as if nothing were submitted. For cross-dispatch work, do it once at `ctx.dispatch.index === 0` or keep the state in the record through `ctx.api`; to derive from a read-only field, read the stored row on `ctx.previous`. `ctx.dispatch.mode` / `ctx.dispatch.index`, and a field that merely happens to be named `scope` or `submitted` (`ctx.input.scope`), are **not** affected.
172+
173+
Like `.sudo(` and `.create(`, these two refuse the **body**, not the handler: `objectstack build` bundles the handler into its runtime module (`objectstack-runtime.*.mjs`) instead and still exits 0 (with a warning naming the hook), `os lint` reports it as `hook-body/bundled-fallback`, and `objectstack build --strict-body` fails on it. The bundled handler runs in-process wherever that module is served beside `objectstack.json`; a deployment that serves the artifact alone has no function to bind the hook to, so it logs the hook as refused at boot and the hook never fires — build with `--strict-body` when the artifact must be body-only.
171174

172175
Need outbound HTTP? Define a **Connector recipe** as metadata and call it via `ctx.connector(...)`. (Connector spec is tracked separately and ships after L1+L2 stabilises.)
173176

‎content/docs/protocol/objectql/security.mdx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -300,9 +300,10 @@ same writes. What moved is the hook's view.
300300
`ctx.submitted`.
301301
- A self-assignment (`data.x = data.x`) on such a field is now a **no-op** — the stored
302302
value stands, and the write is stripped and reported exactly as an un-hooked one is.
303-
- `ctx.submitted` is **not** marshalled onto the sandboxed `body` face. A `body` deriving
304-
from a read-only column reads `ctx.previous`, the stored row, which is the correct
305-
source either way.
303+
- `ctx.submitted` is **not** marshalled onto the sandboxed `body` face, and `objectstack
304+
build` refuses to lower a handler that reads it (the handler is bundled instead). A
305+
`body` deriving from a read-only column reads `ctx.previous`, the stored row, which is
306+
the correct source either way.
306307

307308
`readonlyWhen` locks are deliberately still hook-writable (#9107).
308309

‎packages/cli/src/lint/hook-body-lowering.test.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,63 @@ describe('checkHookBodyLowering', () => {
207207
expect(checkHookBodyLowering({ hooks: [selfContainedHook] })).toEqual([]);
208208
});
209209

210+
// [#22810] The context members a body face does not carry — `dispatch.scope`
211+
// and `submitted` — are refused by the same `extractHookBody` the build
212+
// calls, so lint reports them on the same rule `.sudo(` / `.create(` land on:
213+
// the structural class, a warning, because the bundle is where the handler
214+
// still works. `--strict-body` is what makes them fatal at build time.
215+
describe('a body reaching a context member the sandbox does not marshal (#22810)', () => {
216+
const scopeHook = {
217+
name: 'stash_total',
218+
object: 'order',
219+
events: ['beforeInsert'],
220+
handler: (ctx: any) => {
221+
ctx.dispatch.scope.total = Number(ctx.input.amount);
222+
},
223+
};
224+
const submittedHook = {
225+
name: 'guard_rename',
226+
object: 'order',
227+
events: ['beforeUpdate'],
228+
handler: (ctx: any) => {
229+
if (ctx.submitted?.name) ctx.input.note = 'renamed';
230+
},
231+
};
232+
const modeHook = {
233+
name: 'batch_once',
234+
object: 'order',
235+
events: ['beforeUpdate'],
236+
handler: (ctx: any) => {
237+
if (ctx.dispatch?.mode === 'per-row' && ctx.dispatch.index === 0) ctx.input.note = 'first';
238+
},
239+
};
240+
241+
it('reports each on the bundled-fallback rule, naming the hook, its path and the member', () => {
242+
const issues = checkHookBodyLowering({ hooks: [scopeHook, submittedHook] });
243+
expect(issues.map((i) => [i.rule, i.severity, i.path])).toEqual([
244+
[BUNDLED_FALLBACK_RULE, 'warning', 'hooks[0].handler'],
245+
[BUNDLED_FALLBACK_RULE, 'warning', 'hooks[1].handler'],
246+
]);
247+
expect(issues[0].message).toContain("hook 'stash_total'");
248+
expect(issues[0].message).toContain('`ctx.dispatch.scope`');
249+
expect(issues[1].message).toContain("hook 'guard_rename'");
250+
expect(issues[1].message).toContain('`ctx.submitted`');
251+
});
252+
253+
it('CONTROL: says nothing about `ctx.dispatch.mode` / `ctx.dispatch.index`', () => {
254+
expect(checkHookBodyLowering({ hooks: [modeHook] })).toEqual([]);
255+
});
256+
257+
it('agrees with what the build records for the same hooks', () => {
258+
const lowering = lowerCallables({ hooks: [{ ...scopeHook }, { ...submittedHook }, { ...modeHook }] });
259+
expect(lowering.bodyExtractionWarnings.map((w) => `${w.origin}|${w.kind}`)).toEqual([
260+
"hook 'stash_total'|forbidden-token",
261+
"hook 'guard_rename'|forbidden-token",
262+
]);
263+
expect(lowering.bodyExtracted).toBe(1);
264+
});
265+
});
266+
210267
it('truncates the multi-line offending-source dump to one line', () => {
211268
const [issue] = checkHookBodyLowering({ hooks: [forbiddenTokenHook] });
212269
expect(issue.message).not.toContain('--- offending body source ---');

‎packages/cli/src/utils/extract-hook-body.ts‎

Lines changed: 123 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,14 @@
1515
* extracted body — full TypeScript AST analysis is deferred to v2. Anything
1616
* the regex rejects (top-level `import`, `require(` / esbuild's `__require(`,
1717
* `fetch(`, `process.*`, `globalThis.*`, `eval`, `new Function`, `.sudo(`,
18-
* `.create(`) makes extraction **throw**.
18+
* `.create(`, `ctx.dispatch.scope`, `ctx.submitted`) makes extraction **throw**.
1919
*
20-
* The last two are one family: a member that is REAL on the host
21-
* `ScopedContext`/`ObjectRepository` and absent from the VM's `ctx.api`, so the
22-
* same handler source passes an in-process test and TypeErrors the moment the
23-
* build lowers it into a body. `.create(` carries one wrinkle `.sudo(` does not
24-
* — see its entry in `FORBIDDEN_PATTERNS`.
20+
* The last four are one family: a member that is REAL on the host context
21+
* (`ScopedContext`/`ObjectRepository` for the first two, the engine's
22+
* `HookContext` for the last two) and absent from what the VM hands a body, so
23+
* the same handler source passes an in-process test and fails the moment the
24+
* build lowers it into a body. `.create(` and `dispatch.scope` each carry a
25+
* wrinkle `.sudo(` does not — see their entries in `FORBIDDEN_PATTERNS`.
2526
*
2627
* ⚠️ What that throw costs the BUILD depends on the flag, and the two outcomes
2728
* are not the same one. This header used to claim only the second (#10678):
@@ -173,6 +174,52 @@ export class HookBodyExtractionError extends Error {
173174
}
174175
}
175176

177+
/**
178+
* A member read off a ROOT identifier — `ctx` itself, or any local name a
179+
* handler bound (`const c = ctx`, `const d = ctx.dispatch`) — as opposed to a
180+
* member reached through another member (`ctx.input.<field>`).
181+
*
182+
* The `.sudo(` / `.create(` reach (receiver-loose) restated for a member whose
183+
* NAME is not distinctive. `sudo` names nothing but the host method, so any
184+
* receiver may be refused; `scope` and `submitted` are also ordinary field
185+
* names, and a field read through a record image the body DOES receive
186+
* (`ctx.input.scope`, `ctx.previous.submitted`) is working, lowerable code —
187+
* refusing it would be the false refusal `.create(` carves `Object` out for.
188+
* A root receiver is what both an alias and the context itself look like; a
189+
* record image never is. What it still over-refuses — a root-bound ROW with a
190+
* field of that name (`const row = ctx.input; row.submitted`), and the same
191+
* token inside a string literal — is listed at the two entries below.
192+
*/
193+
const ROOT_RECEIVER = String.raw`(?<![\w$]|\.\s*)[A-Za-z_$][\w$]*`;
194+
195+
/** `ctx.dispatch.scope` in any spelling below — gated on the body naming `dispatch` at all. */
196+
const DISPATCH_SCOPE_RX = new RegExp(
197+
String.raw`^(?=[\s\S]*\bdispatch\b)[\s\S]*?(?:` +
198+
[
199+
// `ctx.dispatch.scope`, `ctx?.dispatch?.scope`, a destructured
200+
// `dispatch.scope`, and an alias of the marker (`d.scope`).
201+
String.raw`(?:\bdispatch|${ROOT_RECEIVER})\s*\??\.\s*scope\b`,
202+
// The same receivers, bracket-spelled: `ctx.dispatch['scope']`.
203+
String.raw`(?:\bdispatch|${ROOT_RECEIVER})\s*(?:\?\.)?\s*\[\s*(['"\x60])scope\1\s*\]`,
204+
// Destructured: `const { scope } = ctx.dispatch`, `const { dispatch: { scope } } = ctx`.
205+
String.raw`\{[^{}]*\bscope\b[^{}]*\}\s*=(?![=>])`,
206+
String.raw`\bdispatch\s*:\s*\{[^{}]*\bscope\b`,
207+
].join('|') +
208+
')',
209+
);
210+
211+
/** `ctx.submitted` in any spelling below. */
212+
const SUBMITTED_RX = new RegExp(
213+
[
214+
// `ctx.submitted`, `ctx?.submitted`, and a context alias (`c.submitted`).
215+
String.raw`${ROOT_RECEIVER}\s*\??\.\s*submitted\b`,
216+
// Bracket-spelled: `ctx['submitted']`.
217+
String.raw`${ROOT_RECEIVER}\s*(?:\?\.)?\s*\[\s*(['"\x60])submitted\1\s*\]`,
218+
// Destructured: `const { input, submitted } = ctx`.
219+
String.raw`\{[^{}]*\bsubmitted\b[^{}]*\}\s*=(?![=>])`,
220+
].join('|'),
221+
);
222+
176223
const FORBIDDEN_PATTERNS: Array<{ rx: RegExp; reason: string }> = [
177224
{ rx: /\bimport\s*[\(\*\{]/, reason: 'dynamic `import()` and ES imports are not allowed in hook/action bodies — declare a Connector recipe instead' },
178225
// Both spellings, one reason (#10678). A TypeScript config is loaded through
@@ -262,6 +309,76 @@ const FORBIDDEN_PATTERNS: Array<{ rx: RegExp; reason: string }> = [
262309
+ 'Alternatively leave this handler bundled so it runs in-process, where the host repository\'s '
263310
+ '`create()` alias exists',
264311
},
312+
// [#22810] Same family as `.sudo(` / `.create(`, one surface over: not a
313+
// member of `ctx.api` but of the context itself. The omission is the DECLARED
314+
// contract — `HookContextSchema.dispatch` (packages/spec/src/data/hook.zod.ts)
315+
// says the body face carries `{ mode, index }` "and not `scope`", and
316+
// `buildSandboxContext` (runtime/src/sandbox/body-runner.ts) copies exactly
317+
// those two: `scope`'s contract is shared object identity across every
318+
// dispatch of one write, which a JSON copy into the VM heap cannot keep. So a
319+
// body writing `ctx.dispatch.scope.<key>` is `TypeError: cannot set property
320+
// … of undefined` on its first run — measured through `bootStack`'s artifact
321+
// door as a `SandboxError` with no code, and `500 INTERNAL_ERROR` over REST,
322+
// where the in-process handler stores the row.
323+
//
324+
// ⛔ Marshalling `scope` instead is a second published body contract, not a
325+
// fix (the spec text: "a body needing it is the signal to raise that
326+
// question, not to add it silently").
327+
//
328+
// Reach, and the one wrinkle: `scope` is an ordinary field name (platform
329+
// objects declare it), so a bare receiver-loose `.scope` would refuse
330+
// `ctx.input.scope` — working code. Two narrowings keep the alias reach
331+
// without that: the body must name `dispatch` somewhere, and the member must
332+
// hang off `dispatch` or a ROOT identifier (see `ROOT_RECEIVER`). Caught:
333+
// `ctx.dispatch.scope`, `?.`, brackets, `const d = ctx.dispatch; d.scope`,
334+
// `const { dispatch } = ctx; dispatch.scope`, `const { scope } =
335+
// ctx.dispatch`, `const { dispatch: { scope } } = ctx`. Over-refused (the
336+
// safe direction — the handler is bundled and runs): a dispatch-naming body
337+
// that also reads `.scope` off a root-bound row, or spells `x.scope` inside a
338+
// string. Knowingly NOT caught: a computed key (`ctx.dispatch[k]`),
339+
// `Reflect.get(ctx.dispatch, 'scope')`, a parenthesised or indexed receiver
340+
// (`(ctx.dispatch).scope`), and a handler that destructures `dispatch` in its
341+
// PARAMETER list — the lowering drops the parameter list entirely, so that
342+
// handler fails once lowered whatever it touches.
343+
{
344+
rx: DISPATCH_SCOPE_RX,
345+
reason:
346+
'`ctx.dispatch.scope` is not reachable from a sandboxed body — the VM\'s `ctx.dispatch` carries '
347+
+ 'only `mode` and `index` (`scope` is shared object identity across every dispatch of one write, '
348+
+ 'which a copy into the sandbox cannot keep), so writing through it is a TypeError at run time '
349+
+ '(and under a hook\'s default `onError: \'abort\'` that aborts the triggering write) and a '
350+
+ 'guarded read sees `undefined`. Do the batch-scoped work once, at `ctx.dispatch.index === 0`, '
351+
+ 'or keep cross-dispatch state in the record itself through `ctx.api`; `ctx.dispatch.mode` and '
352+
+ '`ctx.dispatch.index` are unaffected. Alternatively leave this handler bundled so it runs '
353+
+ 'in-process, where the engine\'s shared `scope` exists',
354+
},
355+
// [#22810] The sibling: `HookContextSchema.submitted` is "NOT marshalled into
356+
// the sandboxed `body` face, for the same reason `dispatch.scope` is not",
357+
// and `buildSandboxContext` assembles the face key by key without it. A
358+
// lowered body reads `undefined` — measured: `typeof ctx.submitted` is
359+
// `'object'` in-process on an update and `'undefined'` from the artifact —
360+
// so a guard written on it evaluates as if nothing were submitted, with no
361+
// error at all. The silent half of this family, which is why it is refused
362+
// rather than left to fail at run time.
363+
//
364+
// Reach: `ROOT_RECEIVER`, ungated — no `dispatch`-style anchor exists for a
365+
// top-level context member. `ctx.input.submitted` / `ctx.previous.submitted`
366+
// (a field of that name, read through a record image) are NOT refused.
367+
// Over-refused: `.submitted` off a root-bound row (`row.submitted`), a
368+
// destructured field of that name (`const { submitted } = ctx.input`), and
369+
// `x.submitted` inside a string. Knowingly NOT caught: computed keys,
370+
// `Reflect.get`, a parenthesised receiver, and parameter-list destructuring
371+
// (the same parameter-list gap as above).
372+
{
373+
rx: SUBMITTED_RX,
374+
reason:
375+
'`ctx.submitted` is not reachable from a sandboxed body — the VM\'s `ctx` is assembled key by key '
376+
+ 'and does not carry the caller\'s submission, so it reads `undefined` there: a guard written on it '
377+
+ 'evaluates as if nothing were submitted, and reaching into it is a TypeError at run time. To '
378+
+ 'derive a value from a read-only field, read the stored row on `ctx.previous`, which is the '
379+
+ 'correct source either way. Alternatively leave this handler bundled so it runs in-process, '
380+
+ 'where `ctx.submitted` carries the caller\'s submission on update',
381+
},
265382
];
266383

267384
const CAPABILITY_PATTERNS: Array<{ rx: RegExp; cap: 'api.read' | 'api.write' | 'crypto.uuid' | 'log' }> = [

0 commit comments

Comments
 (0)