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
43 changes: 43 additions & 0 deletions .changeset/hook-body-crypto-hash-retired.md
Original file line number Diff line number Diff line change
@@ -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` 改写作者源。
21 changes: 19 additions & 2 deletions content/docs/automation/hook-bodies.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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).<method>(...)` _(planned)_ | Outbound HTTP / SaaS calls. **Not yet wired into the sandbox** — ships with the separate Connector spec. | (separate Connector spec) |

<Callout type="warn">
**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.
</Callout>

### What the sandbox forbids

The CLI builder **rejects** any source that uses:
Expand Down Expand Up @@ -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:
Expand Down
33 changes: 2 additions & 31 deletions content/docs/references/data/hook-body.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Callout type="info">
**Source:** `packages/spec/src/data/hook-body.zod.ts`
</Callout>
Expand Down Expand Up @@ -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) |

Expand All @@ -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`


Expand All @@ -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) |

Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/ui/action.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
3 changes: 3 additions & 0 deletions docs/protocol-upgrade-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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)

Expand Down
10 changes: 6 additions & 4 deletions packages/cli/src/utils/extract-hook-body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -49,15 +49,18 @@ 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' },
];

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;
}
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading