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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/22810-lowered-body-scope-submitted-refused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
"@objectstack/cli": minor
---

fix(cli)!: `objectstack build` refuses to lower a hook body that reaches `ctx.dispatch.scope` or `ctx.submitted` (#22810)

Clause-②: no (narrowing)

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.

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.

**BREAKING — what moves for consumers.**

- **`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.
- **`objectstack build --strict-body`.** Such a handler built at exit 0. It now fails the build (exit 1), naming the hook and the member.
- **`os lint`.** It said nothing. It now reports a `hook-body/bundled-fallback` warning at `hooks[i].handler`.
- **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.
- **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.

**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.

**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.

<!-- 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). -->
3 changes: 3 additions & 0 deletions content/docs/automation/hook-bodies.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,9 @@ The CLI builder **rejects** any source that uses:
- `eval`, `new Function`
- references to identifiers from value-only top-level imports
- `.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.
- `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.

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.

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.)

Expand Down
7 changes: 4 additions & 3 deletions content/docs/protocol/objectql/security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -300,9 +300,10 @@ same writes. What moved is the hook's view.
`ctx.submitted`.
- A self-assignment (`data.x = data.x`) on such a field is now a **no-op** — the stored
value stands, and the write is stripped and reported exactly as an un-hooked one is.
- `ctx.submitted` is **not** marshalled onto the sandboxed `body` face. A `body` deriving
from a read-only column reads `ctx.previous`, the stored row, which is the correct
source either way.
- `ctx.submitted` is **not** marshalled onto the sandboxed `body` face, and `objectstack
build` refuses to lower a handler that reads it (the handler is bundled instead). A
`body` deriving from a read-only column reads `ctx.previous`, the stored row, which is
the correct source either way.

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

Expand Down
57 changes: 57 additions & 0 deletions packages/cli/src/lint/hook-body-lowering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,63 @@ describe('checkHookBodyLowering', () => {
expect(checkHookBodyLowering({ hooks: [selfContainedHook] })).toEqual([]);
});

// [#22810] The context members a body face does not carry — `dispatch.scope`
// and `submitted` — are refused by the same `extractHookBody` the build
// calls, so lint reports them on the same rule `.sudo(` / `.create(` land on:
// the structural class, a warning, because the bundle is where the handler
// still works. `--strict-body` is what makes them fatal at build time.
describe('a body reaching a context member the sandbox does not marshal (#22810)', () => {
const scopeHook = {
name: 'stash_total',
object: 'order',
events: ['beforeInsert'],
handler: (ctx: any) => {
ctx.dispatch.scope.total = Number(ctx.input.amount);
},
};
const submittedHook = {
name: 'guard_rename',
object: 'order',
events: ['beforeUpdate'],
handler: (ctx: any) => {
if (ctx.submitted?.name) ctx.input.note = 'renamed';
},
};
const modeHook = {
name: 'batch_once',
object: 'order',
events: ['beforeUpdate'],
handler: (ctx: any) => {
if (ctx.dispatch?.mode === 'per-row' && ctx.dispatch.index === 0) ctx.input.note = 'first';
},
};

it('reports each on the bundled-fallback rule, naming the hook, its path and the member', () => {
const issues = checkHookBodyLowering({ hooks: [scopeHook, submittedHook] });
expect(issues.map((i) => [i.rule, i.severity, i.path])).toEqual([
[BUNDLED_FALLBACK_RULE, 'warning', 'hooks[0].handler'],
[BUNDLED_FALLBACK_RULE, 'warning', 'hooks[1].handler'],
]);
expect(issues[0].message).toContain("hook 'stash_total'");
expect(issues[0].message).toContain('`ctx.dispatch.scope`');
expect(issues[1].message).toContain("hook 'guard_rename'");
expect(issues[1].message).toContain('`ctx.submitted`');
});

it('CONTROL: says nothing about `ctx.dispatch.mode` / `ctx.dispatch.index`', () => {
expect(checkHookBodyLowering({ hooks: [modeHook] })).toEqual([]);
});

it('agrees with what the build records for the same hooks', () => {
const lowering = lowerCallables({ hooks: [{ ...scopeHook }, { ...submittedHook }, { ...modeHook }] });
expect(lowering.bodyExtractionWarnings.map((w) => `${w.origin}|${w.kind}`)).toEqual([
"hook 'stash_total'|forbidden-token",
"hook 'guard_rename'|forbidden-token",
]);
expect(lowering.bodyExtracted).toBe(1);
});
});

it('truncates the multi-line offending-source dump to one line', () => {
const [issue] = checkHookBodyLowering({ hooks: [forbiddenTokenHook] });
expect(issue.message).not.toContain('--- offending body source ---');
Expand Down
129 changes: 123 additions & 6 deletions packages/cli/src/utils/extract-hook-body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,14 @@
* extracted body — full TypeScript AST analysis is deferred to v2. Anything
* the regex rejects (top-level `import`, `require(` / esbuild's `__require(`,
* `fetch(`, `process.*`, `globalThis.*`, `eval`, `new Function`, `.sudo(`,
* `.create(`) makes extraction **throw**.
* `.create(`, `ctx.dispatch.scope`, `ctx.submitted`) makes extraction **throw**.
*
* The last two are one family: a member that is REAL on the host
* `ScopedContext`/`ObjectRepository` and absent from the VM's `ctx.api`, so the
* same handler source passes an in-process test and TypeErrors the moment the
* build lowers it into a body. `.create(` carries one wrinkle `.sudo(` does not
* — see its entry in `FORBIDDEN_PATTERNS`.
* The last four are one family: a member that is REAL on the host context
* (`ScopedContext`/`ObjectRepository` for the first two, the engine's
* `HookContext` for the last two) and absent from what the VM hands a body, so
* the same handler source passes an in-process test and fails the moment the
* build lowers it into a body. `.create(` and `dispatch.scope` each carry a
* wrinkle `.sudo(` does not — see their entries in `FORBIDDEN_PATTERNS`.
*
* ⚠️ What that throw costs the BUILD depends on the flag, and the two outcomes
* are not the same one. This header used to claim only the second (#10678):
Expand Down Expand Up @@ -173,6 +174,52 @@ export class HookBodyExtractionError extends Error {
}
}

/**
* A member read off a ROOT identifier — `ctx` itself, or any local name a
* handler bound (`const c = ctx`, `const d = ctx.dispatch`) — as opposed to a
* member reached through another member (`ctx.input.<field>`).
*
* The `.sudo(` / `.create(` reach (receiver-loose) restated for a member whose
* NAME is not distinctive. `sudo` names nothing but the host method, so any
* receiver may be refused; `scope` and `submitted` are also ordinary field
* names, and a field read through a record image the body DOES receive
* (`ctx.input.scope`, `ctx.previous.submitted`) is working, lowerable code —
* refusing it would be the false refusal `.create(` carves `Object` out for.
* A root receiver is what both an alias and the context itself look like; a
* record image never is. What it still over-refuses — a root-bound ROW with a
* field of that name (`const row = ctx.input; row.submitted`), and the same
* token inside a string literal — is listed at the two entries below.
*/
const ROOT_RECEIVER = String.raw`(?<![\w$]|\.\s*)[A-Za-z_$][\w$]*`;

/** `ctx.dispatch.scope` in any spelling below — gated on the body naming `dispatch` at all. */
const DISPATCH_SCOPE_RX = new RegExp(
String.raw`^(?=[\s\S]*\bdispatch\b)[\s\S]*?(?:` +
[
// `ctx.dispatch.scope`, `ctx?.dispatch?.scope`, a destructured
// `dispatch.scope`, and an alias of the marker (`d.scope`).
String.raw`(?:\bdispatch|${ROOT_RECEIVER})\s*\??\.\s*scope\b`,
// The same receivers, bracket-spelled: `ctx.dispatch['scope']`.
String.raw`(?:\bdispatch|${ROOT_RECEIVER})\s*(?:\?\.)?\s*\[\s*(['"\x60])scope\1\s*\]`,
// Destructured: `const { scope } = ctx.dispatch`, `const { dispatch: { scope } } = ctx`.
String.raw`\{[^{}]*\bscope\b[^{}]*\}\s*=(?![=>])`,
String.raw`\bdispatch\s*:\s*\{[^{}]*\bscope\b`,
].join('|') +
')',
);

/** `ctx.submitted` in any spelling below. */
const SUBMITTED_RX = new RegExp(
[
// `ctx.submitted`, `ctx?.submitted`, and a context alias (`c.submitted`).
String.raw`${ROOT_RECEIVER}\s*\??\.\s*submitted\b`,
// Bracket-spelled: `ctx['submitted']`.
String.raw`${ROOT_RECEIVER}\s*(?:\?\.)?\s*\[\s*(['"\x60])submitted\1\s*\]`,
// Destructured: `const { input, submitted } = ctx`.
String.raw`\{[^{}]*\bsubmitted\b[^{}]*\}\s*=(?![=>])`,
].join('|'),
);

const FORBIDDEN_PATTERNS: Array<{ rx: RegExp; reason: string }> = [
{ rx: /\bimport\s*[\(\*\{]/, reason: 'dynamic `import()` and ES imports are not allowed in hook/action bodies — declare a Connector recipe instead' },
// Both spellings, one reason (#10678). A TypeScript config is loaded through
Expand Down Expand Up @@ -262,6 +309,76 @@ const FORBIDDEN_PATTERNS: Array<{ rx: RegExp; reason: string }> = [
+ 'Alternatively leave this handler bundled so it runs in-process, where the host repository\'s '
+ '`create()` alias exists',
},
// [#22810] Same family as `.sudo(` / `.create(`, one surface over: not a
// member of `ctx.api` but of the context itself. The omission is the DECLARED
// contract — `HookContextSchema.dispatch` (packages/spec/src/data/hook.zod.ts)
// says the body face carries `{ mode, index }` "and not `scope`", and
// `buildSandboxContext` (runtime/src/sandbox/body-runner.ts) copies exactly
// those two: `scope`'s contract is shared object identity across every
// dispatch of one write, which a JSON copy into the VM heap cannot keep. So a
// body writing `ctx.dispatch.scope.<key>` is `TypeError: cannot set property
// … of undefined` on its first run — measured through `bootStack`'s artifact
// door as a `SandboxError` with no code, and `500 INTERNAL_ERROR` over REST,
// where the in-process handler stores the row.
//
// ⛔ Marshalling `scope` instead is a second published body contract, not a
// fix (the spec text: "a body needing it is the signal to raise that
// question, not to add it silently").
//
// Reach, and the one wrinkle: `scope` is an ordinary field name (platform
// objects declare it), so a bare receiver-loose `.scope` would refuse
// `ctx.input.scope` — working code. Two narrowings keep the alias reach
// without that: the body must name `dispatch` somewhere, and the member must
// hang off `dispatch` or a ROOT identifier (see `ROOT_RECEIVER`). Caught:
// `ctx.dispatch.scope`, `?.`, brackets, `const d = ctx.dispatch; d.scope`,
// `const { dispatch } = ctx; dispatch.scope`, `const { scope } =
// ctx.dispatch`, `const { dispatch: { scope } } = ctx`. Over-refused (the
// safe direction — the handler is bundled and runs): a dispatch-naming body
// that also reads `.scope` off a root-bound row, or spells `x.scope` inside a
// string. Knowingly NOT caught: a computed key (`ctx.dispatch[k]`),
// `Reflect.get(ctx.dispatch, 'scope')`, a parenthesised or indexed receiver
// (`(ctx.dispatch).scope`), and a handler that destructures `dispatch` in its
// PARAMETER list — the lowering drops the parameter list entirely, so that
// handler fails once lowered whatever it touches.
{
rx: DISPATCH_SCOPE_RX,
reason:
'`ctx.dispatch.scope` is not reachable from a sandboxed body — the VM\'s `ctx.dispatch` carries '
+ 'only `mode` and `index` (`scope` is shared object identity across every dispatch of one write, '
+ 'which a copy into the sandbox cannot keep), so writing through it is a TypeError at run time '
+ '(and under a hook\'s default `onError: \'abort\'` that aborts the triggering write) and a '
+ 'guarded read sees `undefined`. Do the batch-scoped work once, at `ctx.dispatch.index === 0`, '
+ 'or keep cross-dispatch state in the record itself through `ctx.api`; `ctx.dispatch.mode` and '
+ '`ctx.dispatch.index` are unaffected. Alternatively leave this handler bundled so it runs '
+ 'in-process, where the engine\'s shared `scope` exists',
},
// [#22810] The sibling: `HookContextSchema.submitted` is "NOT marshalled into
// the sandboxed `body` face, for the same reason `dispatch.scope` is not",
// and `buildSandboxContext` assembles the face key by key without it. A
// lowered body reads `undefined` — measured: `typeof ctx.submitted` is
// `'object'` in-process on an update and `'undefined'` from the artifact —
// so a guard written on it evaluates as if nothing were submitted, with no
// error at all. The silent half of this family, which is why it is refused
// rather than left to fail at run time.
//
// Reach: `ROOT_RECEIVER`, ungated — no `dispatch`-style anchor exists for a
// top-level context member. `ctx.input.submitted` / `ctx.previous.submitted`
// (a field of that name, read through a record image) are NOT refused.
// Over-refused: `.submitted` off a root-bound row (`row.submitted`), a
// destructured field of that name (`const { submitted } = ctx.input`), and
// `x.submitted` inside a string. Knowingly NOT caught: computed keys,
// `Reflect.get`, a parenthesised receiver, and parameter-list destructuring
// (the same parameter-list gap as above).
{
rx: SUBMITTED_RX,
reason:
'`ctx.submitted` is not reachable from a sandboxed body — the VM\'s `ctx` is assembled key by key '
+ 'and does not carry the caller\'s submission, so it reads `undefined` there: a guard written on it '
+ 'evaluates as if nothing were submitted, and reaching into it is a TypeError at run time. To '
+ 'derive a value from a read-only field, read the stored row on `ctx.previous`, which is the '
+ 'correct source either way. Alternatively leave this handler bundled so it runs in-process, '
+ 'where `ctx.submitted` carries the caller\'s submission on update',
},
];

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