You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 1ee1d1b
Browse filesBrowse the repository at this point in the historyBrowse files
feat(runtime,cli): a hook body reads ctx.provenance, so a flow-vs-user guard ships as metadata (#25952)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} -->
_Requested by **Elon** · [project
thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DJpRLxH24N6TCGQQWmVvX3L)_Fixes#25905
Clause-②: yes (widening)
Metadata-protocol: none
## What changed
A sandboxed hook body now reads the declared `HookContext.provenance`,
the same server-stamped write provenance an in-process handler reads. A
guard that tells a flow data node's write from a user's own write
(hotcrm#2285's account-approval guard) can ship as a metadata-only body
instead of being bundled.
- **Runtime, `buildSandboxContext`**
(`packages/runtime/src/sandbox/body-runner.ts`, hook face only):
projects `provenance` member by member: `flowRunId`, `attributedUserId`,
`performedByClientId`. Each member is copied only as a string. The whole
key is absent when none survives, which is what the engine face carries
on a plain REST write. The source is objectql's `buildProvenance()`
output on the hook context, never the payload. The action face
(`buildActionSandboxContext`) is untouched.
- **Runtime, `installCtx`** (`quickjs-runner.ts`): grafts
`ctx.provenance` frozen and non-writable, the same way `ctx.dispatch` is
grafted, so a body cannot drift its own copy. It is installed only when
the host side carried one.
- **Runtime, `ScriptContext`** (`script-runner.ts`): the `provenance`
member is typed and documented.
- **CLI, `extract-hook-body`**: the `ctx.provenance` forbidden pattern
is removed. A handler reading it lowers, and `os lint` no longer reports
`hook-body/bundled-fallback` for it.
- **Docs**: `content/docs/automation/hook-bodies.mdx` gets a
sandbox-surface row, a "Write provenance" section (members, absence
semantics, a guard example, the two guarantees), and drops provenance
from the forbidden list.
- **Changesets**: two patch entries. `@objectstack/runtime` declares
`Clause-②: yes (widening)`: `ScriptContext` (a barrel export) gains the
optional `provenance?` member; `dist/index.d.ts` goes 338119 → 339467
bytes, its export list is byte-identical (184 names), and that member is
the only declaration that changed. `@objectstack/cli` declares
`Clause-②: no`: its three `dist/*.d.ts` are byte-identical.
- **No `packages/spec` change.** The key was already declared on
`HookContextSchema`. This PR makes the sandbox honour it.
## Tests
- New
`packages/runtime/src/sandbox/hook-provenance-signal.integration.test.ts`
runs on real ObjectQL, real SqlDriver (better-sqlite3), real QuickJS and
`hookBodyRunnerFactory`. Under the same caller identity it checks three
things:
- the flow-shaped write (execution context carries `flowRunId`) reads
`ctx.provenance.flowRunId === 'run_25905'`, with keys `['flowRunId']`,
after the body tried to drift the member and replace the key;
- the REST write reads `typeof ctx.provenance === 'undefined'`;
- a REST write whose payload carries a field `provenance` holding a
forged JSON value shows it on `ctx.input.provenance` only, while
`ctx.provenance` stays absent.
- `packages/cli/test/lowered-body-face-enumeration.test.ts`:
`provenance` leaves the absent set (it now reads 4 refusals and 1
exception). Both faces are pinned to the three declared members.
- `packages/cli/test/hook-body-parameter-rebind.test.ts`: the three
`ctx.provenance` spellings move from "refused" to "lowers".
- `packages/cli/src/lint/hook-body-lowering.test.ts`: a guard on
`ctx.provenance?.flowRunId` gets no lint issue, and the build lowers it
(`bodyExtracted: 1`, no warnings).
### Ablations (two, both via `scripts/ablation-replace.mjs`, restore
proven by blob == HEAD and an empty `git diff HEAD`)
1. **Negative (forgery) pin.** `buildSandboxContext` was mutated to fall
back to `JSON.parse(engineCtx.input.provenance)`, i.e. a consumer
reading the payload. The pin went red at
`hook-provenance-signal.integration.test.ts:165` (`expected 'object' to
be 'undefined'`). Anchor 1 to 0, blob `58df4905a51e` to `81882a7eb7e0`,
restored to `58df4905a51e`.
2. **Freeze.** The graft's `Object.freeze(p)` / `writable: false` was
mutated to a plain writable value. The test went red at `:139`
(`expected 'replaced' to be 'run_25905'`). Blob `04f49a96f07d` to
`7c20c0ada09a`, restored.
The test imports `./body-runner.js` and `./quickjs-runner.js` from
`src`, so no dist rebuild was involved.
## Local verification (head `df1c7164`; `256f0583` adds changesets only)
- `pnpm --filter @objectstack/runtime exec vitest run`: Test Files 449
passed (449), Tests 8015 passed, 19 skipped.
- `pnpm --filter @objectstack/cli exec vitest run --project unit`: 330
of 332 files passed on the first run. The two failures were
`published-subpath-console.pin` and `published-subpath-hook-body.pin`,
which threw "packages/cli is not built". After `pnpm --filter
@objectstack/cli build` both passed (29 tests). The integration tier is
declared to CI.
- `pnpm --filter @objectstack/runtime --filter @objectstack/cli
typecheck`: exit 0.
- `pnpm lint` (repo-wide eslint): exit 0.
- Derived gates (`dispatch-gates --commands`, 98 families): 95 exit 0.
`check:i18n`, `check:i18n-coverage` and `check:i18n-walk-parity` first
exited 3 (CLI dist absent) and exit 0 after the CLI build. `--ran`
reconciliation: 98 derived, 98 run.
- NOT MEASURED (exit 3, prerequisite only, none reads a changed file):
- `check:dual-build-cjs-loads`: unbuilt plugin dists;
- `@objectstack/spec check:skill-examples`: client-react dist absent;
- `check-plugin-teardown-shape --self-test`: shallow clone, fixture
commit unreachable.
CI runs all three.
## Acceptance notes
- A body may still define `ctx.provenance` itself on a write that
carries none (the key is absent there, as with `ctx.dispatch`). That
only affects the body's own VM. Nothing is written back from it, and it
cannot reach another hook or the engine.
- `attributedUserId` and `performedByClientId` are exposed alongside
`flowRunId`. All three are declared, server-stamped attribution marks.
`session.userId` is already on the body face.
- #25653 (seat 1) edits the action face of `body-runner.ts`. This PR
touches only the hook face's `buildSandboxContext` plus a new helper
below it. Whichever lands second merges `main`.
---
_Generated by [Claude
Code](https://claude.ai/code/session_0144xurUaQ5v2UtFYLpYp28J)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
feat(cli): `objectstack build` lowers a hook handler that reads `ctx.provenance` instead of bundling it
6
+
7
+
Clause-②: no
8
+
Metadata-protocol: none
9
+
10
+
The sandboxed hook body now carries `ctx.provenance` (see the `@objectstack/runtime` entry), so the lowerer no longer refuses a handler that reads it. Such a handler ships as a metadata-only body, and `os lint` no longer reports `hook-body/bundled-fallback` for that read. The CLI's published type surface does not change: `dist/index.d.ts`, `dist/hook-body.d.ts` and `dist/console.d.ts` are byte-identical before and after. Nothing to migrate: a handler that read `ctx.provenance` was bundled before and runs in-process, where it behaves the same. Rebuilding turns it into a body.
feat(runtime): a hook body reads `ctx.provenance`, so a guard that tells a flow's write from a user's own write ships as metadata
6
+
7
+
Clause-②: yes (widening)
8
+
Metadata-protocol: none
9
+
10
+
The sandboxed hook body now carries the declared `HookContext.provenance`, the same server-stamped write provenance an in-process handler reads. On a write from a flow data node, `ctx.provenance.flowRunId` is that flow run's id. `attributedUserId` and `performedByClientId` ride along when the engine stamps them. Each member is a string or absent, and `ctx.provenance` itself is absent on a write with no origin, such as a plain REST write. Read it as `ctx.provenance?.flowRunId`. The object is frozen in the VM. It is built from the server-side execution context, so a payload field named `provenance` stays on `ctx.input` and never reaches it.
11
+
12
+
The widening is on the published type surface. `ScriptContext`, exported from the `@objectstack/runtime` barrel, gains one optional member: `provenance?: { flowRunId?: string; attributedUserId?: string; performedByClientId?: string }`. Measured on `dist/index.d.ts` before and after the change:
13
+
- the file grows from 338119 to 339467 bytes;
14
+
- the export list is byte-identical, with the same 184 names;
15
+
- the only declaration that changes is that one member and its doc comment.
16
+
17
+
Nothing to migrate: the member is optional, and a `ScriptRunner` that ignores it behaves exactly as before.
|`ctx.provenance`| Where the write came from (hook bodies only) — see [Write provenance](#write-provenance--ctxprovenance). Absent on a write with no origin. | none |
101
102
|`ctx.api.object(name).find\|count\|aggregate`| Cross-object reads, scoped to current tenant. |`api.read`|
|`ctx.title()`| This record's title — the object's `nameField`, **including when it is a `formula`** (evaluated server-side against the record already in hand: no extra read over the record's own columns, one read per lookup a title such as `record.book.title + ' · ' + record.reader.name` reads through). | none |
@@ -106,6 +107,34 @@ The script sees only what the surrounding `ctx` object exposes:
|`ctx.connector(name).<method>(...)`_(planned)_| Outbound HTTP / SaaS calls. **Not yet wired into the sandbox** — ships with the separate Connector spec. | (separate Connector spec) |
108
109
110
+
### Write provenance — `ctx.provenance`
111
+
112
+
A hook body sees the same server-stamped write provenance an in-process handler does — the declared [`HookContext.provenance`](/docs/references/data/hook), copied into the sandbox member by member:
113
+
114
+
| Member | Present when |
115
+
|---|---|
116
+
|`ctx.provenance.flowRunId`| The write comes from an automation flow's data node; the value is that flow run's id. |
117
+
|`ctx.provenance.attributedUserId`| The write is authorized as the system and credited to a real person (for example, the admin behind an identity-adapter call). |
118
+
|`ctx.provenance.performedByClientId`| An AI client acting through the MCP OAuth door performed the write on behalf of the user in `ctx.session.userId`. |
119
+
120
+
Each member is a string or absent, and `ctx.provenance` itself is **absent** when none applies — a plain REST write carries no provenance — so read it as `ctx.provenance?.flowRunId`. That is the guard that tells a flow's write apart from a user writing the same fields by hand, which matters most for a flow that runs `runAs: 'user'`: there `ctx.user`, `ctx.session` and `ctx.input` are identical for both writes.
121
+
122
+
```ts
123
+
// Only lead conversion may set `converted_from_lead`.
124
+
handler: (ctx) => {
125
+
if (ctx.input.converted_from_lead&&!ctx.provenance?.flowRunId) {
126
+
thrownewError('converted_from_lead is set by lead conversion only');
127
+
}
128
+
},
129
+
```
130
+
131
+
Two guarantees hold, so the mark is safe to build a guard on:
132
+
133
+
-**It cannot be forged through the data API.** The engine builds it from the server-side execution context, which a transport assembles from the authenticated session and never from request input (the automation engine sets `flowRunId` on its own data nodes); a payload field that happens to be named `provenance` stays a field on `ctx.input`.
134
+
-**It is read-only in the sandbox.** The object is frozen and the key is not writable, so a body cannot change its own copy.
135
+
136
+
It is a signal for app code, not an authorization input: no security middleware reads it, and each write is still authorized as its caller. Action bodies do not carry it.
137
+
109
138
### Naming a record — `ctx.title()`
110
139
111
140
A body composing a message needs the record's **name**, and until this accessor
@@ -176,7 +205,7 @@ The CLI builder **rejects** any source that uses:
176
205
- references to identifiers from value-only top-level imports
177
206
-`.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.
178
207
-`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.
179
-
-`ctx.provenance`, `ctx.ql` and `ctx.transaction` (hook handlers) — the rest of the in-process `HookContext` the body face does not carry. A body reads `ctx.provenance` as `undefined`, so a guard on `ctx.provenance.flowRunId` evaluates as if the write had no origin;`ctx.ql` (the engine) and `ctx.transaction` (a driver handle) are live host objects no copy into the sandbox can carry. Reach other objects through `ctx.api.object(name)`, and group writes with `ctx.api.transaction(fn)`, which is **not** affected. The enumeration behind this list is derived, not remembered: a test compares every member `HookContextSchema` declares against every member the sandbox marshals, and fails on one that is neither refused here nor recorded as a deliberate exception.
208
+
-`ctx.ql` and `ctx.transaction` (hook handlers) — the rest of the in-process `HookContext` the body face does not carry. `ctx.ql` (the engine) and `ctx.transaction` (a driver handle) are live host objects no copy into the sandbox can carry. Reach other objects through `ctx.api.object(name)`, and group writes with `ctx.api.transaction(fn)`, which is **not** affected. The enumeration behind this list is derived, not remembered: a test compares every member `HookContextSchema` declares against every member the sandbox marshals, and fails on one that is neither refused here nor recorded as a deliberate exception.
180
209
181
210
Like `.sudo(` and `.create(`, these 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.
0 commit comments