Skip to content

Commit bd70706

Browse files
fix(runtime)!: an app-authored body may not bind a hook to, or write, the stored-metadata tables (#21520) (#21563)
Fixes #21520 Clause-②: yes (narrowing) Executes ruling A (record `5965059068`, maintainer 「同意」): **an app-authored body may not touch the stored-metadata family's tables** (`sys_metadata`, `sys_metadata_history`). For an app-authored body the metadata protocol is their only writer. Two refusals, each carrying `PERMISSION_DENIED` / 403 and a prescription that names the metadata API: 1. **Binding.** A hook with a sandboxed `body` whose `object` names a family table is refused at registration. 2. **Writing.** A sandboxed body's write of a family table through `ctx.api` is refused before the write runs. This also closes the write verb's own predicate path, which triage `5965718076` carried onto this card: a refused write runs nothing, and its answer does not depend on what it names. Platform code is outside the refusals: the metadata protocol and its writers, the platform's code hooks, and host code a deployer registers. ## Census of platform writers (A1), by symbol walk **Method.** A TypeScript AST walk (the compiler API) over every non-test source file under `packages/*/src`: 2707 files at `fd5a1cd597`. - A call counts as a family write when its callee is a write verb (`insert`, `create`, `update`, `updateById`, `upsert`, `delete`, `deleteById`, `updateMany`, `deleteMany`, and the bulk spellings) and its object argument resolves to a family name. - An object argument resolves when it is a literal, a same-file binding, or an exported constant (`OVERLAY_TABLE`, `METADATA_HISTORY_OBJECT`). The receiver `X.object(name)` counts the same way. - Write calls with a non-literal object argument, in files that name the family, are listed separately so a dynamic writer is not hidden. **Readings.** - 13 resolved family writes: `metadata-protocol` (`sys-metadata-repository.ts` ×5, `protocol.ts` ×2, `migrations/recorded-by-sentinel.ts` ×1), `service-datasource` (`datasource-admin-plugin.ts` ×4) and `plugin-security` (`permission-set-overlay-discard.ts` ×1). Every one is platform module code calling the engine directly (`this.engine` / `engine` / `ql`). - 112 unresolved write calls across 18 family-naming files. All are module code on an engine or driver receiver. - `buildSandboxApi` is the only constructor of a body's API. It is reached only from `buildSandboxContext` (hook bodies) and `buildActionSandboxContext` (action bodies). No platform writer goes through it. - Zero shipped hook or action bodies name a family table in `packages/**` or `examples/**` (non-test). The only `object: 'sys_metadata'` hits are the platform's own list views in `metadata-core`, matching the ruling's census. **A1's assumption measured false: the existing seam is not body-only.** `serveStoredMetadataReadsThrough` is applied at `buildSandboxApi` (bodies). It is also applied at `buildActionApi`, which is the `ctx.api` of a host code action handler as well as of an action body. So a throw in `serveRepository`'s shared write branch would also refuse deployer host code. The ruling says the seam refuses bodies only, and triage `5964836549` put deployer host code outside the family. So the write refusal is a **separate, body-only layer in the same seam file**: - `refuseStoredMetadataBodyWrites` layers over the read seam. - It shares one derived-context walk (`deriveThroughSeam`) with the read seam, so there is no second walk. - It is applied only at `buildSandboxApi`. `serveRepository`'s write branch keeps serving write returns for host handlers. Its comment now says why the refusal is not attached there. ## The binding point (A2): one place every door shares Every door a body hook binds through reaches `hookBodyRunnerFactory`'s per-hook resolver. That resolver is where a `body` becomes a handler, at registration: - the boot artifact and an installed artifact, install and rehydrate, through `bindAppArtifactHandlers` (its explicit runner); - runtime-authored hooks, through ObjectQLPlugin's metadata-service bind (boot sync and resync), which use the engine's default body runner installed by `AppPlugin`. That runner is the same factory. `bindAppArtifactHandlers` alone would have missed the runtime-authored door. The refusal is a throw from the resolver, so the binder records it against the hook and logs it at `error`, or rethrows under `strict`. The hook is never registered. **Wildcard.** A `'*'` body hook names no family table, so it binds, but it admits the family's tables. Its body is therefore not run for a family table's event: a dispatch-side check in the bound handler. The bind says so once, at `info`. **Platform hooks** are code handlers, never bodies, so they never reach this factory. Pinned: a code hook on `sys_metadata` still binds and fires, and the metadata door's save still fires a platform code hook. ## Codes (A3): an existing code fits, no new ledger row Both refusals carry **`PERMISSION_DENIED` / 403**, a member of the ledger's `ErrorCode` union (the standard catalog). - The condition is generic: this author context is not permitted this operation on this table. - The ledger's admission rule (#8211, mechanical) sends a generic permission condition to the standard member rather than to a registered synonym. - Precedent: the flow-authoring write gate refuses an authoring write by authority with the same code. - What the author does instead is carried in the prescription. So this is **not** `PENDING LEDGER CODE`, and nothing under `packages/spec` is edited. ## Reach first (A5), measured as classes before the fix The pins below were run against the pre-fix `body-runner.ts` (BASE `fd5a1cd597`, byte-restored to HEAD afterwards, `git diff HEAD` empty). Every observation is a neutral marker token on a free-text column. No stored content is read. - **Binding (unit tier, real ObjectQL + QuickJS):** a body hook targeting each family table bound and ran on that table's write event, through all three doors (artifact binder, runtime-authored default runner, wildcard). Result: 6 red, 2 controls green. - **Composed kernel:** - The metadata door's own save ran an explicit family body hook, the wildcard body hook and a runtime-authored family body hook (all three markers present on the saved row). - An elevated action body's insert into `sys_metadata` answered `200` for the administrator and for a member. - Result: 4 red, 3 controls green. ## Pins - `stored-metadata-body-boundary.test.ts`, 8 cases, binding, on a real engine: - refused at registration for each family table, string and list forms, with code, status and the metadata-API prescription; - refused and recorded through the artifact binder and through the runtime-authored default runner; - thrown under strict binding; - a wildcard binds and never runs on a family table; - controls: an ordinary hook binds, and a code hook on a family table binds. - `stored-metadata-body-writes.test.ts`, 10 cases, writing, on a counting double: - every write verb on each family table is refused before it reaches the store; - a predicate write answers identically whatever its predicate names, and runs nothing; - reads pass to the read seam; - controls: ordinary tables write; - `sudo`, `withRunAs`, `transaction` and `beginTransaction` refuse the same way; - the layer is idempotent and transparent to the read seam's marker; - the read seam alone (a host handler's `ctx.api`) keeps its writes; - through the real QuickJS sandbox, an action body and a hook body on an ordinary table are both refused. - `stored-metadata-body-boundary.pin.test.ts`, 7 cases, composed kernel, boot paid in `beforeAll`: - the metadata door's save runs no body bound to a family table, and still fires the platform code hook; - controls: ordinary-table hooks fire, the wildcard among them; - a runtime-authored family hook is not bound, while a runtime-authored ordinary hook binds; - an action body's family writes (an insert, a predicate update, a history insert) answer `403 PERMISSION_DENIED` and land nothing, for administrator and member; - control: the same body's ordinary write lands. ## Reverse verification (ablation), fix committed first, at `0d8af06c80` Each leg ran through `scripts/ablation-replace.mjs`: the anchor hit 1 → 0 on disk, the blob changed, and the restore was proven (blob == HEAD, `git diff HEAD` empty). The pins resolve `body-runner.ts` by relative path within the package (src), so no dist leg applies. - **A1**, registration throw disabled: 5 red (the 5 refusal and record pins), 20 green. The composed "no body ran" pin stayed green because the dispatch-side check still stops the body: defence in depth, observed as expected. - **A2**, dispatch-side check disabled: 2 red (the wildcard unit pin, and the composed save pin with the wildcard marker present). - **B**, the body write layer removed from `buildSandboxApi`: 4 red (both sandbox unit pins, and both composed write pins). ## Tests and gates, at `9a95e459d1` (after merging `origin/main` `ce532184d1`, which carries #21539's landed seam) - `@objectstack/runtime`, `--project local`: Test Files 316 passed, Tests 4444 passed, 19 skipped. `--project repo`: 3 files, 751 passed. - `pnpm --filter @objectstack/runtime typecheck`: exit 0. `check:test-typecheck` is OK with the ledger unchanged, and all three new test files are in the `tsconfig.test.json` program (`--listFilesOnly`). - `dispatch-gates --commands --repo objectstack-ai/objectstack` with no paths derived 62 families. All 62 were run, each exit 0, and `--ran` reconciled 62 derived, 62 run, 0 not-measured. `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET; after a full `turbo run build` it measured 106 entries across 66 packages. - **Lint, a proven narrowing** (`pnpm lint` itself is CI's run): - population, from eslint's own config: 6 of the 7 changed paths are linted (the changeset `.md` has no matching configuration); - count, from `--format json`: 6 files, 0 errors, 0 warnings; - invariance: `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), and its only import rule is per-file (`no-restricted-imports`), so this diff cannot move the verdict on an untouched file. - `check:nul-bytes`: OK. Control-byte self-scan of the 7 changed files: grep exit 1 (none). ## Acceptance notes - **The metadata door accepts the hook that the bind then refuses.** Saving a runtime-authored hook whose `object` is a family table answers `200` at the metadata door (recorded by the composed pin). The bind then refuses it and records the refusal at `error`, but nothing refuses it at save. This is reported to the seat as an authoring-trap finding; it is not fixed here (the save door and the spec are outside this card's surface). - An expected write refusal from an action body is logged by the existing body-runner catch at `error` (`[BodyRunner] sandboxed action threw`). That is the runner's existing posture for any body that throws, and it is unchanged here. - Flow record nodes and host code are outside this ruling (bodies only). Action bodies declared on a family object are covered by the write layer, like any body. - An earlier head of this branch merged #21539's head while it was open. #21539 has since landed, and `origin/main` was merged on top. The seam file's #21539 bytes are identical to the landed squash, so this PR's diff against `main` is exactly the 7 files listed by the gate derivation. ## Changeset `@objectstack/runtime` **minor** (the launch-window convention for accept-set narrowings), with `Clause-②: yes (narrowing)` and the ADR-0087 disposition `not-required (no-migration-prescription)`. No stored metadata shape, authorable key or export moves. --- _Generated by [Claude Code](https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1ca1eb0 commit bd70706

7 files changed

Lines changed: 981 additions & 18 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/runtime': minor
3+
---
4+
5+
fix(runtime)!: an app-authored body may not bind a hook to, or write, the stored-metadata tables (#21520)
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) no metadata body, authorable key, spelling, export or stored shape moves; what changes is which tables a sandboxed hook or action body may be bound to and may write, so `objectstack migrate meta` has nothing to rewrite. The other categories are closed on facts: the package publishes (not `unpublished`); no ADR-0087 id covers a refused binding or a refused write (not `registered` / `already-registered`); and the change is runtime behaviour, not a declaration (not `runtime-interface-only` / `type-surface-only`). -->
10+
11+
**BREAKING**: this narrows what an app-authored body may do with the two stored-metadata tables, `sys_metadata` and `sys_metadata_history`. For an app-authored body, the metadata protocol is now their only writer: a change to metadata goes through the metadata API, where it is validated and its provenance is recorded.
12+
13+
- **Binding.** A hook with a sandboxed `body` whose `object` names either table, alone or in a list, is no longer bound. The refusal is made at registration, at the one point every body hook becomes a handler, so it holds on every door a hook binds by: a code bundle or boot artifact, an installed artifact, and a hook authored at runtime through the metadata door. It carries `PERMISSION_DENIED` / 403, names the metadata API, and is recorded against the hook in the bind log at `error` (thrown under strict binding). A wildcard (`'*'`) body hook still binds; its body is not run for either table's events, and the bind says so once at `info`.
14+
- **Writing.** A sandboxed action or hook body's write of either table through `ctx.api` — every write verb, inside a transaction or not, with or without elevation — answers `PERMISSION_DENIED` / 403 before the write runs, so nothing lands and the answer does not depend on what the write names.
15+
- **Unchanged:** a body's reads of the two tables (still served as the generic data door serves them); host code that registers its own action handlers or hooks; the platform's own hooks, which are code and still fire on the metadata door's save; and every other object.
16+
17+
The route: change metadata through the metadata API (`PUT /api/v1/meta/:type/:name`) rather than from a body, and bind hooks to the objects an app owns. No shipped example binds a body hook to either table or writes one from a body. It ships as `minor` under the launch-window convention for accept-set narrowings.

‎packages/runtime/src/sandbox/body-runner.ts‎

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,13 @@ import {
5757
resolveRecordTitle,
5858
resolveRelatedTitleTarget,
5959
} from '@objectstack/objectql';
60-
import { serveStoredMetadataReadsThrough } from '../stored-metadata-reader-seam.js';
60+
import { refuseStoredMetadataBodyWrites, serveStoredMetadataReadsThrough } from '../stored-metadata-reader-seam.js';
61+
import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel';
62+
import {
63+
isWildcardHookTarget,
64+
storedMetadataBodyHookBindingRefusal,
65+
storedMetadataFamilyTableList,
66+
} from '../stored-metadata-body-boundary.js';
6167

6268
interface FactoryOptions {
6369
ql: any;
@@ -290,6 +296,20 @@ export function hookBodyRunnerFactory(
290296
const raw = (hook as any).body;
291297
if (!raw) return undefined;
292298

299+
// [#21520] An app-authored hook BODY may not be bound to a table of the
300+
// stored-metadata family: the metadata protocol is the family's only writer
301+
// for a body. This is the ONE point every body hook passes through to become
302+
// a handler, whichever door bound it — the boot artifact and an installed
303+
// artifact (`bindAppArtifactHandlers`), and runtime-authored hooks
304+
// (ObjectQLPlugin's metadata-service bind, through the engine's default
305+
// runner) — so the refusal is made here, at registration, and never per
306+
// door. Thrown rather than answered `undefined`: the binder records the
307+
// throw against the hook and logs it at `error` (rethrows under `strict`),
308+
// whereas `undefined` would be reported as a missing runner. Platform hooks
309+
// are code, not bodies, and never reach this factory.
310+
const bindingRefusal = storedMetadataBodyHookBindingRefusal(hook as any);
311+
if (bindingRefusal) throw bindingRefusal;
312+
293313
const parsed = HookBodySchema.safeParse(raw);
294314
if (!parsed.success) {
295315
opts.logger?.warn?.('[BodyRunner] invalid hook.body shape', {
@@ -301,7 +321,24 @@ export function hookBodyRunnerFactory(
301321
}
302322
const body = parsed.data;
303323

324+
// [#21520] A wildcard target names no family table, so it binds — but it
325+
// admits every object, the family's among them, and the boundary is that a
326+
// body never touches those tables. So the body is not run for a family
327+
// table's event (below), and the author is told once, at bind.
328+
if (isWildcardHookTarget((hook as any).object)) {
329+
opts.logger?.info?.(
330+
`[BodyRunner] hook '${hook.name}' targets every object ('*'); its body is never run for the stored-metadata `
331+
+ `tables (${storedMetadataFamilyTableList()}). Change metadata through the metadata API.`,
332+
{ appId: opts.appId, hook: hook.name },
333+
);
334+
}
335+
304336
return async function boundBodyHandler(engineCtx: any): Promise<void> {
337+
// [#21520] The dispatch-side half of the binding refusal above: whatever
338+
// admitted this event (a wildcard, a global registration), a body does not
339+
// run on a stored-metadata table's event, so it never receives that row
340+
// as its input or writes it back.
341+
if (typeof engineCtx?.object === 'string' && isStoredMetadataBodyObject(engineCtx.object)) return;
305342
const sandboxCtx = buildSandboxContext(
306343
engineCtx,
307344
opts.ql,
@@ -795,9 +832,17 @@ function buildEngineRepoFacade(ql: any, objectName: string, context?: any) {
795832
* place both body faces get their API, so the hook face, the action face and
796833
* every fallback below are served alike, and a body can copy only what it was
797834
* served.
835+
*
836+
* [#21520] And, layered over that, a body may not WRITE a stored-metadata table
837+
* at all: every write of one is refused before it runs, whatever the body's
838+
* elevation. Applied HERE and nowhere else because this is the one place a
839+
* body gets its API — a host code handler's `ctx.api` is served by the read
840+
* seam but keeps its writes (deployer code, outside the boundary).
798841
*/
799842
function buildSandboxApi(engineCtx: any, ql: any, errLabel: string) {
800-
return serveStoredMetadataReadsThrough(buildSandboxApiSource(engineCtx, ql, errLabel), ql);
843+
return refuseStoredMetadataBodyWrites(
844+
serveStoredMetadataReadsThrough(buildSandboxApiSource(engineCtx, ql, errLabel), ql),
845+
);
801846
}
802847

803848
function buildSandboxApiSource(engineCtx: any, ql: any, errLabel: string) {

0 commit comments

Comments
 (0)