|
15 | 15 | * extracted body — full TypeScript AST analysis is deferred to v2. Anything |
16 | 16 | * the regex rejects (top-level `import`, `require(` / esbuild's `__require(`, |
17 | 17 | * `fetch(`, `process.*`, `globalThis.*`, `eval`, `new Function`, `.sudo(`, |
18 | | - * `.create(`) makes extraction **throw**. |
| 18 | + * `.create(`, `ctx.dispatch.scope`, `ctx.submitted`) makes extraction **throw**. |
19 | 19 | * |
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`. |
25 | 26 | * |
26 | 27 | * ⚠️ What that throw costs the BUILD depends on the flag, and the two outcomes |
27 | 28 | * are not the same one. This header used to claim only the second (#10678): |
@@ -173,6 +174,52 @@ export class HookBodyExtractionError extends Error { |
173 | 174 | } |
174 | 175 | } |
175 | 176 |
|
| 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 | + |
176 | 223 | const FORBIDDEN_PATTERNS: Array<{ rx: RegExp; reason: string }> = [ |
177 | 224 | { rx: /\bimport\s*[\(\*\{]/, reason: 'dynamic `import()` and ES imports are not allowed in hook/action bodies — declare a Connector recipe instead' }, |
178 | 225 | // Both spellings, one reason (#10678). A TypeScript config is loaded through |
@@ -262,6 +309,76 @@ const FORBIDDEN_PATTERNS: Array<{ rx: RegExp; reason: string }> = [ |
262 | 309 | + 'Alternatively leave this handler bundled so it runs in-process, where the host repository\'s ' |
263 | 310 | + '`create()` alias exists', |
264 | 311 | }, |
| 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 | + }, |
265 | 382 | ]; |
266 | 383 |
|
267 | 384 | const CAPABILITY_PATTERNS: Array<{ rx: RegExp; cap: 'api.read' | 'api.write' | 'crypto.uuid' | 'log' }> = [ |
|
0 commit comments