Skip to content

Commit 0418943

Browse files
committed
refactor(spec,cli,runtime)!: 退役 crypto.hash 能力 —— 声明四层、构建期自动推断,沙箱从没实现 (#4391)
`crypto.hash` 由四层声明、零层实现:`HookBodyCapability` 枚举、枚举旁文档表、 CLI 提取器的构建期推断、`ScriptContext.crypto.hash` 签名 —— 而 `installCtx` 只 往 VM 的 `ctx.crypto` 装了 `randomUUID`,该 token 唯一授权的调用每次都在 VM 里抛。 构建期推断是危险的放大器:作者写下 `ctx.crypto.hash(...)`,提取器替他把能力加进 `capabilities`,`os build` 因此全绿,直到运行时才炸。维护者裁决 remove:从未实现、 调用即抛、零投诉 —— 对一个每次使用都抛错的能力,这本身就是最强的活性证据;在沙箱里 实现 crypto 会扩大能力面与安全审查面,无业务拉动不做。实现先行、声明随实现走。 同批删除四处声明,并注册 ADR-0087 D2 转换 `hook-body-crypto-hash-removed` (D3 挂 protocol-17):枚举值收窄不是 key 移除,故无 `retiredKey()` 墓碑,处方由 枚举 error map 按 `issue.input` 承载(`object.managedBy: 'system'` 先例)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0176qgxgCXTJCUv4YFLtusP9
1 parent 9c040f1 commit 0418943

16 files changed

Lines changed: 442 additions & 48 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
"@objectstack/spec": major
3+
"@objectstack/cli": major
4+
"@objectstack/runtime": major
5+
---
6+
7+
refactor(spec,cli,runtime)!: 退役 `crypto.hash` 能力 —— 声明了四层、构建期还自动推断,沙箱从没实现(#4391,ADR-0049 enforce-or-remove)
8+
9+
`crypto.hash` 是四层声明、零层实现:`HookBodyCapability` 枚举收它、枚举旁的文档表列它、CLI 提取器**自动推断**它、`ScriptContext.crypto.hash` 还写了签名 —— 而 `installCtx` 只往 VM 的 `ctx.crypto` 上装了 `randomUUID`。于是这个 token 唯一授权的那次调用,**每一次都在 VM 里抛**
10+
11+
这比普通的 declared ≠ enforced 更毒一档,坏就坏在**构建期推断**:作者(尤其是 AI 作者)写下 `ctx.crypto.hash(...)`,提取器就替他把能力加进 `capabilities`,`os build` 因此全绿 —— 系统亲手把人送进一条必炸的死路,而唯一诚实的记录是文档表格里一句 `_(not yet wired)_`,没有作者会先读表格再写 body。
12+
13+
**裁决是 remove,不是实现**(维护者 2026-08-02):从未实现、调用即抛、**零投诉** —— 对一个每次使用都抛错的能力来说,这本身就是最强的活性证据,没人需要它。在沙箱里实现 crypto 会扩大沙箱的能力面与安全审查面,那是长期成本而非一次性工时,无业务拉动不做。真需要哈希时按能力准入流程重提:**实现先行,声明随实现走**(ADR-0049 的 enforce 腿留给有实现的那天)。
14+
15+
## FROM → TO
16+
17+
| 写了什么 | 现在怎么办 |
18+
| :--- | :--- |
19+
| `capabilities: ['crypto.hash']` | **删掉这个 token**。它从未授权成任何东西 |
20+
| `await ctx.crypto.hash(algo, data)` | **删掉这次调用**。它从未返回过值 —— 今天能跑的代码没有一行依赖它 |
21+
| 确实需要哈希 | 在 host 侧做(Connector recipe,或引擎侧 hook)。沙箱内哈希须走能力准入流程重开,实现先行 |
22+
23+
一句话修法:**两个都删**`os migrate meta --from 16` 会自动帮你剥掉 token;那行**死调用是你自己要删的** —— 转换层刻意不改 body 源码(见下)。
24+
25+
## 定级理由(逐条自证,未照抄前例)
26+
27+
三问按 #4535 §5 逐条走:
28+
29+
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 内清理。
30+
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 同侧。
31+
3. **形状变更?****枚举值收窄**(6 → 5),不是 key 移除。故**没有 `retiredKey()` 墓碑** —— `capabilities` 这个 key 本身依然活着、依然被强制。处方改由枚举自己的 error map 承载,并按 `object.managedBy: 'system'` 的先例**`issue.input` 为键**:只有「曾经合法」的那个拼写会被告知「was removed」,写错成 `crypto.hsah` 的作者拿到的仍是 zod 自己那条列出合法 token 的消息 —— 告诉他「你的值被退役了」属于误导。
32+
33+
`@objectstack/cli``@objectstack/runtime` 同定 **major**:前者 `ExtractedBody.capabilities` 的公开联合类型收窄(赋值给它的代码 TS2322),后者 `ScriptContext.crypto` 少一个成员(TS2339)。
34+
35+
## 门禁实报
36+
37+
枚举值收窄对四张 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 仍活,不产生台账行变更)。
38+
39+
也就是说:**本次移除没有任何一张基线能自动兜住它** —— 兜住它的只有本 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`,枚举选项随之少一项)。
40+
41+
## 转换刻意不做的事
42+
43+
`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` 改写作者源。

content/docs/automation/hook-bodies.mdx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,10 +100,28 @@ The script sees only what the surrounding `ctx` object exposes:
100100
| `ctx.api.object(name).find\|count\|aggregate` | Cross-object reads, scoped to current tenant. | `api.read` |
101101
| `ctx.api.object(name).insert\|update\|delete` | Cross-object writes. | `api.write` |
102102
| `ctx.crypto.randomUUID()` | UUID generation. | `crypto.uuid` |
103-
| `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` |
104103
| `ctx.log.{info,warn,error}` | Structured logging. | `log` |
105104
| `ctx.connector(name).<method>(...)` _(planned)_ | Outbound HTTP / SaaS calls. **Not yet wired into the sandbox** — ships with the separate Connector spec. | (separate Connector spec) |
106105

106+
<Callout type="warn">
107+
**There is no hashing capability — `crypto.hash` was removed in spec 17.** Until
108+
17 the `crypto.hash` token was declared in `HookBodyCapability`, listed in this
109+
table, typed on `ScriptContext` and *auto-inferred by the build-time extractor*
110+
but the sandbox never installed the function. Every call the token authorised
111+
threw inside the VM, while `os build` reported success precisely because writing
112+
`ctx.crypto.hash(...)` is what made the CLI grant the capability. All four
113+
declarations were removed together in #4391: declaring `crypto.hash` is now a
114+
parse error that explains this, and writing the call no longer earns a capability.
115+
116+
**If you declared it:** delete the token from `capabilities` **and** delete the
117+
`ctx.crypto.hash(...)` call — the call has never returned a value, so nothing
118+
that works today depends on it. `os migrate meta --from 16` strips the token for
119+
you; the dead call is yours to remove. Hash in the host instead (a Connector
120+
recipe, or an engine-side hook). Hashing inside the sandbox comes back only
121+
*with* an implementation, via the capability admission process — the declaration
122+
follows the implementation, it never leads it.
123+
</Callout>
124+
107125
### What the sandbox forbids
108126

109127
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
296314
| `*.object(…).find / findOne / count / aggregate / get / list` | `api.read` |
297315
| `*.object(…).insert / update / upsert / delete / patch / remove / create` | `api.write` |
298316
| `ctx.crypto.randomUUID` | `crypto.uuid` |
299-
| `ctx.crypto.hash` | `crypto.hash` |
300317
| `ctx.log.info / warn / error / debug` | `log` |
301318

302319
To override the inference, add a directive comment as the first line of your handler body:

content/docs/references/data/hook-body.mdx

Lines changed: 2 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,34 +5,6 @@ description: Hook Body protocol schemas
55

66
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
77

8-
Capability tokens a script body may request.
9-
10-
The runtime sandbox enforces these — if a body uses a `ctx` API that requires
11-
12-
a capability it did not declare, the call throws at invocation time.
13-
14-
- `api.read``ctx.api.object(...).find / findOne / count / aggregate`
15-
16-
- `api.write``ctx.api.object(...).insert / update / delete`
17-
18-
- `api.transaction``ctx.api.transaction(async () => \{ … \})` — runs the
19-
20-
callback's `ctx.api` writes/reads inside one driver transaction, committed
21-
22-
on return and rolled back if the callback throws. Requires `api.write`
23-
24-
alongside it to be useful (the transaction body still needs write access).
25-
26-
- `crypto.uuid``ctx.crypto.randomUUID()`
27-
28-
- `crypto.hash``ctx.crypto.hash(algo, data)`
29-
30-
- `log``ctx.log.info / warn / error`
31-
32-
`http.fetch` is intentionally absent — outbound calls go through Connector
33-
34-
recipes (separate spec) so they remain auditable and replayable.
35-
368
<Callout type="info">
379
**Source:** `packages/spec/src/data/hook-body.zod.ts`
3810
</Callout>
@@ -94,7 +66,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
9466
| :--- | :--- | :--- | :--- |
9567
| **language** | `'js'` || |
9668
| **source** | `string` || Function body source |
97-
| **capabilities** | `Enum<'api.read' \| 'api.write' \| 'api.transaction' \| 'crypto.uuid' \| 'crypto.hash' \| 'log'>[]` || Granted capability tokens |
69+
| **capabilities** | `Enum<'api.read' \| 'api.write' \| 'api.transaction' \| 'crypto.uuid' \| 'log'>[]` || Granted capability tokens |
9870
| **timeoutMs** | `integer` | optional | Per-invocation timeout (ms) |
9971
| **memoryMb** | `integer` | optional | Per-invocation memory cap (MB) |
10072

@@ -111,7 +83,6 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
11183
* `api.write`
11284
* `api.transaction`
11385
* `crypto.uuid`
114-
* `crypto.hash`
11586
* `log`
11687

11788

@@ -127,7 +98,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
12798
| :--- | :--- | :--- | :--- |
12899
| **language** | `'js'` || |
129100
| **source** | `string` || Function body source |
130-
| **capabilities** | `Enum<'api.read' \| 'api.write' \| 'api.transaction' \| 'crypto.uuid' \| 'crypto.hash' \| 'log'>[]` || Granted capability tokens |
101+
| **capabilities** | `Enum<'api.read' \| 'api.write' \| 'api.transaction' \| 'crypto.uuid' \| 'log'>[]` || Granted capability tokens |
131102
| **timeoutMs** | `integer` | optional | Per-invocation timeout (ms) |
132103
| **memoryMb** | `integer` | optional | Per-invocation memory cap (MB) |
133104

content/docs/references/ui/action.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ const result = ActionSchema.parse(data);
8686
| **type** | `Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api' \| 'form'>` | optional | Action functionality type |
8787
| **target** | `string` | optional | URL, Script Name, Flow ID, or API Endpoint. Supports $`{param.X}` and $`{ctx.X}` interpolation. |
8888
| **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). |
89-
| **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`. |
89+
| **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`. |
9090
| **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. |
9191
| **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string; … }[]` | optional | Input parameters required from user |
9292
| **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) |

docs/protocol-upgrade-guide.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,8 @@ The object capability block closes out the same ADR-0049 pass: `enable.trash` an
180180

181181
The same enforce-or-remove pass retires the `RestServerConfig.openApi31` block (#4579): `OpenApi31ExtensionsSchema` (`webhooks` / `callbacks` / `jsonSchemaDialect` / `pathItemReferences`) with `OpenApiWebhookEventSchema` and `CallbackSchema` under it. Declared-but-unenforced end to end: the REST server's `normalizeConfig` forwards only `api`/`crud`/`metadata`/`batch`/`routes`, the served /openapi.json is the pre-generated contract enriched with the live server URL and registered objects, and `gen:openapi` never read a webhook or callback — so a definition authored under `openApi31.webhooks` never appeared in any served document, and zero import-level consumers existed across objectstack / cloud / objectui. `RestServerConfig` is plugin TS configuration (the REST plugin constructor / `plugin-hono-server` `restConfig`), never a stored metadata shape: the stack tree's own `api` block declares only its four scoping/auth knobs, so no `sys_metadata` row can carry `openApi31` and there is no source for the chain to rewrite — one semantic TODO for config authors rather than a stack conversion, the `validateOnly` shape. The key itself is tombstoned (the schema is not `.strict()`; a plain delete would strip it silently), and a config-driven webhooks/callbacks synthesis, if ever wanted, returns via the enforce route of ADR-0049 through a new ADR.
182182

183+
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.
184+
183185
### Mechanical (applied for you)
184186

185187
| Conversion | Surface | Change | Load window |
@@ -220,6 +222,7 @@ The same enforce-or-remove pass retires the `RestServerConfig.openApi31` block (
220222
| `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 |
221223
| `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 |
222224
| `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 |
225+
| `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 |
223226

224227
### Semantic (delegated to you, with acceptance criteria)
225228

packages/cli/src/utils/extract-hook-body.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ const FORBIDDEN_PATTERNS: Array<{ rx: RegExp; reason: string }> = [
4040
{ rx: /\bnew\s+Function\s*\(/, reason: '`new Function()` is not allowed in hook/action bodies' },
4141
];
4242

43-
const CAPABILITY_PATTERNS: Array<{ rx: RegExp; cap: 'api.read' | 'api.write' | 'crypto.uuid' | 'crypto.hash' | 'log' }> = [
43+
const CAPABILITY_PATTERNS: Array<{ rx: RegExp; cap: 'api.read' | 'api.write' | 'crypto.uuid' | 'log' }> = [
4444
// Match `ctx.api.object(...)` directly OR a local alias like
4545
// `const api = ctx.api;` then `api.object(...)`. We accept any
4646
// identifier (or chain) ending in `.object(...)` followed by a known
@@ -49,15 +49,18 @@ const CAPABILITY_PATTERNS: Array<{ rx: RegExp; cap: 'api.read' | 'api.write' | '
4949
{ rx: /\.object\s*\([^)]+\)\s*\.\s*(?:find|findOne|count|aggregate|get|list)\b/, cap: 'api.read' },
5050
{ rx: /\.object\s*\([^)]+\)\s*\.\s*(?:insert|update|upsert|delete|patch|remove|create)\b/, cap: 'api.write' },
5151
{ rx: /ctx\.crypto\.randomUUID\b/, cap: 'crypto.uuid' },
52-
{ rx: /ctx\.crypto\.hash\b/, cap: 'crypto.hash' },
52+
// NO `ctx.crypto.hash` pattern: the `crypto.hash` token was removed in spec 17
53+
// (#4391) because the sandbox never installed the function. Inferring a
54+
// capability from a call that always threw is what let `os build` bless a
55+
// dead body — the inference was the amplifier, not the safety net.
5356
{ rx: /ctx\.log\.(?:info|warn|error|debug)\b/, cap: 'log' },
5457
];
5558

5659
export interface ExtractedBody {
5760
/** Pure function-body source (without the surrounding `(ctx) => {...}`). */
5861
source: string;
5962
/** Inferred capability tokens — may be merged with explicit `// @capabilities` line. */
60-
capabilities: Array<'api.read' | 'api.write' | 'crypto.uuid' | 'crypto.hash' | 'log'>;
63+
capabilities: Array<'api.read' | 'api.write' | 'crypto.uuid' | 'log'>;
6164
/** True when source is a single expression (arrow with implicit return). */
6265
isExpression: boolean;
6366
}
@@ -120,7 +123,6 @@ export function extractHookBody(fn: (...a: unknown[]) => unknown, originLabel: s
120123
t === 'api.read' ||
121124
t === 'api.write' ||
122125
t === 'crypto.uuid' ||
123-
t === 'crypto.hash' ||
124126
t === 'log'
125127
) {
126128
inferred.add(t);

0 commit comments

Comments
 (0)