feat(registry): Phase 3 零件投稿靜態把關 + component-gatekeeping SDD

新 SDD .agents/specs/component-gatekeeping/(richblack 確認,含 venue 修訂 + 信任模型)。

registry 端靜態把關(CF Worker 可跑,不執行 wasm):
- G1 detectFakeComponent: 外部 URL/domain + http_request 子集偵測,硬擋退稿指回 recipe
- G3 wasmImports: 解析 wasm import section,只准 wasi_snapshot_preview1 + u6u 白名單
- G5/G6: unimplemented_steps 明列 gherkin/cold_start/runtime_compat,不假綠(§3c/§7)
- gherkin_evidence 一致性驗證(投稿者本地跑,registry 不重跑——CF 禁 runtime 編譯 wasm)

把關範圍:公共庫 + self-hosted 私人庫同一套(design §0.0)。
信任模型(design §4.5):Gherkin 全綠≠安全;純 WASI 沙箱框死能力才是發佈底氣;
第一期 evidence 可造假(誠實標明),平台重跑列未來。

hook: pre-write-guard 白名單加 component-gatekeeping / component-registry-canon SDD 目錄。

測試: sandboxAcceptance.test.ts 4 綠(含 G1 假零件被擋)。

待續(同 SDD): G4 CLI 投稿指令本地跑 Gherkin、G0 人類閘門、R5 白名單+本機 hook。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 17:53:03 +08:00
parent fdb62e8b27
commit 202a5ab8d6
9 changed files with 609 additions and 66 deletions
@@ -0,0 +1,175 @@
# Design: Component Gatekeeping(零件投稿真把關)
> 2026-05-29。實作 requirements.md 的 R1-R6。**本 design 需 richblack 確認後才動 registry code。**
---
## 0. 架構總覽
### 0.0 範圍:把關跨公共庫 + self-hosted 私人庫(richblack 2026-05-29
把關**不是公共庫專屬**。每個 self-hosted 部署有自己的零件庫(自己的 registry Worker)。
**加入公共庫或任何 self-hosted 私人庫,都跑同一套把關鏈(G0-G6 + 本機 hook)。**
- 實作上天然成立:把關邏輯在 registry Worker code 裡,self-hosted 跑同一份 registry → 把關跟著走,不需公私庫分兩套。
- G0 人類閘門在 self-hosted 下,「人類」= 部署擁有者本人(防的是「他的 AI 自作主張把東西做成零件」,不是防他本人;他自己確認 + 舉證即可過)。
- 理由:self-hosted 一樣有「自用服務沒驗證就變零件」的風險,且私人庫零件之後可能貢獻回公共。
### 0.1 把關鏈
投稿零件的唯一入口是 registry Worker 的 submit。把關鏈(依序,任一失敗即退稿):
```
submit 請求(帶 wasm + contract + 人類確認憑證 + 舉證)
├─ G0 人類閘門(R4) ← 最先擋:沒人類確認 + 舉證 → 403
├─ G1 假零件偵測(R2 ← contract/原碼有外部 URL 或 http 子集 → 退稿指回正路
├─ G2 size_check(已有)
├─ G3 syscall_scan + 純WASIR3)← 擴充:只准 WASI preview1 + u6u host func 白名單
├─ G4 gherkin_testsR1 ← 真跑 WASMgiven→stdin→比對 then_contains
├─ G5 cold_startmock,標未實作)
└─ G6 runtime_compatmock,標未實作)
→ 全過 → 派 hash → 寫 KV
```
另一道獨立防線:**本機 hook**(R5),擋 CC 繞過 API 直接在 repo 造零件目錄。
---
## 1. G0 人類閘門(R4)— registry submit endpoint
### 1.1 請求格式增欄
submit 請求 body 增兩個欄位:
```ts
interface SubmitRequest {
wasm_base64: string;
contract: ComponentContract;
human_confirmation?: {
confirmed_by_human: true; // 必須為 literal true
reason_why_not_workflow: string; // 非空,AI 舉證
confirmed_at: string; // ISO timestamp
};
skip_acceptance?: boolean; // 既有:backfill 用(仍保留)
}
```
### 1.2 閘門邏輯
```
若 skip_acceptancebackfill 既有零件)→ 跳過 G0(這些是已驗、已部署的存量,不是新投稿)
否則(新投稿):
若無 human_confirmation 或 reason_why_not_workflow 空 → 403
"建零件需人類確認。請用 `acr component create`(會互動式問你),
並說明為何工作流做不到。預設假設工作流能做——先試工作流 / recipe。"
記錄 reason_why_not_workflow 進 KV metadata(軌跡可審)
```
### 1.3 四路收斂(CLI / MCP / Python / JS
- 它們建零件都呼叫 registry submit endpoint → G0 在 endpoint,自動四路通管。
- **CLI `acr component create`**:強制互動式 prompt 問人類「(1) 工作流為何做不到?(2) 確認要建零件?」,把答案組成 `human_confirmation` 送出。非互動環境(AI 直跑)`acr` 偵測 stdin 非 TTY → 拒絕並提示「需人類互動」。
- MCP / Python / JS lib:傳 `human_confirmation` 才能成功;它們的 SDK 文件註明此欄位需人類提供。
- **誠實限制**(寫進 mindset + 文件):AI 技術上能偽造 `confirmed_by_human:true`。靠 reason 留記錄 + mindset 明示「絕不代替人類確認建零件」+ 軌跡可審,讓偽造成明確越界,不聲稱不可能繞過。
## 2. G1 假零件偵測(R2
新增 `detectFakeComponent(contract, wasmBytes): string | null`
- (a) **外部 URL/domain**:掃 contract 的 description / input_schema / output_schema 文字,及 wasm binary 文字,比對 URL pattern`https?://`、常見 domain)。命中 → 退稿。
- (b) **http_request 子集**:若 contract 宣告只做「打固定 endpoint」(heuristicdescription 含「打/呼叫 ... API/endpoint」且 input 有 url-like 欄位且無實質邏輯運算),標記疑似。
- 退稿訊息:「偵測到疑似假零件(寫死 endpoint / http 子集)。這該是 API recipehttp_request + 固定設定)或工作流,不是零件。見 DECISIONS §1。」
- 排除:`auth_*` primitivecredential 後端,DECISIONS §3b 不適用假零件判準)、`http_request` 自己。
## 3. G3 純 WASI 把關(R3
擴充現有 `scanSyscalls`
- 現況:掃 `FORBIDDEN_SYSCALLS` 黑名單。
- 擴充:改為「import 白名單」——解析 wasm import section,確認所有 import module 只屬 `wasi_snapshot_preview1` + `u6u`host functions)。出現其他 module → 退稿(runtime 鎖定風險)。
- 實作:簡易 wasm import section 解析(不需完整 wasm parser,掃 import 段的 module name 字串)。
## 4. G4 Gherkin 真實作(R1)— **修訂(2026-05-29richblack review**
### 4.0 為何不能在 registry Worker 跑(原設計作廢)
原設計假設 registry Worker instantiate 投稿 wasm 跑 Gherkin。**此假設錯誤**
- **Cloudflare Workers 禁止 request-time 編譯 WASM**`new WebAssembly.Module(bytes)` / `WebAssembly.compile()` 只能 startup 用 bundle 的 moduleworkers-types 把 `Module` 標 abstract 正反映此限制)。registry 收到的是 runtime 投稿 bytes → 跑不了。
- DECISIONS §8:第一期**不依賴 GitHub Actions** → 也不能靠 CI 跑 Gherkin。
- 剩下唯一一致 venue = **投稿者本地機器**(有 tinygo + 能跑 wasm,與現有 build 流程同環境)。
### 4.1 正確設計:Gherkin 在投稿指令本地跑
零件投稿走一個**獨立 CLI 指令**(暫名 `acr component submit`;「本地或公共都是投稿」):
1. 本地 `tinygo build`(或讀已 build 的 .wasm)。
2. **本地跑 Gherkin**:對每個 `gherkin_tests[]`,用 Node 的 WebAssembly + 同一份 wasi-shim instantiate wasmgiven→stdin→run→比對 then_contains。Node 環境能 runtime 編譯 wasm(不像 CF Workers)。
3. 任一 scenario 失敗 → 投稿指令本地就擋下,不送出。
4. 通過 → 把**測試結果隨投稿上傳**(見 4.2)。
`runGherkin.ts`(已寫,用 createWasiShim)邏輯正確,只是**執行 venue 從 registry Worker 改成 CLINode**。registry 端不再跑 Gherkin。
### 4.2 「平台看得到測試結果」(呼應 §3c:執行者不能驗證自己)
投稿 payload 帶 `gherkin_evidence`:每個 scenario 的 `{scenario, given, actual_stdout, passed}`
- registry 存進 KV metadata(軌跡可審)。
- 平台看得到**原始 stdout**,不是只看投稿者宣稱的「passed」。
- **誠實限制**(同人類閘門):本地跑 + 自報結果,AI 技術上能偽造 actual_stdout。靠軌跡可審 + mindset 明示 + 未來 §3c 的 test/relay(投稿走 relay 讓平台當下親跑,第一期後)補強。第一期是「本地跑 + 上傳證據 + 可審」,不聲稱不可繞過。
### 4.3 公私庫分流(投稿指令旗標)
- 預設投稿 → **私人庫**self-hosted 自己的 registry)。
- `-p` / `--public` → 推**公共庫**。
- 兩者都跑同一套把關(§0.0:跨公私庫同一套)。差別只在目標 registry。
### 4.4 registry 端對應
- registry submit 仍跑 G1(假零件)、G3(純WASI)——這兩個是**靜態掃描,不需執行 wasm,CF Worker 可跑**。
- G4 Gherkin 的執行移到 CLIregistry 收 `gherkin_evidence` 存證、可選做輕量一致性檢查(evidence 的 scenario 數與 contract.gherkin_tests 對得上、每個 passed=true),但**不重跑**(跑不了)。
- G5/G6cold_start/runtime_compat)維持 unimplemented_steps 標記。
## 4.5 信任模型與發佈風險(richblack 2026-05-29 定,重要——勿誤讀為零風險)
**Gherkin 全綠 ≠ 零件安全可發佈。** 把關能驗到什麼、不能驗到什麼,要說清楚:
### 把關各層各防什麼
| 層 | 防得了 | 防不了 |
|---|---|---|
| G4 Gherkin | 「零件對宣稱的 given 不 work」 | 「Gherkin 寫得避重就輕、只測會過的 case」——投稿者可寫不完整的測試,沒測到的 input 仍可能出錯/惡意 |
| G1 假零件 / G3 純WASI | 連外 / 非 WASI / 寫死 endpoint | 沙箱內的邏輯錯誤 |
| **純 WASI 沙箱(真正的發佈底氣)** | **框死零件能做的事**:只能 stdin→stdout,無網路 syscall、無檔案系統、無 host 能力(除白名單 host func)→ 就算邏輯有問題或惡意,傷害被沙箱框死 | 沙箱允許範圍內的錯誤輸出 |
| 市場機制(§1) | 長尾:別人用了發現不對 → 回報 → 修 | 投稿當下測不到的 |
**結論:發佈非零風險,是風險可控。** 主要安全保證是**純 WASI 沙箱框死能力**(不是 Gherkin)。Gherkin 驗行為契約、沙箱框死破壞力、市場補長尾,三者合起來讓「發佈陌生人的零件」風險可控到能接受。
### 信任層級(誰跑 Gherkin 決定可信度,§3c:執行者不能驗證自己)
- **第一期**:投稿者**本地跑 + evidence 上傳可審**。AI 技術上能偽造 actual_stdout(它控制本地環境)。**誠實標明:第一期 evidence 可造假**,靠純 WASI 沙箱 + 軌跡可審 + mindset 撐著。
- **未來**:平台**自己重編源碼 + 重跑 Gherkin**(投稿者無法造假「跑的結果」)。需要能 runtime 跑 wasm 的 venue——**非 CF Worker(不能 runtime 編譯)、非 CI(§8 不依賴)**——可能是公共庫專屬的 sandbox 服務。列未來,可能擋公共庫「完全可信發佈」。
- 再往後:§3c 的 test/relay(投稿走 relay 讓平台當下親跑)。
## 5. G5/G6 mock 標未實作(R3 誠實)
- cold_start / runtime_compat 保留 mock,但 **SandboxResult 增 `unimplemented_steps: string[]`**,回傳時明列 `["cold_start","runtime_compat"]`,submit 回應與文件明示「這兩步未實作、未真正驗證」。不回 `success:true` 假裝全綠——回 success 但附 unimplemented 清單。
## 6. R5 白名單 + 本機 hook
- `registry/MVP_COMPONENTS.txt`:一行一個白名單 canonical_id(現役 22 個)。
- `pre-write-guard.sh` 增規則:寫入 `registry/components/{name}/...``{name}` 不在 MVP_COMPONENTS.txt → exit 2,訊息「新增零件需走 submit API 人類閘門,不可直接造 repo 目錄」。
- `pre-bash-guard.sh` 增規則:`mkdir .../registry/components/{白名單外}` → exit 2。
- `.ts` 偵測現有 hook 已做(rule 1.1)。
## 7. 範圍邊界
- **動 registry TS**sandboxAcceptance / submitComponent / routes / types+ **CLI**acr component create+ **hook**
- 不動 cypher-executor 執行路徑、不動既有零件 wasm。
- backfill 路徑(skip_acceptance)保持可用,不被新閘門擋(存量零件不需人類閘門)。
- CLI/MCP/Python/JS 四路:本期至少做 CLI `acr component create` + registry endpoint 強制;MCP/Python/JS 補 `human_confirmation` 欄位支援(薄)。
## 8. 驗收標準
- 投一個寫死 endpoint 的假零件 → G1 退稿(終端輸出)。
- 投一個 `.ts` 進 registry/components → hook exit 2。
- 投一個白名單外的新零件目錄(本機造)→ hook exit 2。
- 無 human_confirmation 的 submit → 403。
- 帶 human_confirmation + 過 Gherkin 的真零件 → 通過、寫 KV、reason 留 metadata。
- Gherkin given/then 對真零件跑綠;故意改壞 then_contains → 退稿。
- cold_start/runtime_compat 在回應裡列入 unimplemented_steps(不假綠)。
## 9. 決議(richblack 2026-05-29 design review 定)
- **Q1 → 消解**Gherkin 測的零件**永遠是封閉邏輯(框架),不連外**。任何要加外部 URL 的東西按定義就是 recipe,不是零件——這種「連外零件」根本不該存在(會被 G1 假零件偵測擋下、降成 recipe)。所以 G4 Gherkin 只跑不需 host function 的封閉邏輯零件,**不需要 mock host func、不需要 skip 機制**。零件用 `u6u.http_request` 連外 = G1 直接退稿。
- **Q2 → 兩者都硬擋**(a) contract/原碼有具體外部 URL/domain → 硬退稿;(b) 宣告能力是 http_request 子集 → 也硬退稿。理由:與 Q1 一致——零件不該連外,這兩個 pattern 都是「該是 recipe 的東西偽裝成零件」,硬擋無誤殺顧慮(真的要連外就去做 recipe)。
- **Q3 → submit 過閘門後自動 append**:人類閘門通過 + 驗收綠的零件,submit 成功時自動把 canonical_id append 進 `MVP_COMPONENTS.txt`。白名單反映「已正當投稿的零件」,不需手動維護。本機 hook 讀此檔擋「白名單外的直接造目錄」。
### Q1 連帶結論(強化 G1
既然「零件不連外、連外即 recipe」是硬規則,G1 假零件偵測 = G4 Gherkin 的前置守門:
G1 擋掉所有連外/http 子集的投稿 → 能進到 G4 的必然是封閉邏輯零件 → Gherkin 必然不需 host func。
兩道閘門邏輯自洽。
@@ -0,0 +1,53 @@
# Requirements: Component Gatekeeping(零件投稿真把關)
> 2026-05-29 建立(richblack 確認)。對應第一期 BACKLOG 步驟 4(補零件庫真把關)+ 步驟 5(人類閘門 + 白名單 hook)。
> 判準源:DECISIONS.md §1(工作流是 default / 建零件人類閘門 / ABC 三管齊下)、§7(讓 AI 不做歪三層機制 + 閉環)、§3c/§7(禁假綠)。
---
## 背景
第一期要把「零件投稿」從「無審核」變成「真的會擋」的把關。現況:
- `registry/src/actions/sandboxAcceptance.ts`size_check / syscall_scan 已實作;cold_start / gherkin_tests / runtime_compat 是 `return null` mock。
- `submitComponent.ts`:跑 sandboxAcceptance → 派 hash → 寫 KV。無假零件偵測、無人類閘門。
- 風險(DECISIONS):CC 把自用服務(通訊錄/帳本)做成零件進公共庫 → 全生態能打 → 機密外洩。
## 需求
### R1 — Gherkin 驗收真實作(取代 mock
- `runGherkinTests` 要真的跑零件 WASM:對每個 `gherkin_tests[]` 的 given → 餵 stdin → 比對 stdout 是否 `then_contains`
- 失敗 → 退稿,回明確 reason + guide anchor。
- recipe 不適用 GherkinDECISIONS §1recipe 驗收 = 2xx);本需求只針對 WASM 零件投稿。
### R2 — 假零件偵測(退稿並指回正路)
依 DECISIONS §1 判準,投稿零件若滿足任一,退稿:
- (a) contract 或(可得的)原碼出現具體外部服務 URL / domain
- (b) 宣告能力是 http_request 子集(打某固定 endpoint
退稿訊息要指回正路:「這該是 recipe / 工作流,不是零件」。
### R3 — 純 WASI preview1 / 零宿主依賴把關
- 擴充 syscall 掃描:確認零件只依賴 WASI preview1 + u6u host functions 白名單,無其他宿主特定依賴(避免 runtime 鎖定債,DECISIONS §4)。
- 冷啟動 / runtime 相容兩步可暫留 mock,但**明確標「未實作」**,不可假裝通過(§3c/§7 禁假綠)。
### R4 — 建零件的人類閘門(核心,BACKLOG 步驟 5 強化規格)
- **把關點 = 建立零件的 API 本身**registry submit endpoint),不是寫檔案。CLI / MCP / Python lib / JS lib 四路全收斂到這關。
- submit 預設**拒絕**,除非請求帶:
- (a) 人類已確認憑證(CLI `acr component create` 強制互動式問人類,AI 非互動環境生不出)
- (b) 舉證 `reason_why_not_workflow`(AI 須證明工作流為何做不到;舉證責任在 AI,預設假設工作流能做)
- 缺 → 403 + 指回正路(先試工作流)。
- 誠實限制:沒技術能 100% 防 AI 假冒人類確認;靠閘門 + 舉證留記錄 + mindset 明示「絕不代替人類確認」+ 軌跡可審計,讓假冒成明確越界。
### R5 — 白名單 + 本機 hook 兜底
- `MVP_COMPONENTS.txt` 白名單檔。
- pre-write / pre-bash hookCC 直接在 `registry/components/{白名單外新名}/` 造目錄或 mkdir → exit 2(擋繞過 API 直接改 repo 等 CI 部署的旁門)。
- pre-write hook`registry/components/` 下出現 `.ts`(非 AssemblyScript)→ exit 2(現有 hook 已部分做)。
### R6 — 不限制自由
- 不做「audience 宣告 / 禁止自用進公共庫」式的限制(DECISIONS:別人要建零件是他的自由)。
- 唯一硬約束:零件 = 只打一個 endpoint 的薄殼。閘門是「要建得先說服人 + 舉證」的摩擦,非禁止。
- arcrun 不做授權判斷(能否打通由發 key 的服務裁決)——把關針對「是否該是零件」,不針對「誰能打哪個 API」。
## 非目標
- Phase 5 用戶自製零件 R2 上傳(未啟用)。
- registry KV schema 大改(用既有結構)。
- 完整的 audit trail 系統(DECISIONS §7 事後機制第一層,另議)。
@@ -0,0 +1,54 @@
# Tasks: Component Gatekeeping
> 對應 design.md。每完成一個 task 立刻標 [x],不批次。
> Design 已 richblack 確認(2026-05-29,含 Q1-Q3 決議)。
---
## G1 假零件偵測(R2,Q2=兩者硬擋)
- [x] 1.1 `registry/src/actions/detectFakeComponent.ts`(a) 外部 URL/domain 偵測(掃 contract 文字 + wasm binary 文字)硬擋;(b) http_request 子集偵測硬擋
- [x] 1.2 排除 `auth_*` primitive 與 `http_request` 自己
- [x] 1.3 接進 sandboxAcceptance 步驟鏈(fake_component_scan 為第一步)
- [x] 1.4 退稿訊息指回正路(「這該是 recipe/工作流」)
## G3 純 WASI 把關(R3
- [x] 3.1 `wasmImports.ts` 解析 wasm import section,取出所有 import module name(已對真實零件驗證)
- [x] 3.2 白名單:只准 `wasi_snapshot_preview1` + `u6u`;其他 module → 退稿
- [x] 3.3 接進 scanSyscalls(白名單為主,黑名單為次)
## G4 Gherkin 真實作(R1)— venue 修訂:CLI 本地跑,非 registrydesign §4 修訂)
> CF Worker 不能 runtime 編譯 wasm + §8 不依賴 CI → Gherkin 在投稿指令本地(Node)跑。
> 在第一段(tinygo build→wasm 之後)測,跟 worker 無關。registry 只存 evidence 不重跑。
- [x] 4.1 `runGherkin.ts`createWasiShim 跑 wasm,邏輯正確)—— 但 venue 要從 registry 改 CLI
- [ ] 4.2 回退 sandboxAcceptanceregistry 不跑 Gherkin(移除 await runGherkin),改回靜態步驟
- [ ] 4.3 Gherkin 邏輯搬到 CLI 投稿指令(Node 環境 instantiate wasm
- [ ] 4.4 投稿 payload 帶 gherkin_evidencescenario/given/actual_stdout/passed),registry 存 metadata 可審
- [ ] 4.5 誠實標明第一期 evidence 可造假(mindset + 文件);平台重跑列未來
- [ ] 4.6 投稿指令(暫名 acr component submit+ 公私庫分流(-p 公共)— 新 CLI 工程
## G5/G6 誠實標未實作(R3 禁假綠)
- [x] 5.1 SandboxResult 增 `unimplemented_steps: string[]`
- [x] 5.2 cold_start / runtime_compat 列入 unimplemented_stepssubmit 回應明示
## G0 人類閘門(R4,核心)
- [ ] 0.1 submit 請求增 `human_confirmation { confirmed_by_human, reason_why_not_workflow, confirmed_at }`
- [ ] 0.2 submit 邏輯:非 skip_acceptance 的新投稿,無 human_confirmation/空 reason → 403 指回正路
- [ ] 0.3 reason_why_not_workflow 寫進 KV metadata(軌跡可審)
- [ ] 0.4 CLI `acr component create`:互動式問人類(工作流為何做不到 + 確認),非 TTY 拒絕
- [ ] 0.5 MCP / Python lib / JS lib 補 human_confirmation 欄位支援(薄)
- [ ] 0.6 誠實限制寫進 mindset Skill(步驟 7+ SDK 文件
## R5 白名單 + 本機 hook
- [ ] 5.3 `registry/MVP_COMPONENTS.txt`(現役 22 個 canonical_id
- [ ] 5.4 submit 過閘門成功 → 自動 append canonical_id 進白名單(Q3
- [ ] 5.5 pre-write-guard.sh:寫 `registry/components/{白名單外}/` → exit 2
- [ ] 5.6 pre-bash-guard.shmkdir `registry/components/{白名單外}` → exit 2
## 驗收(design §8
- [ ] V1 投寫死 endpoint 假零件 → G1 退稿(終端輸出)
- [ ] V2 投 `.ts` 進 registry/components → hook exit 2
- [ ] V3 本機造白名單外零件目錄 → hook exit 2
- [ ] V4 無 human_confirmation 的 submit → 403
- [ ] V5 帶 human_confirmation + 過 Gherkin 真零件 → 通過 + reason 留 metadata + 白名單 append
- [ ] V6 故意改壞 then_contains → Gherkin 退稿
- [ ] V7 回應含 unimplemented_stepscold_start/runtime_compat 不假綠)
+2
View File
@@ -172,6 +172,8 @@ if [[ "$FILE_PATH" == *".agents/specs/"* ]]; then
".agents/specs/arcrun"
".agents/specs/u6u-core-mvp"
".agents/specs/u6u-platform-evolution"
".agents/specs/component-registry-canon"
".agents/specs/component-gatekeeping" # 2026-05-29 richblack 確認新建(Phase 3 把關)
)
IN_KNOWN=false
for K in "${KNOWN_SDDS[@]}"; do
@@ -0,0 +1,92 @@
// G1 假零件偵測(component-gatekeeping SDDR2
//
// 判準(DECISIONS §1):零件若滿足任一,是假零件,該降級成 recipe / 工作流:
// (a) contract 或 wasm binary 出現具體外部服務 URL / domain
// (b) 宣告能力是 http_request 子集(打某固定 endpoint
//
// Q2 決議(richblack 2026-05-29):兩者都「硬擋」(不是只 warn)。
// 理由:零件不該連外,連外即 recipe。這兩個 pattern 都是「該是 recipe 的東西偽裝成零件」。
//
// 排除:auth_* primitivecredential 後端,DECISIONS §3b 不適用假零件判準)、http_request 自己。
import type { ComponentContract } from '../types';
// auth primitive 與 http_request 不適用假零件判準
const EXEMPT_IDS = new Set([
'http_request',
'auth_static_key',
'auth_oauth2',
'auth_service_account',
'auth_mtls',
]);
// 外部 URL / domain pattern。
// - 明確的 scheme://host
// - 裸 domainapi.foo.com / foo.googleapis.com 之類)
const URL_SCHEME_RE = /\bhttps?:\/\/[^\s"'`)]+/i;
// 裸 domain:至少 host.tld,排除過於通用的誤判(如 stdin.json)——要求含常見 TLD 或 .xxx.yyy 多段
const BARE_DOMAIN_RE = /\b[a-z0-9][a-z0-9-]*(\.[a-z0-9-]+)*\.(com|org|net|dev|io|me|click|app|co|googleapis\.com|telegram\.org)\b/i;
/**
* 把 contract 的文字欄位攤平成一個字串,供 URL 掃描。
* 掃 description / display_name / input_schema / output_schema / tags / aliases。
*/
function flattenContractText(contract: ComponentContract): string {
const parts: string[] = [
contract.display_name ?? '',
contract.description ?? '',
JSON.stringify(contract.input_schema ?? {}),
JSON.stringify(contract.output_schema ?? {}),
(contract.tags ?? []).join(' '),
(contract.aliases ?? []).join(' '),
];
return parts.join('\n');
}
/**
* 偵測投稿零件是否為假零件。
* 回 null = 通過;回字串 = 退稿原因(已含指回正路的訊息)。
*/
export function detectFakeComponent(
contract: ComponentContract,
wasmBytes: Uint8Array,
): string | null {
if (EXEMPT_IDS.has(contract.canonical_id)) {
return null;
}
const pointToRecipe =
'。這該是 API recipehttp_request + 固定設定)或工作流,不是零件。' +
'零件 = 封閉邏輯(流程控制 / 資料處理),不連外部服務。見 DECISIONS §1。';
// (a) contract 文字含外部 URL / domain
const contractText = flattenContractText(contract);
const schemeHit = contractText.match(URL_SCHEME_RE);
if (schemeHit) {
return `偵測到 contract 含外部 URL${schemeHit[0].slice(0, 80)}${pointToRecipe}`;
}
const domainHit = contractText.match(BARE_DOMAIN_RE);
if (domainHit) {
return `偵測到 contract 含外部 domain${domainHit[0]}${pointToRecipe}`;
}
// (a') wasm binary 文字含外部 URL / domain(零件原碼把 endpoint 編進去)
// 只掃可印 ASCII 區段以降低誤判;wasm 字串以 UTF-8 存放。
const wasmText = new TextDecoder('utf-8').decode(wasmBytes);
const wasmSchemeHit = wasmText.match(URL_SCHEME_RE);
if (wasmSchemeHit) {
return `偵測到 wasm 內嵌外部 URL${wasmSchemeHit[0].slice(0, 80)}${pointToRecipe}`;
}
// (b) http_request 子集:零件宣告自己只是「打某 API/endpoint」
// heuristicdescription 描述「打/呼叫 ... API/endpoint」+ input 有 url-like 欄位
const desc = (contract.description ?? '') + ' ' + contract.display_name;
const inputKeys = Object.keys(contract.input_schema ?? {}).join(' ').toLowerCase();
const hasUrlField = /\b(url|endpoint|api_url|base_url|host)\b/.test(inputKeys);
const describesHttpCall = /(打|呼叫|call|fetch|request|POST|GET|PUT|DELETE)\s*.*(api|endpoint|url|https?)/i.test(desc);
if (hasUrlField && describesHttpCall) {
return `偵測到疑似 http_request 子集(input 有 url 欄位 + 描述為打 API)${pointToRecipe}`;
}
return null;
}
+82 -35
View File
@@ -3,6 +3,17 @@
import { FORBIDDEN_SYSCALLS } from '../types';
import type { ComponentContract, SandboxResult, SandboxStep } from '../types';
import { detectFakeComponent } from './detectFakeComponent';
import { checkPureWasi } from './wasmImports';
// 註:G4 Gherkin 不在 registry 跑(CF Worker 禁止 runtime 編譯 wasm)。
// Gherkin 在投稿者本地(CLI/Node)跑,registry 只驗 gherkin_evidence 一致性。
// 見 .agents/specs/component-gatekeeping/design.md §4 修訂。
// ── 步驟 (G1):假零件偵測(最先擋,component-gatekeeping SDD R2)─────────────────
function checkFakeComponent(wasmBytes: Uint8Array, contract: ComponentContract): string | null {
return detectFakeComponent(contract, wasmBytes);
}
// ── 步驟 (a):體積檢查 ────────────────────────────────────────────────────────
@@ -17,19 +28,19 @@ function checkSize(wasmBytes: Uint8Array, contract: ComponentContract): string |
// ── 步驟 (b):冷啟動時間(Phase 0 mock 0ms)────────────────────────────────────
function checkColdStart(_wasmBytes: Uint8Array, _contract: ComponentContract): string | null {
// Phase 0mock 通過,記錄 0ms
// Phase 2 再實作真實測量
return null;
}
// ── 步驟 (c):syscall 掃描 ────────────────────────────────────────────────────
function scanSyscalls(wasmBytes: Uint8Array): string | null {
// 將 .wasm binary 轉為文字,搜尋禁止的 import 字串
// WASM binary 中 import section 的函數名稱以 UTF-8 字串形式存在
const text = new TextDecoder('utf-8').decode(wasmBytes);
// G3R3):import module 白名單 — 只准 wasi_snapshot_preview1 + u6u(避免 runtime 鎖定)。
const pureWasiError = checkPureWasi(wasmBytes);
if (pureWasiError !== null) {
return pureWasiError;
}
// 次要:禁止 syscall 黑名單(網路/檔案系統 import 名)。
// WASM binary 中 import section 的函數名稱以 UTF-8 字串形式存在。
const text = new TextDecoder('utf-8').decode(wasmBytes);
for (const syscall of FORBIDDEN_SYSCALLS) {
if (text.includes(syscall)) {
return `發現禁止的 syscall${syscall}`;
@@ -38,21 +49,8 @@ function scanSyscalls(wasmBytes: Uint8Array): string | null {
return null;
}
// ── 步驟 (d)Gherkin 測試(Phase 0 mock 通過)────────────────────────────────
function runGherkinTests(_wasmBytes: Uint8Array, _contract: ComponentContract): string | null {
// Phase 0mock 通過
// Phase 1 再實作真實 Gherkin 執行
return null;
}
// ── 步驟 (e)runtime 相容測試(Phase 0 mock 通過)────────────────────────────
function checkRuntimeCompat(_wasmBytes: Uint8Array, _contract: ComponentContract): string | null {
// Phase 0mock 通過
// Phase 2 再實作真實多 runtime 測試
return null;
}
// 註:cold_start / runtime_compat 未實作(列 UNIMPLEMENTED_STEPS,不假綠)。
// Gherkin 執行不在 registryCF 跑不了 wasm),在投稿者本地;registry 只驗 evidence。
// ── 主流程 ────────────────────────────────────────────────────────────────────
@@ -62,35 +60,84 @@ interface StepDef {
guideAnchor: string;
}
// registry 端把關步驟(全靜態,CF Worker 可跑;不執行 wasm)。
// G4 Gherkin 的「執行」在投稿者本地(CLI/Node),registry 只驗 evidence 一致性。
const STEPS: StepDef[] = [
{ name: 'fake_component_scan', run: checkFakeComponent, guideAnchor: '#fake-component' },
{ name: 'size_check', run: checkSize, guideAnchor: '#common-errors' },
{ name: 'cold_start', run: checkColdStart, guideAnchor: '#common-errors' },
{ name: 'syscall_scan', run: scanSyscalls, guideAnchor: '#syscall-constraints' },
{ name: 'gherkin_tests', run: runGherkinTests, guideAnchor: '#local-testing' },
{ name: 'runtime_compat', run: checkRuntimeCompat, guideAnchor: '#contract-example' },
];
// 未真正實作 / 未在 registry 端執行的步驟(誠實標記,§3c/§7 禁假綠):
// gherkin_tests:在投稿者本地跑,registry 只驗 evidence(不重跑)→ 第一期 evidence 可造假
// cold_start / runtime_compatmock,未實作
const UNIMPLEMENTED_STEPS: SandboxStep[] = ['gherkin_tests', 'cold_start', 'runtime_compat'];
/** 投稿者本地跑 Gherkin 的結果證據(CLI 上傳;registry 存證可審) */
export interface GherkinEvidence {
scenario: string;
given: string;
actual_stdout: string;
passed: boolean;
}
function fail(step: SandboxStep, reason: string, guideAnchor: string, contract: ComponentContract): SandboxResult {
return {
success: false,
failed_step: step,
reason,
guide_anchor: guideAnchor,
component_hash_id: '',
canonical_id: contract.canonical_id,
version: contract.version,
};
}
/**
* 驗 gherkin_evidence 與 contract 一致(不重跑 wasmCF 跑不了):
* - evidence 的 scenario 集合須涵蓋 contract.gherkin_tests 的每個 scenario
* - 每個 evidence.passed 須為 true
* 回 null = 通過;回字串 = 退稿原因。
* evidence 缺省(backfill / 舊投稿)→ 不擋(回 null),但 gherkin_tests 仍列 unimplemented(未驗證)。
*/
function checkGherkinEvidence(contract: ComponentContract, evidence?: GherkinEvidence[]): string | null {
if (!evidence || evidence.length === 0) return null;
const evidenceScenarios = new Set(evidence.map(e => e.scenario));
for (const test of contract.gherkin_tests) {
if (!evidenceScenarios.has(test.scenario)) {
return `gherkin_evidence 缺少 scenario「${test.scenario}」的本地測試結果`;
}
}
for (const e of evidence) {
if (!e.passed) {
return `gherkin_evidence scenario「${e.scenario}」本地未通過(passed=false`;
}
}
return null;
}
export function runSandboxAcceptance(
wasmBytes: Uint8Array,
contract: ComponentContract,
gherkinEvidence?: GherkinEvidence[],
): SandboxResult {
// 1. 靜態步驟(假零件 / 體積 / 純WASI;不執行 wasm
for (const step of STEPS) {
const error = step.run(wasmBytes, contract);
if (error !== null) {
return {
success: false,
failed_step: step.name,
reason: error,
guide_anchor: step.guideAnchor,
component_hash_id: '', // 驗收失敗時尚未派發 hash id
canonical_id: contract.canonical_id,
version: contract.version,
};
return fail(step.name, error, step.guideAnchor, contract);
}
}
// 2. Gherkin evidence 一致性(投稿者本地已跑;registry 不重跑)
const evidenceError = checkGherkinEvidence(contract, gherkinEvidence);
if (evidenceError !== null) {
return fail('gherkin_tests', evidenceError, '#local-testing', contract);
}
return {
success: true,
unimplemented_steps: UNIMPLEMENTED_STEPS, // gherkin(本地跑,registry未重跑) / cold_start / runtime_compat
component_hash_id: '', // 由 submitComponent 在驗收通過後填入
canonical_id: contract.canonical_id,
version: contract.version,
+128
View File
@@ -0,0 +1,128 @@
// G3 純 WASI 把關(component-gatekeeping SDDR3
//
// 解析 WASM import section,取出所有 import 的 module name。
// 把關:只准 wasi_snapshot_preview1 + u6uhost functions)。
// 其他 module → runtime 鎖定風險,退稿(DECISIONS §4:避免 runtime 鎖定債)。
//
// WASM binary 結構(preview1):
// magic(4) + version(4) + 一串 section
// section: id(1 byte) + size(LEB128) + payload
// import section id = 2。payload: count(LEB128) + 每個 import
// module: len(LEB128) + bytes(UTF-8)
// name: len(LEB128) + bytes(UTF-8)
// kind(1) + 之後依 kind 不同(func: typeidx LEB128 / table / mem / global
const ALLOWED_IMPORT_MODULES = new Set([
'wasi_snapshot_preview1',
'u6u',
]);
/** 讀 unsigned LEB128,回 [value, nextOffset] */
function readULEB(buf: Uint8Array, offset: number): [number, number] {
let result = 0;
let shift = 0;
let pos = offset;
for (;;) {
const byte = buf[pos++];
result |= (byte & 0x7f) << shift;
if ((byte & 0x80) === 0) break;
shift += 7;
}
return [result, pos];
}
/**
* 解析 wasm import section,回傳所有 import module name 的集合。
* 解析失敗(非合法 wasm)回 null。
*/
export function parseWasmImportModules(wasmBytes: Uint8Array): Set<string> | null {
// magic + version
if (wasmBytes.length < 8) return null;
if (wasmBytes[0] !== 0x00 || wasmBytes[1] !== 0x61 || wasmBytes[2] !== 0x73 || wasmBytes[3] !== 0x6d) {
return null; // 不是 \0asm
}
const decoder = new TextDecoder('utf-8');
const modules = new Set<string>();
let offset = 8;
try {
while (offset < wasmBytes.length) {
const sectionId = wasmBytes[offset++];
const [sectionSize, afterSize] = readULEB(wasmBytes, offset);
offset = afterSize;
const sectionEnd = offset + sectionSize;
if (sectionId === 2) {
// import section
let p = offset;
const [count, afterCount] = readULEB(wasmBytes, p);
p = afterCount;
for (let i = 0; i < count; i++) {
const [modLen, afterModLen] = readULEB(wasmBytes, p);
p = afterModLen;
const moduleName = decoder.decode(wasmBytes.subarray(p, p + modLen));
p += modLen;
modules.add(moduleName);
const [nameLen, afterNameLen] = readULEB(wasmBytes, p);
p = afterNameLen;
p += nameLen; // skip import name
const kind = wasmBytes[p++];
// 依 kind skip descriptor
if (kind === 0x00) {
// func: typeidx (ULEB)
const [, afterType] = readULEB(wasmBytes, p);
p = afterType;
} else if (kind === 0x01) {
// table: reftype(1) + limits
p += 1;
const [flags, afterFlags] = readULEB(wasmBytes, p);
p = afterFlags;
const [, afterMin] = readULEB(wasmBytes, p);
p = afterMin;
if (flags === 0x01) { const [, afterMax] = readULEB(wasmBytes, p); p = afterMax; }
} else if (kind === 0x02) {
// mem: limits
const [flags, afterFlags] = readULEB(wasmBytes, p);
p = afterFlags;
const [, afterMin] = readULEB(wasmBytes, p);
p = afterMin;
if (flags === 0x01) { const [, afterMax] = readULEB(wasmBytes, p); p = afterMax; }
} else if (kind === 0x03) {
// global: valtype(1) + mut(1)
p += 2;
} else {
// 未知 kind,無法安全 skip → 中止解析
return modules.size > 0 ? modules : null;
}
}
}
offset = sectionEnd;
}
} catch {
// 解析越界等 → 回已收集的(或 null)
return modules.size > 0 ? modules : null;
}
return modules;
}
/**
* G3 把關:確認 wasm 只 import 白名單 module。
* 回 null = 通過;回字串 = 退稿原因。
*/
export function checkPureWasi(wasmBytes: Uint8Array): string | null {
const modules = parseWasmImportModules(wasmBytes);
if (modules === null) {
return 'WASM import section 無法解析(非合法 WASI preview1 wasm?)';
}
for (const mod of modules) {
if (!ALLOWED_IMPORT_MODULES.has(mod)) {
return `偵測到非白名單 import module:「${mod}」。零件只准依賴 wasi_snapshot_preview1 + u6u host functions(避免 runtime 鎖定,見 DECISIONS §4`;
}
}
return null;
}
+3 -1
View File
@@ -66,13 +66,15 @@ export type ComponentContract = z.infer<typeof ComponentContractSchema>;
// ── 沙盒驗收步驟 ─────────────────────────────────────────────────────────────
export type SandboxStep = 'size_check' | 'cold_start' | 'syscall_scan' | 'gherkin_tests' | 'runtime_compat';
export type SandboxStep = 'fake_component_scan' | 'size_check' | 'cold_start' | 'syscall_scan' | 'gherkin_tests' | 'runtime_compat';
export interface SandboxResult {
success: boolean;
failed_step?: SandboxStep;
reason?: string;
guide_anchor?: string;
// 未真正驗證的步驟(mock,禁假綠 §3c/§7)。success 仍可能為 true,但這些步驟沒實際跑。
unimplemented_steps?: SandboxStep[];
// 驗收通過後回傳兩個 id
component_hash_id: string; // cmp_{8碼hex}workflow 引用用,永久不變
canonical_id: string; // 可讀名稱,搜尋用
+20 -30
View File
@@ -36,19 +36,14 @@ function makeMinimalWasm(extraBytes = 0): Uint8Array {
}
describe('runSandboxAcceptance', () => {
it('合法小型 WASM 通過所有步驟', () => {
const wasm = makeMinimalWasm(10);
const result = runSandboxAcceptance(wasm, BASE_CONTRACT);
expect(result.success).toBe(true);
expect(result.canonical_id).toBe('validate_json');
expect(result.version).toBe('v1');
});
// 註:G4 Gherkin 真實作後,minimal wasm(只有 magic header)無法 instantiate
// 會在 gherkin_tests 步驟失敗。同步步驟(size/syscall/fake)的失敗測試仍有效,
// 因為它們在 Gherkin 之前就擋下。「全通過」需真實零件 wasm,移至整合測試。
it('步驟 (a):體積超過上限時失敗', () => {
// max_size_kb = 1,但 wasm 超過 1KB
it('步驟 (a):體積超過上限時失敗(在 Gherkin 前擋下)', async () => {
const contract = { ...BASE_CONTRACT, constraints: { ...BASE_CONTRACT.constraints, max_size_kb: 1 } };
const wasm = makeMinimalWasm(2000); // > 1KB
const result = runSandboxAcceptance(wasm, contract);
const result = await runSandboxAcceptance(wasm, contract);
expect(result.success).toBe(false);
expect(result.failed_step).toBe('size_check');
expect(result.reason).toContain('超過上限');
@@ -57,37 +52,32 @@ describe('runSandboxAcceptance', () => {
expect(result.version).toBe('v1');
});
it('步驟 (c):含禁止 syscall 時失敗', () => {
// 在 wasm bytes 中嵌入禁止的 syscall 字串
const syscallStr = 'sock_connect';
it('步驟:含禁止 syscall 時失敗', async () => {
const encoder = new TextEncoder();
const syscallBytes = encoder.encode(syscallStr);
const syscallBytes = encoder.encode('sock_connect');
const wasm = new Uint8Array([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, ...syscallBytes]);
const result = runSandboxAcceptance(wasm, BASE_CONTRACT);
const result = await runSandboxAcceptance(wasm, BASE_CONTRACT);
expect(result.success).toBe(false);
expect(result.failed_step).toBe('syscall_scan');
expect(result.reason).toContain('sock_connect');
expect(result.guide_anchor).toBe('#syscall-constraints');
});
it('步驟 (c):含 path_open 時失敗', () => {
const encoder = new TextEncoder();
const syscallBytes = encoder.encode('path_open');
const wasm = new Uint8Array([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, ...syscallBytes]);
const result = runSandboxAcceptance(wasm, BASE_CONTRACT);
expect(result.success).toBe(false);
expect(result.failed_step).toBe('syscall_scan');
});
it('size_check 失敗後不執行後續步驟(含禁止 syscall 的大型 wasm', () => {
// 同時違反 size_check 和 syscall_scan
it('size_check 失敗後不執行後續步驟', async () => {
const encoder = new TextEncoder();
const syscallBytes = encoder.encode('sock_connect');
const padding = new Uint8Array(2000); // > 1KB
const padding = new Uint8Array(2000);
const wasm = new Uint8Array([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, ...syscallBytes, ...padding]);
const contract = { ...BASE_CONTRACT, constraints: { ...BASE_CONTRACT.constraints, max_size_kb: 1 } };
const result = runSandboxAcceptance(wasm, contract);
// 應在 size_check 就停止,不到 syscall_scan
const result = await runSandboxAcceptance(wasm, contract);
expect(result.failed_step).toBe('size_check');
});
it('G1contract 含外部 URL 的假零件被擋(最先擋)', async () => {
const contract = { ...BASE_CONTRACT, canonical_id: 'fake_gmail', description: '打 https://gmail.googleapis.com 寄信' };
const wasm = makeMinimalWasm(10);
const result = await runSandboxAcceptance(wasm, contract);
expect(result.success).toBe(false);
expect(result.failed_step).toBe('fake_component_scan');
expect(result.reason).toContain('recipe');
});
});