diff --git a/.changeset/hook-body-crypto-hash-retired.md b/.changeset/hook-body-crypto-hash-retired.md new file mode 100644 index 0000000000..a56383e66f --- /dev/null +++ b/.changeset/hook-body-crypto-hash-retired.md @@ -0,0 +1,43 @@ +--- +"@objectstack/spec": major +"@objectstack/cli": major +"@objectstack/runtime": major +--- + +refactor(spec,cli,runtime)!: 退役 `crypto.hash` 能力 —— 声明了四层、构建期还自动推断,沙箱从没实现(#4391,ADR-0049 enforce-or-remove) + +`crypto.hash` 是四层声明、零层实现:`HookBodyCapability` 枚举收它、枚举旁的文档表列它、CLI 提取器**自动推断**它、`ScriptContext.crypto.hash` 还写了签名 —— 而 `installCtx` 只往 VM 的 `ctx.crypto` 上装了 `randomUUID`。于是这个 token 唯一授权的那次调用,**每一次都在 VM 里抛**。 + +这比普通的 declared ≠ enforced 更毒一档,坏就坏在**构建期推断**:作者(尤其是 AI 作者)写下 `ctx.crypto.hash(...)`,提取器就替他把能力加进 `capabilities`,`os build` 因此全绿 —— 系统亲手把人送进一条必炸的死路,而唯一诚实的记录是文档表格里一句 `_(not yet wired)_`,没有作者会先读表格再写 body。 + +**裁决是 remove,不是实现**(维护者 2026-08-02):从未实现、调用即抛、**零投诉** —— 对一个每次使用都抛错的能力来说,这本身就是最强的活性证据,没人需要它。在沙箱里实现 crypto 会扩大沙箱的能力面与安全审查面,那是长期成本而非一次性工时,无业务拉动不做。真需要哈希时按能力准入流程重提:**实现先行,声明随实现走**(ADR-0049 的 enforce 腿留给有实现的那天)。 + +## FROM → TO + +| 写了什么 | 现在怎么办 | +| :--- | :--- | +| `capabilities: ['crypto.hash']` | **删掉这个 token**。它从未授权成任何东西 | +| `await ctx.crypto.hash(algo, data)` | **删掉这次调用**。它从未返回过值 —— 今天能跑的代码没有一行依赖它 | +| 确实需要哈希 | 在 host 侧做(Connector recipe,或引擎侧 hook)。沙箱内哈希须走能力准入流程重开,实现先行 | + +一句话修法:**两个都删**。`os migrate meta --from 16` 会自动帮你剥掉 token;那行**死调用是你自己要删的** —— 转换层刻意不改 body 源码(见下)。 + +## 定级理由(逐条自证,未照抄前例) + +三问按 #4535 §5 逐条走: + +1. **会不会 TS2305 / TS2339?** 会,两处。`HookBodyCapability` 是 public 导出类型,把它当**字面量联合**用的代码(`const c: HookBodyCapability = 'crypto.hash'`、对 token 做穷举 switch)现在编译失败;`ScriptContext.crypto.hash` 的调用点以 TS2339 失败。实测三仓(objectstack / cloud / objectui)裸名扫描 `crypto.hash` / `ctx.crypto.hash` / `'crypto.hash'` —— **两个兄弟仓零命中**,本仓命中全在本 PR 内清理。 +2. **有没有元数据迁移?** 有。token 是写在作者源 hook/action body `capabilities: []` 数组里的**值**,也会躺在已存的 `sys_metadata` 行里 —— 故注册了 ADR-0087 D2 转换 `hook-body-crypto-hash-removed`(D3 挂 protocol-17)。这是与 #4767 / #4783 / #4616 的分界:那三单退役的是**导出名 / 运行时描述符**,没有作者源可改写;本单有,和 `object-enable-trash-mru-removed` / #4734 同侧。 +3. **形状变更?** 是**枚举值收窄**(6 → 5),不是 key 移除。故**没有 `retiredKey()` 墓碑** —— `capabilities` 这个 key 本身依然活着、依然被强制。处方改由枚举自己的 error map 承载,并按 `object.managedBy: 'system'` 的先例**以 `issue.input` 为键**:只有「曾经合法」的那个拼写会被告知「was removed」,写错成 `crypto.hsah` 的作者拿到的仍是 zod 自己那条列出合法 token 的消息 —— 告诉他「你的值被退役了」属于误导。 + +`@objectstack/cli` 与 `@objectstack/runtime` 同定 **major**:前者 `ExtractedBody.capabilities` 的公开联合类型收窄(赋值给它的代码 TS2322),后者 `ScriptContext.crypto` 少一个成员(TS2339)。 + +## 门禁实报 + +枚举值收窄对四张 ratchet **全部不可见**,这一点值得单独记一笔:`authorable-surface.json` 记到 key 级(`data/ScriptBody:capabilities`),`json-schema.manifest.json` 记 def 名(`data/HookBodyCapability` 仍在),`packages/spec/json-schema/` 本身 gitignore。所以 `check:authorable-surface` / `check:api-surface` 实跑**零变化**,`check:liveness` / `check:empty-state` 同样 PASS(`capabilities` key 仍活,不产生台账行变更)。 + +也就是说:**本次移除没有任何一张基线能自动兜住它** —— 兜住它的只有本 PR 新增的 pin 测试(spec / cli / runtime 各一组,已 sabotage 实跑验证复活即红)。`check:generated` 8/8 绿,移动的是 `spec-changes.json`、`docs/protocol-upgrade-guide.md` 与两页生成参考文档(`data/hook-body.mdx`、`ui/action.mdx`,枚举选项随之少一项)。 + +## 转换刻意不做的事 + +`hook-body-crypto-hash-removed` 只从 `body.capabilities` 里剥掉死 token,**不碰** body 源码里那行 `ctx.crypto.hash(...)`。这是有意的:那行调用从未返回过值,剥掉授权不会让任何还能跑的东西变坏;但把它一并「修好」会让作者失去唯一一个还在提醒他「这里有段死代码」的信号。`retiredFromLoadPath: true` —— 枚举当场拒绝,活作者在 parse 时就被教育,转换存在的意义是让已存的 16.x / 17-rc 行重放干净(否则永远被打成 `metadata_spec_invalid`,把链上历史误标成当期违约)以及让 `os migrate meta --from 16` 改写作者源。 diff --git a/content/docs/automation/hook-bodies.mdx b/content/docs/automation/hook-bodies.mdx index 9a6ba155ae..f7816b36f6 100644 --- a/content/docs/automation/hook-bodies.mdx +++ b/content/docs/automation/hook-bodies.mdx @@ -100,10 +100,28 @@ The script sees only what the surrounding `ctx` object exposes: | `ctx.api.object(name).find\|count\|aggregate` | Cross-object reads, scoped to current tenant. | `api.read` | | `ctx.api.object(name).insert\|update\|delete` | Cross-object writes. | `api.write` | | `ctx.crypto.randomUUID()` | UUID generation. | `crypto.uuid` | -| `ctx.crypto.hash(algo, data)` _(not yet wired)_ | Sha-256/512 etc. The `crypto.hash` capability exists in the schema and is inferred by the build-time extractor, but the QuickJS sandbox currently only installs `ctx.crypto.randomUUID` — calling `ctx.crypto.hash(...)` throws inside the VM today. | `crypto.hash` | | `ctx.log.{info,warn,error}` | Structured logging. | `log` | | `ctx.connector(name).(...)` _(planned)_ | Outbound HTTP / SaaS calls. **Not yet wired into the sandbox** — ships with the separate Connector spec. | (separate Connector spec) | + +**There is no hashing capability — `crypto.hash` was removed in spec 17.** Until +17 the `crypto.hash` token was declared in `HookBodyCapability`, listed in this +table, typed on `ScriptContext` and *auto-inferred by the build-time extractor* — +but the sandbox never installed the function. Every call the token authorised +threw inside the VM, while `os build` reported success precisely because writing +`ctx.crypto.hash(...)` is what made the CLI grant the capability. All four +declarations were removed together in #4391: declaring `crypto.hash` is now a +parse error that explains this, and writing the call no longer earns a capability. + +**If you declared it:** delete the token from `capabilities` **and** delete the +`ctx.crypto.hash(...)` call — the call has never returned a value, so nothing +that works today depends on it. `os migrate meta --from 16` strips the token for +you; the dead call is yours to remove. Hash in the host instead (a Connector +recipe, or an engine-side hook). Hashing inside the sandbox comes back only +*with* an implementation, via the capability admission process — the declaration +follows the implementation, it never leads it. + + ### What the sandbox forbids The CLI builder **rejects** any source that uses: @@ -296,7 +314,6 @@ The extractor scans each body for known patterns and adds the matching capabilit | `*.object(…).find / findOne / count / aggregate / get / list` | `api.read` | | `*.object(…).insert / update / upsert / delete / patch / remove / create` | `api.write` | | `ctx.crypto.randomUUID` | `crypto.uuid` | -| `ctx.crypto.hash` | `crypto.hash` | | `ctx.log.info / warn / error / debug` | `log` | To override the inference, add a directive comment as the first line of your handler body: diff --git a/content/docs/references/data/hook-body.mdx b/content/docs/references/data/hook-body.mdx index 98cfd677f6..b20b9e13d4 100644 --- a/content/docs/references/data/hook-body.mdx +++ b/content/docs/references/data/hook-body.mdx @@ -5,34 +5,6 @@ description: Hook Body protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} -Capability tokens a script body may request. - -The runtime sandbox enforces these — if a body uses a `ctx` API that requires - -a capability it did not declare, the call throws at invocation time. - -- `api.read` — `ctx.api.object(...).find / findOne / count / aggregate` - -- `api.write` — `ctx.api.object(...).insert / update / delete` - -- `api.transaction` — `ctx.api.transaction(async () => \{ … \})` — runs the - -callback's `ctx.api` writes/reads inside one driver transaction, committed - -on return and rolled back if the callback throws. Requires `api.write` - -alongside it to be useful (the transaction body still needs write access). - -- `crypto.uuid` — `ctx.crypto.randomUUID()` - -- `crypto.hash` — `ctx.crypto.hash(algo, data)` - -- `log` — `ctx.log.info / warn / error` - -`http.fetch` is intentionally absent — outbound calls go through Connector - -recipes (separate spec) so they remain auditable and replayable. - **Source:** `packages/spec/src/data/hook-body.zod.ts` @@ -94,7 +66,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities | :--- | :--- | :--- | :--- | | **language** | `'js'` | ✅ | | | **source** | `string` | ✅ | Function body source | -| **capabilities** | `Enum<'api.read' \| 'api.write' \| 'api.transaction' \| 'crypto.uuid' \| 'crypto.hash' \| 'log'>[]` | ✅ | Granted capability tokens | +| **capabilities** | `Enum<'api.read' \| 'api.write' \| 'api.transaction' \| 'crypto.uuid' \| 'log'>[]` | ✅ | Granted capability tokens | | **timeoutMs** | `integer` | optional | Per-invocation timeout (ms) | | **memoryMb** | `integer` | optional | Per-invocation memory cap (MB) | @@ -111,7 +83,6 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities * `api.write` * `api.transaction` * `crypto.uuid` -* `crypto.hash` * `log` @@ -127,7 +98,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities | :--- | :--- | :--- | :--- | | **language** | `'js'` | ✅ | | | **source** | `string` | ✅ | Function body source | -| **capabilities** | `Enum<'api.read' \| 'api.write' \| 'api.transaction' \| 'crypto.uuid' \| 'crypto.hash' \| 'log'>[]` | ✅ | Granted capability tokens | +| **capabilities** | `Enum<'api.read' \| 'api.write' \| 'api.transaction' \| 'crypto.uuid' \| 'log'>[]` | ✅ | Granted capability tokens | | **timeoutMs** | `integer` | optional | Per-invocation timeout (ms) | | **memoryMb** | `integer` | optional | Per-invocation memory cap (MB) | diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index c2678257fb..7cdaf259d1 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -86,7 +86,7 @@ const result = ActionSchema.parse(data); | **type** | `Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api' \| 'form'>` | optional | Action functionality type | | **target** | `string` | optional | URL, Script Name, Flow ID, or API Endpoint. Supports $`{param.X}` and $`{ctx.X}` interpolation. | | **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For type:'url' — where to open `target`. 'new-tab' opens a new browser tab; 'self' navigates in place. When omitted, external/absolute URLs open in a new tab and relative URLs navigate in place. Static execution option — keep it OUT of `params` (which is user-input-collection only). | -| **body** | `{ language: 'expression'; source: string } \| { language: 'js'; source: string; capabilities?: Enum<'api.read' \| 'api.write' \| 'api.transaction' \| 'crypto.uuid' \| 'crypto.hash' \| 'log'>[]; timeoutMs?: integer; … }` | optional | Action body — expression (L1) or sandboxed JS (L2). Only used when type is `script`. | +| **body** | `{ language: 'expression'; source: string } \| { language: 'js'; source: string; capabilities?: Enum<'api.read' \| 'api.write' \| 'api.transaction' \| 'crypto.uuid' \| 'log'>[]; timeoutMs?: integer; … }` | optional | Action body — expression (L1) or sandboxed JS (L2). Only used when type is `script`. | | **execute** | `any` | optional | [REMOVED] `execute` was removed in @objectstack/spec 17 (#3855) — use `target`. Rename the key; the value (a handler / flow / URL ref) is unchanged. Run `os migrate meta --from 16` to rewrite it automatically. | | **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string; … }[]` | optional | Input parameters required from user | | **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) | diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index a78eec4297..42cdab2432 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -182,6 +182,8 @@ The same enforce-or-remove pass retires the `RestServerConfig.openApi31` block ( The same pass closes `activationEvents` (#4657): both keys that carried it — `DynamicLoadRequest.activationEvents` on the kernel side and `StudioPluginManifest.activationEvents` on the studio side — declared lazy plugin activation ("plugins remain dormant until an activation event fires") that no runtime in any repo ever implemented: every plugin has always activated immediately on load/registration, and cloud-v1's own ROADMAP recorded the capability as unimplemented, planned for v0.4.0. #4653 had just converged the two `ActivationEventSchema` declarations onto one structured `{ type, pattern }` vocabulary in this same unreleased major; with the maintainer's enforce-or-remove ruling landing on REMOVE, that converged vocabulary retires before ever shipping — composed across the two changes, a v16 author simply deletes the key in whichever form they carried. Neither parent is stored metadata (`StudioPluginManifest` is TS configuration parsed by `defineStudioPlugin`; `DynamicLoadRequest` is a runtime request shape with no caller in any repo), so there is no source for the chain to rewrite — one semantic TODO, the `validateOnly` shape. The kernel key is tombstoned (its schema is not `.strict()`; a plain delete would strip it silently), the studio key is rejected by the strict manifest parse with its own guidance prescription, and the orphaned `ActivationEventSchema` def is removed with them. Behaviour is byte-identical: eager activation was always the only behaviour. +Finally it removes the script-body capability token 'crypto.hash' (#4391). Four layers declared it — the `HookBodyCapability` enum, the doc table beside it, the CLI extractor and `ScriptContext.crypto.hash` — and none implemented it: `installCtx` wired only `randomUUID`, so the one call the token authorised threw inside the VM every time. The build-time inference made it worse than an ordinary declared-but-unenforced key: writing `ctx.crypto.hash(...)` made the CLI ADD the capability for you, so `os build` went green on the body that was guaranteed to fail at the first record write. Removed rather than implemented (ADR-0049) — hashing inside the sandbox widens its capability and security-review surface, and a capability that throws on every use yet drew zero complaints in its whole life is its own liveness verdict. This is an enum VALUE, not a key, so there is no `retiredKey()` tombstone: the enum error map carries the prescription, keyed on the received value so that only the spelling which used to be legal is told it "was removed". The conversion strips the dead token from `body.capabilities` on hooks and actions; it deliberately does NOT touch the `ctx.crypto.hash(...)` call the body made under it, which never returned a value and which the author must delete. Hashing returns only WITH an implementation, through the capability admission process. + ### Mechanical (applied for you) | Conversion | Surface | Change | Load window | @@ -222,6 +224,7 @@ The same pass closes `activationEvents` (#4657): both keys that carried it — ` | `retry-policy-converged` | `flow.node.config.retry.retryDelayMs / job.retryPolicy.maxRetries / job.retryPolicy.backoffMultiplier` | retry policy unified across job.retryPolicy and try_catch retry: base delay 'retryDelayMs' → 'backoffMs', and the pre-17 job defaults (maxRetries 3, backoffMultiplier 2) written out explicitly now that the merged default is 0 / 1 (#4661) | live — protocol 17 loader accepts the old shape | | `object-managed-by-system-to-system-data` | `object.managedBy` | object managedBy 'system' → 'system-data' (#3355 — ADR-0103's residual bucket named the engine-owned half v16 had already moved out to `engine-owned`; the rename leaves the name describing what the bucket actually holds: admin/user-writable platform data) | retired — `migrate meta` only | | `object-enable-trash-mru-removed` | `object.enable.trash / object.enable.mru` | object capability flags 'enable.trash'/'enable.mru' removed (#3207, #2377 close-out — no recycle bin and no MRU tracking ever ran; both default-true flags gated nothing) | retired — `migrate meta` only | +| `hook-body-crypto-hash-removed` | `hook.body.capabilities / action.body.capabilities` | script-body capability token 'crypto.hash' removed (#4391 — the sandbox never installed ctx.crypto.hash, so the token granted a call that always threw; the CLI inferred it too) | retired — `migrate meta` only | ### Semantic (delegated to you, with acceptance criteria) diff --git a/packages/cli/src/utils/extract-hook-body.ts b/packages/cli/src/utils/extract-hook-body.ts index 7f6e4d7392..1e05dd68ec 100644 --- a/packages/cli/src/utils/extract-hook-body.ts +++ b/packages/cli/src/utils/extract-hook-body.ts @@ -40,7 +40,7 @@ const FORBIDDEN_PATTERNS: Array<{ rx: RegExp; reason: string }> = [ { rx: /\bnew\s+Function\s*\(/, reason: '`new Function()` is not allowed in hook/action bodies' }, ]; -const CAPABILITY_PATTERNS: Array<{ rx: RegExp; cap: 'api.read' | 'api.write' | 'crypto.uuid' | 'crypto.hash' | 'log' }> = [ +const CAPABILITY_PATTERNS: Array<{ rx: RegExp; cap: 'api.read' | 'api.write' | 'crypto.uuid' | 'log' }> = [ // Match `ctx.api.object(...)` directly OR a local alias like // `const api = ctx.api;` then `api.object(...)`. We accept any // identifier (or chain) ending in `.object(...)` followed by a known @@ -49,7 +49,10 @@ const CAPABILITY_PATTERNS: Array<{ rx: RegExp; cap: 'api.read' | 'api.write' | ' { rx: /\.object\s*\([^)]+\)\s*\.\s*(?:find|findOne|count|aggregate|get|list)\b/, cap: 'api.read' }, { rx: /\.object\s*\([^)]+\)\s*\.\s*(?:insert|update|upsert|delete|patch|remove|create)\b/, cap: 'api.write' }, { rx: /ctx\.crypto\.randomUUID\b/, cap: 'crypto.uuid' }, - { rx: /ctx\.crypto\.hash\b/, cap: 'crypto.hash' }, + // NO `ctx.crypto.hash` pattern: the `crypto.hash` token was removed in spec 17 + // (#4391) because the sandbox never installed the function. Inferring a + // capability from a call that always threw is what let `os build` bless a + // dead body — the inference was the amplifier, not the safety net. { rx: /ctx\.log\.(?:info|warn|error|debug)\b/, cap: 'log' }, ]; @@ -57,7 +60,7 @@ export interface ExtractedBody { /** Pure function-body source (without the surrounding `(ctx) => {...}`). */ source: string; /** Inferred capability tokens — may be merged with explicit `// @capabilities` line. */ - capabilities: Array<'api.read' | 'api.write' | 'crypto.uuid' | 'crypto.hash' | 'log'>; + capabilities: Array<'api.read' | 'api.write' | 'crypto.uuid' | 'log'>; /** True when source is a single expression (arrow with implicit return). */ isExpression: boolean; } @@ -120,7 +123,6 @@ export function extractHookBody(fn: (...a: unknown[]) => unknown, originLabel: s t === 'api.read' || t === 'api.write' || t === 'crypto.uuid' || - t === 'crypto.hash' || t === 'log' ) { inferred.add(t); diff --git a/packages/cli/test/extract-hook-body.test.ts b/packages/cli/test/extract-hook-body.test.ts index 0d0586fbdf..1693703749 100644 --- a/packages/cli/test/extract-hook-body.test.ts +++ b/packages/cli/test/extract-hook-body.test.ts @@ -94,6 +94,44 @@ describe('extractHookBody', () => { expect(ext.capabilities.sort()).toEqual(['api.read', 'api.write', 'log']); }); + // ── `crypto.hash` inference retired (#4391) ────────────────────────────── + // + // This inference was the amplifier that kept the missing implementation + // alive: writing `ctx.crypto.hash(...)` made the extractor GRANT the + // capability, so `os build` went green on a body guaranteed to throw at the + // first record write. The token left `HookBodyCapability` in spec 17, so + // re-adding the pattern would now emit a body the spec itself rejects. + + it('does NOT infer a capability from ctx.crypto.hash (#4391)', () => { + const fn = async (ctx: any) => { + ctx.input.fingerprint = await ctx.crypto.hash('sha256', ctx.input.email); + }; + const ext = extractHookBody(fn, 'hook hash'); + expect(ext.capabilities).toEqual([]); + expect(ext.capabilities).not.toContain('crypto.hash'); + // The source still travels verbatim — the extractor's job is not to rewrite + // the body, and the dead call is the author's to delete (the spec parse + // error on the declared token is what tells them so). + expect(ext.source).toContain('ctx.crypto.hash'); + }); + + it('ignores crypto.hash in an explicit @capabilities override (#4391)', () => { + const fn = (_ctx: any) => { + // @capabilities api.read crypto.hash log + return 1; + }; + const ext = extractHookBody(fn, 'hook f'); + expect(ext.capabilities.sort()).toEqual(['api.read', 'log']); + expect(ext.capabilities).not.toContain('crypto.hash'); + }); + + it('still infers crypto.uuid — the sibling that IS implemented (#4391)', () => { + const fn = (ctx: any) => { + ctx.input.trace = ctx.crypto.randomUUID(); + }; + expect(extractHookBody(fn, 'hook uuid').capabilities).toContain('crypto.uuid'); + }); + // #1876 — a handler that references a module-scope helper is not self- // contained; extraction must throw so lowerCallables falls back to bundling // (which carries the closure) instead of shipping a body that ReferenceErrors. diff --git a/packages/runtime/src/sandbox/quickjs-runner.test.ts b/packages/runtime/src/sandbox/quickjs-runner.test.ts index d36799c2a3..434c7d22e9 100644 --- a/packages/runtime/src/sandbox/quickjs-runner.test.ts +++ b/packages/runtime/src/sandbox/quickjs-runner.test.ts @@ -143,6 +143,65 @@ describe('QuickJSScriptRunner — L2 hook script', () => { expect((r.value as string).length).toBeGreaterThanOrEqual(36); }); + // ── `crypto.hash` retirement pins (#4391) ───────────────────────────────── + // + // `ScriptContext.crypto.hash` was typed on the host seam (and `crypto.hash` + // was an authorable token, and the CLI inferred it) while `installCtx` wired + // only `randomUUID`. The type promised a seam that never existed; spec 17 + // removed it rather than implementing it, so nothing may re-declare it + // without also installing it. + + it('the VM ctx.crypto exposes exactly randomUUID, under ANY grant (#4391)', async () => { + // The EXECUTABLE pin, and the load-bearing one. `@objectstack/runtime` has + // no `typecheck` script (it sits in the DEBT table of + // scripts/check-type-check-coverage.mjs), so a type-level assertion here + // would never be compiled — a dead pin reads as assurance and gives none. + // This enumerates what `installCtx` actually put on the seam instead. + // + // It is deliberately exhaustive rather than `hash`-specific: the defect was + // a member advertised ahead of its implementation, so ANY new member must + // come through a review that also updates this list. + // + // Every token the enum still offers is granted, so a failure cannot be + // misread as "the capability simply was not granted". + const allGrants = ['api.read', 'api.write', 'api.transaction', 'crypto.uuid', 'log'] as const; + + const keys = await runner.runScript( + { + language: 'js', + source: 'return Object.keys(ctx.crypto).sort().join(",");', + capabilities: [...allGrants], + }, + ctx(), + hookOpts, + ); + expect(keys.value).toBe('randomUUID'); + + // And the specific regression: no hash function reachable by any spelling. + const typeofHash = await runner.runScript( + { + language: 'js', + source: 'return typeof ctx.crypto.hash;', + capabilities: [...allGrants], + }, + ctx(), + hookOpts, + ); + expect(typeofHash.value).toBe('undefined'); + }); + + it('ScriptContext.crypto declares randomUUID and nothing else (#4391)', () => { + // Compile-time companion to the pin above. It is DORMANT today (runtime is + // not typechecked — see the note above) and arms itself the moment runtime + // onboards `typecheck`; it is kept because re-declaring the type without an + // implementation is the exact defect #4391 removed, and this is where the + // next reader will look for that rule. + type CryptoSeam = NonNullable< ScriptContext['crypto'] >; + type ExtraMembers = Exclude< keyof CryptoSeam, 'randomUUID' >; + const extraMembers: ExtraMembers[] = []; + expect(extraMembers).toEqual([]); + }); + it('reports script-thrown errors with origin name', async () => { await expect( runner.runScript( diff --git a/packages/runtime/src/sandbox/script-runner.ts b/packages/runtime/src/sandbox/script-runner.ts index 112e8eab16..0a40e4ff0a 100644 --- a/packages/runtime/src/sandbox/script-runner.ts +++ b/packages/runtime/src/sandbox/script-runner.ts @@ -112,9 +112,20 @@ export interface ScriptContext { warn: (msg: string, data?: unknown) => void; error: (msg: string, data?: unknown) => void; }; + /** + * Host-provided crypto seam. `randomUUID` is the only member — it is the only + * one `installCtx` (quickjs-runner.ts) ever wired onto the VM's `ctx.crypto`. + * + * `hash?: (algo, data) => Promise< string >` was declared here until spec 17 + * (#4391) with no implementation behind it anywhere: this signature, the + * `crypto.hash` capability token and the CLI's build-time inference all + * described a function the sandbox never installed, so the one call it typed + * threw inside the VM. Removed rather than implemented (ADR-0049) — hashing + * in the sandbox widens its capability and security-review surface and + * nothing pulled for it. It returns WITH an implementation, not ahead of one. + */ crypto?: { randomUUID?: () => string; - hash?: (algo: string, data: string | Uint8Array) => Promise; }; } diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 493d99d7d2..f88462b5ab 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -283,6 +283,12 @@ "to": "object capability flags 'enable.trash'/'enable.mru' removed (#3207, #2377 close-out — no recycle bin and no MRU tracking ever ran; both default-true flags gated nothing)", "conversionId": "object-enable-trash-mru-removed", "toMajor": 17 + }, + { + "surface": "hook.body.capabilities / action.body.capabilities", + "to": "script-body capability token 'crypto.hash' removed (#4391 — the sandbox never installed ctx.crypto.hash, so the token granted a call that always threw; the CLI inferred it too)", + "conversionId": "hook-body-crypto-hash-removed", + "toMajor": 17 } ], "migrated": [ @@ -893,6 +899,12 @@ "to": "object capability flags 'enable.trash'/'enable.mru' removed (#3207, #2377 close-out — no recycle bin and no MRU tracking ever ran; both default-true flags gated nothing)", "conversionId": "object-enable-trash-mru-removed", "toMajor": 17 + }, + { + "surface": "hook.body.capabilities / action.body.capabilities", + "to": "script-body capability token 'crypto.hash' removed (#4391 — the sandbox never installed ctx.crypto.hash, so the token granted a call that always threw; the CLI inferred it too)", + "conversionId": "hook-body-crypto-hash-removed", + "toMajor": 17 } ], "migrated": [ diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index fb4ca25be6..1211939df3 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -3424,6 +3424,140 @@ const retryPolicyConverged: MetadataConversion = { }, }; +/** + * The `crypto.hash` capability token leaves `HookBodyCapability` (protocol 17, + * #4391 — ADR-0049 enforce-or-remove). + * + * Four layers advertised the token and none implemented it: the spec enum, the + * doc table beside it, the CLI's build-time extractor (which INFERRED the token + * from a `ctx.crypto.hash` call, so `os build` blessed the very body that was + * about to fail) and `ScriptContext.crypto.hash`. `installCtx` wired only + * `randomUUID`, so the single call the token authorised threw inside the VM. + * Removed rather than implemented: crypto in the sandbox widens its capability + * and security-review surface, and zero complaints against a capability that + * throws on every use is the strongest liveness evidence there is. + * + * This is an enum-VALUE retirement, so there is no `retiredKey()` tombstone to + * hang the prescription on — the enum's own error map carries it + * (`CRYPTO_HASH_RETIRED`, data/hook-body.zod.ts), keyed on `issue.input` so that + * only the value which used to be legal gets the "was removed" message. + * + * `retiredFromLoadPath`: the enum rejects the token outright, so a live author + * is taught at parse rather than silently rewritten. The entry exists so stored + * 16.x/17-rc rows replay clean (`applyConversionsToStoredItem` — without it a + * pre-removal row flags `metadata_spec_invalid` forever, mislabelling + * chain-owned history as a current-contract violation) and so + * `os migrate meta --from 16` rewrites author sources. The + * `object-enable-trash-mru-removed` precedent, one level deeper: the token is a + * VALUE inside `body.capabilities`, not a key, so `stripKeys` cannot reach it. + * + * Note what the strip deliberately does NOT do: it removes the dead grant, not + * the `ctx.crypto.hash(...)` call the body made under it. That call has never + * returned a value, so nothing regresses — but the author is still left a dead + * line to delete, which is exactly what the prescription tells them to do. + */ +const hookBodyCryptoHashRemoved: MetadataConversion = { + id: 'hook-body-crypto-hash-removed', + toMajor: 17, + retiredFromLoadPath: true, + surface: 'hook.body.capabilities / action.body.capabilities', + summary: + "script-body capability token 'crypto.hash' removed (#4391 — the sandbox never installed " + + 'ctx.crypto.hash, so the token granted a call that always threw; the CLI inferred it too)', + apply(stack, emit) { + const stripToken = (item: Dict, path: string): Dict => { + const body = item.body; + if (!body || typeof body !== 'object' || Array.isArray(body)) return item; + const caps = (body as Dict).capabilities; + if (!Array.isArray(caps) || !caps.includes('crypto.hash')) return item; + emit({ from: 'crypto.hash', to: '(removed)', path: `${path}.body.capabilities` }); + return { + ...item, + body: { ...(body as Dict), capabilities: caps.filter((c) => c !== 'crypto.hash') }, + }; + }; + const withHooks = mapCollection(stack, 'hooks', stripToken); + return mapCollection(withHooks, 'actions', stripToken); + }, + fixture: { + before: { + hooks: [ + { + name: 'fingerprint_lead', + object: 'crm_lead', + events: ['beforeInsert'], + body: { + language: 'js', + source: "ctx.input.fp = await ctx.crypto.hash('sha256', ctx.input.email);", + capabilities: ['crypto.hash', 'crypto.uuid'], + }, + }, + // a body without the retired token passes through untouched + { + name: 'stamp_lead', + object: 'crm_lead', + events: ['beforeInsert'], + body: { + language: 'js', + source: 'ctx.input.trace = ctx.crypto.randomUUID();', + capabilities: ['crypto.uuid'], + }, + }, + ], + actions: [ + { + name: 'digest_deal', + type: 'script', + body: { + language: 'js', + source: "return ctx.crypto.hash('sha256', ctx.record.id);", + capabilities: ['crypto.hash'], + }, + }, + ], + }, + // Surgical: `crypto.uuid` survives beside the stripped token, and the + // `capabilities` key itself stays (an empty grant set is legal). + after: { + hooks: [ + { + name: 'fingerprint_lead', + object: 'crm_lead', + events: ['beforeInsert'], + body: { + language: 'js', + source: "ctx.input.fp = await ctx.crypto.hash('sha256', ctx.input.email);", + capabilities: ['crypto.uuid'], + }, + }, + { + name: 'stamp_lead', + object: 'crm_lead', + events: ['beforeInsert'], + body: { + language: 'js', + source: 'ctx.input.trace = ctx.crypto.randomUUID();', + capabilities: ['crypto.uuid'], + }, + }, + ], + actions: [ + { + name: 'digest_deal', + type: 'script', + body: { + language: 'js', + source: "return ctx.crypto.hash('sha256', ctx.record.id);", + capabilities: [], + }, + }, + ], + }, + // One per rewritten body — the hook and the action; `stamp_lead` is untouched. + expectedNotices: 2, + }, +}; + export const CONVERSIONS_BY_MAJOR: Readonly> = { 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], 13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition], @@ -3468,6 +3602,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly { @@ -53,6 +54,62 @@ describe('HookBody', () => { expect(r.success).toBe(false); }); + // ── `crypto.hash` retirement pins (#4391) ─────────────────────────────── + // + // The token was declared here, inferred by the CLI extractor and typed on + // `ScriptContext`, while the sandbox installed only `randomUUID` — so the + // one call it authorised always threw. All three declarations went in + // #4391. These assertions are what must go red if any of them comes back + // WITHOUT an implementation behind it. + + it('does not offer `crypto.hash` as a capability token (#4391)', () => { + expect(HookBodyCapability.options).toEqual([ + 'api.read', + 'api.write', + 'api.transaction', + 'crypto.uuid', + 'log', + ]); + expect(HookBodyCapability.options).not.toContain('crypto.hash'); + }); + + it('rejects a body declaring `crypto.hash`, with the retirement prescription (#4391)', () => { + const r = ScriptBodySchema.safeParse({ + language: 'js', + source: "ctx.input.fp = await ctx.crypto.hash('sha256', ctx.input.email);", + capabilities: ['crypto.hash'], + }); + expect(r.success).toBe(false); + const message = r.success ? '' : JSON.stringify(r.error.issues); + // The prescription itself, not a bare "invalid enum value": it must name + // the token, say it was removed, and tell the author what to do instead. + expect(message).toMatch(/crypto\.hash.*was removed.*17.*#4391/s); + expect(message).toMatch(/sandbox never implemented it/s); + expect(message).toMatch(/Delete the capability/s); + }); + + it('still accepts `crypto.uuid` — the sibling that IS implemented (#4391)', () => { + const r = ScriptBodySchema.safeParse({ + language: 'js', + source: 'ctx.input.trace = ctx.crypto.randomUUID();', + capabilities: ['crypto.uuid'], + }); + expect(r.success).toBe(true); + }); + + it("gives an UNKNOWN token zod's own message, not the retirement one (#4391)", () => { + // Only the value that used to be legal gets "was removed" — telling the + // author of a typo that their token was retired would misinform. + const r = ScriptBodySchema.safeParse({ + language: 'js', + source: 'x = 1;', + capabilities: ['crypto.hsah'], + }); + expect(r.success).toBe(false); + const message = r.success ? '' : JSON.stringify(r.error.issues); + expect(message).not.toMatch(/was removed/); + }); + it('rejects timeoutMs over the cap', () => { const r = ScriptBodySchema.safeParse({ language: 'js', diff --git a/packages/spec/src/data/hook-body.zod.ts b/packages/spec/src/data/hook-body.zod.ts index 038da7b20a..78313e1a9e 100644 --- a/packages/spec/src/data/hook-body.zod.ts +++ b/packages/spec/src/data/hook-body.zod.ts @@ -3,6 +3,23 @@ import { z } from 'zod'; import { strictUnknownKeyError } from '../shared/suggestions.zod'; +// Retired token prescription. Declared with `//` (never `/** */`) and ABOVE the +// capability enum's JSDoc on purpose — see the placement note below: build-docs +// takes the file's FIRST JSDoc as the reference page's module blurb, so a doc +// comment here would replace the whole capability-token table. +const CRYPTO_HASH_RETIRED = + "`crypto.hash` was removed from `HookBodyCapability` in @objectstack/spec 17 (#4391, " + + 'ADR-0049 enforce-or-remove) — the sandbox never implemented it. `installCtx` wired only ' + + '`ctx.crypto.randomUUID`, so `ctx.crypto.hash(...)` threw inside the VM on every call the ' + + 'token ever "granted", while the build-time extractor inferred the token from that very ' + + 'call and let `os build` pass. Delete the capability from `capabilities` AND delete the ' + + '`ctx.crypto.hash(...)` call it was declared for — the call has never returned a value, so ' + + 'nothing that works today depends on it. There is no replacement inside the sandbox: hash ' + + 'in the host (a Connector recipe, or an engine-side hook) instead. If you need hashing in ' + + 'a body, reopen it through the capability admission process — implementation first, the ' + + 'declaration lands with the implementation. ' + + 'Run `os migrate meta --from 16` to rewrite it automatically.'; + /** * Capability tokens a script body may request. * @@ -16,20 +33,31 @@ import { strictUnknownKeyError } from '../shared/suggestions.zod'; * on return and rolled back if the callback throws. Requires `api.write` * alongside it to be useful (the transaction body still needs write access). * - `crypto.uuid` — `ctx.crypto.randomUUID()` - * - `crypto.hash` — `ctx.crypto.hash(algo, data)` * - `log` — `ctx.log.info / warn / error` * * `http.fetch` is intentionally absent — outbound calls go through Connector * recipes (separate spec) so they remain auditable and replayable. + * + * `crypto.hash` was REMOVED in 17 (#4391): declared here, inferred by the CLI + * extractor and typed on `ScriptContext`, but never installed on the VM's + * `ctx.crypto` — so the one thing it authorised always threw. Every layer that + * advertised it is gone in the same change; a body that still declares it is + * rejected at parse with {@link CRYPTO_HASH_RETIRED}. It comes back only WITH + * an implementation (ADR-0049's enforce leg), not ahead of one. */ export const HookBodyCapability = z.enum([ 'api.read', 'api.write', 'api.transaction', 'crypto.uuid', - 'crypto.hash', 'log', -]); +], { + // Only the value that USED to be legal gets the retirement prescription — + // telling the author of `crypto.hsah` that their value "was removed" would + // misinform. Everything else keeps zod's own enum message, which already + // lists the legal tokens. (The `managedBy: 'system'` precedent, object.zod.ts.) + error: (issue) => (issue.input === 'crypto.hash' ? CRYPTO_HASH_RETIRED : undefined), +}); export type HookBodyCapability = z.infer; /* diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 2966fc711b..6a07aa9753 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -737,7 +737,24 @@ const step17: MigrationStep = { + '(its schema is not `.strict()`; a plain delete would strip it silently), the studio ' + 'key is rejected by the strict manifest parse with its own guidance prescription, and ' + 'the orphaned `ActivationEventSchema` def is removed with them. Behaviour is ' - + 'byte-identical: eager activation was always the only behaviour.', + + 'byte-identical: eager activation was always the only behaviour.\n\n' + + "Finally it removes the script-body capability token 'crypto.hash' (#4391). Four layers " + + 'declared it — the `HookBodyCapability` enum, the doc table beside it, the CLI extractor ' + + 'and `ScriptContext.crypto.hash` — and none implemented it: `installCtx` wired only ' + + '`randomUUID`, so the one call the token authorised threw inside the VM every time. The ' + + 'build-time inference made it worse than an ordinary declared-but-unenforced key: writing ' + + '`ctx.crypto.hash(...)` made the CLI ADD the capability for you, so `os build` went green ' + + 'on the body that was guaranteed to fail at the first record write. Removed rather than ' + + 'implemented (ADR-0049) — hashing inside the sandbox widens its capability and ' + + 'security-review surface, and a capability that throws on every use yet drew zero ' + + 'complaints in its whole life is its own liveness verdict. This is an enum VALUE, not a ' + + 'key, so there is no `retiredKey()` tombstone: the enum error map carries the ' + + 'prescription, keyed on the received value so that only the spelling which used to be ' + + 'legal is told it "was removed". The conversion strips the dead token from ' + + '`body.capabilities` on hooks and actions; it deliberately does NOT touch the ' + + '`ctx.crypto.hash(...)` call the body made under it, which never returned a value and ' + + 'which the author must delete. Hashing returns only WITH an implementation, through the ' + + 'capability admission process.', conversionIds: [ 'action-execute-to-target', 'field-conditionalRequired-to-requiredWhen', @@ -775,6 +792,7 @@ const step17: MigrationStep = { 'object-managed-by-system-to-system-data', 'retry-policy-converged', 'object-enable-trash-mru-removed', + 'hook-body-crypto-hash-removed', ], semantic: [ { diff --git a/skills/objectstack-data/references/data-hooks.md b/skills/objectstack-data/references/data-hooks.md index 97eda5a254..3d1b19d1d6 100644 --- a/skills/objectstack-data/references/data-hooks.md +++ b/skills/objectstack-data/references/data-hooks.md @@ -406,7 +406,7 @@ single-record form is `update({ id, ...fieldsToChange })` — e.g. A body may only touch a `ctx` API it declared in `capabilities`. Calling an undeclared one **throws inside the VM** — `capability '' not granted to hook '' …` — which surfaces as a hook error (see Troubleshooting). The full -set of legal tokens (`HookBodyCapability`) is exactly six: +set of legal tokens (`HookBodyCapability`) is exactly five: | Token | Unlocks | |:--|:--| @@ -414,7 +414,6 @@ set of legal tokens (`HookBodyCapability`) is exactly six: | `api.write` | `ctx.api.object(n).insert` / `update` / `delete` / `upsert` | | `api.transaction` | `ctx.api.transaction(async () => { … })` — runs the callback's `ctx.api` ops in **one driver transaction** (commit on return, rollback on throw). Pair it with `api.write`. | | `crypto.uuid` | `ctx.crypto.randomUUID()` | -| `crypto.hash` | *declared token* for `ctx.crypto.hash(algo, data)` — the current WASM runner wires only `randomUUID`, so don't rely on `hash` yet | | `log` | `ctx.log.info` / `warn` / `error(msg, data?)` | There is **no `http.fetch` capability** by design — outbound calls go through diff --git a/skills/objectstack-data/rules/hooks.md b/skills/objectstack-data/rules/hooks.md index 2118134270..e011dca211 100644 --- a/skills/objectstack-data/rules/hooks.md +++ b/skills/objectstack-data/rules/hooks.md @@ -88,9 +88,10 @@ Sandbox essentials (full contract in - **`ctx.api.object(n)`** repo: `find` / `findOne` / `count` / `insert` / `update({ id, ...fields })` / `upsert` / `delete`. Query key is **`where`** (object + `$`-operators) — **not** `filter: [[…]]`. -- **`capabilities`** (declare what the body uses, else it throws) — the six legal - tokens: `api.read`, `api.write`, `api.transaction`, `crypto.uuid`, - `crypto.hash`, `log`. +- **`capabilities`** (declare what the body uses, else it throws) — the five legal + tokens: `api.read`, `api.write`, `api.transaction`, `crypto.uuid`, `log`. + There is **no hashing capability**: `crypto.hash` was removed in spec 17 + (#4391) because the sandbox never implemented it. - Cross-object writes obey the **target's** sharing model — a `public_read` target rejects the write with `FORBIDDEN`, and **admin is not exempt**. - No `console` (use `ctx.log`), no `fetch` (use Connectors), no `import` /