Initial Commit
This commit is contained in:
52
openspec/archive/2026-05-26-api-sandbox-wasm/proposal.md
Normal file
52
openspec/archive/2026-05-26-api-sandbox-wasm/proposal.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# `@suaveplan/api-sandbox-wasm` — WASI sandbox host
|
||||
|
||||
**Package**: `@suaveplan/api-sandbox-wasm`
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package
|
||||
**Stage**: 8 — see umbrella `openspec/changes/api-plugin-foundation/`
|
||||
|
||||
## Why
|
||||
|
||||
Sandboxed-tier plugins compiled to WebAssembly with the WASI ABI need a host that can load them with the narrowest possible capability set. WASI's capability model is at the file-descriptor and socket level, which lines up exactly with the manifest's `permissions.fs[]` (preopens) and `permissions.fetch[]` (outbound-socket allowlist). This package owns that translation.
|
||||
|
||||
Per design doc §2.2 and the §14 open question on WASI granularity, the reference answer is: **read-only fs preopens of every listed path + outbound socket allowlist derived from `permissions.fetch[]`**. Writeable preopens require an explicit `rw:` rule prefix. This package locks that decision down.
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `createWasmSandbox({ wasmModulePath, policy, proxies })` — instantiates the WASM module with a WASI snapshot wired to a `wasi_snapshot_preview1` (or preview2) implementation built from the `PermissionPolicy`. Returns a `WasmSandboxHandle`.
|
||||
- ADDED: `buildWasiPreopens(policy)` — maps `permissions.fs[]` to read-only preopen directories; `rw:` rules become read-write preopens. Paths outside the allowlist are not preopened, so the guest cannot open them.
|
||||
- ADDED: `buildWasiOutboundAllowlist(policy)` — maps `permissions.fetch[]` to a sockets allowlist consulted before each outbound socket connect; non-matching destinations raise `PERMISSION_DENIED` and emit `plugin.permission_denied_total{capability="fetch"}`.
|
||||
- ADDED: `installCapabilityImports(sandbox, proxies)` — exposes capability proxies as WASI host functions (or component-model imports), every call permission-checked first.
|
||||
- ADDED: `runWasmSandboxConformance(label, makeSandbox)` (`./testing`) — conformance suite pinning WASI-specific behaviour (preopen scoping, capability import permission gating, dispose semantics).
|
||||
- ADDED: Error codes (`SAND_WASM_*`): `INSTANTIATION_FAILED`, `WASI_VERSION_UNSUPPORTED`, `OUTBOUND_DENIED`, `PREOPEN_DENIED`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- WASM build toolchain. This package consumes a pre-built `.wasm` file produced by the plugin author.
|
||||
- WASI preview2 component-model conversion utilities. The default supports preview1; preview2 is opt-in via host config.
|
||||
- Permission policy resolution (that's `api-sandbox-permissions`).
|
||||
- Worker spawning (that's `api-worker-host`).
|
||||
|
||||
## Source mapping
|
||||
|
||||
Genesis-original. Source of design intent: `docs/superpowers/specs/2026-05-13-api-plugin-foundation-design.md`.
|
||||
|
||||
| Requirement | Design doc section |
|
||||
|---|---|
|
||||
| WASI permission-model mapping | §2.2, §14 (resolved) |
|
||||
| Read-only preopens by default | §14 |
|
||||
| Outbound socket allowlist | §14, §5.2b |
|
||||
| `PERMISSION_DENIED` surfaced inside the plugin | §5.2b |
|
||||
|
||||
## Verification approach
|
||||
|
||||
- `src/sandbox/sandbox.test.ts` — instantiate a fixture `.wasm` (small WASI hello-world built in CI), assert preopens reflect `permissions.fs[]`, assert outbound allowlist reflects `permissions.fetch[]`.
|
||||
- `src/preopens/preopens.test.ts` — read-only vs `rw:` rule prefix matrix.
|
||||
- `src/outbound/outbound.test.ts` — allowed / denied destination matrix; denied connect raises `SAND_WASM_OUTBOUND_DENIED` and never opens a socket.
|
||||
- `src/sandbox/sandbox.spec-conformance.test.ts` — observability: denials emit `plugin.permission_denied_total` with the right labels; `PERMISSION_DENIED` reaches the guest.
|
||||
- `src/testing/run-wasm-conformance.test.ts` — self-test of the harness against the fixture.
|
||||
- `tests/dep-graph.test.ts` — runtime deps include `api-types`, `api-sandbox-permissions`, `error`, `logger`.
|
||||
|
||||
## Migration impact
|
||||
|
||||
None — additive only. `api-host` registers this when `host.json` `defaultSandbox = "wasm"`.
|
||||
@@ -0,0 +1,101 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: api-sandbox-wasm (api-sandbox-wasm)
|
||||
|
||||
**Package**: `@suaveplan/api-sandbox-wasm`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: WASM module instantiated with a permission-derived WASI snapshot
|
||||
|
||||
The package SHALL expose `createWasmSandbox({ wasmModulePath, policy, proxies })` that instantiates the WASM module against a WASI snapshot built from the `PermissionPolicy`. The instantiation MUST fail with `SAND_WASM_WASI_VERSION_UNSUPPORTED` for unsupported WASI versions and with `SAND_WASM_INSTANTIATION_FAILED` for any other instantiation error.
|
||||
|
||||
#### Scenario: instantiation honours the policy
|
||||
|
||||
- WHEN `createWasmSandbox` is called with a policy declaring `permissions.fs = ["./uploads/*"]`
|
||||
- THEN the resulting WASI snapshot exposes a preopen for `./uploads` AND no other directory
|
||||
|
||||
#### Scenario: unsupported WASI version rejected
|
||||
|
||||
- WHEN the WASM module imports `wasi_snapshot_preview0` (or any version not on the supported list)
|
||||
- THEN instantiation throws `SAND_WASM_WASI_VERSION_UNSUPPORTED`
|
||||
|
||||
**Verified by:** `src/sandbox/sandbox.test.ts::"instantiation honours the policy"` AND `src/sandbox/sandbox.test.ts::"rejects unsupported WASI version"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Filesystem access via read-only preopens by default
|
||||
|
||||
The package SHALL map `permissions.fs[]` to read-only WASI preopen directories. A rule prefix `rw:` is required to make a preopen read-write. Paths outside the allowlist MUST NOT be preopened so the guest cannot open them. Denials surface as `SAND_WASM_PREOPEN_DENIED` AND increment `plugin.permission_denied_total{capability="fs"}`.
|
||||
|
||||
#### Scenario: read-only by default
|
||||
|
||||
- WHEN `permissions.fs = ["./uploads/*"]` is given
|
||||
- THEN the preopen for `./uploads` is read-only AND a guest write into that preopen returns the WASI errno for read-only
|
||||
|
||||
#### Scenario: write access requires explicit prefix
|
||||
|
||||
- WHEN `permissions.fs = ["rw:./uploads/*"]` is given
|
||||
- THEN the preopen is read-write AND a guest write succeeds
|
||||
|
||||
**Verified by:** `src/preopens/preopens.test.ts::"read-only by default"` AND `src/preopens/preopens.test.ts::"rw prefix enables write"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Outbound socket allowlist gates network connects
|
||||
|
||||
The package SHALL refuse outbound socket connects whose destination host is not in `permissions.fetch[]`. The refusal MUST raise `SAND_WASM_OUTBOUND_DENIED` BEFORE any socket is opened. Denials increment `plugin.permission_denied_total{capability="fetch"}`.
|
||||
|
||||
#### Scenario: allowed destination connects through
|
||||
|
||||
- WHEN a guest connects to `api.stripe.com` against `permissions.fetch = ["api.stripe.com"]`
|
||||
- THEN the connect proceeds to the underlying socket
|
||||
|
||||
#### Scenario: denied destination never opens a socket
|
||||
|
||||
- WHEN a guest attempts to connect to `evil.example.com` against the same allowlist
|
||||
- THEN the connect raises `SAND_WASM_OUTBOUND_DENIED` AND `plugin.permission_denied_total{capability="fetch"}` increments AND no OS socket is created
|
||||
|
||||
**Verified by:** `src/outbound/outbound.test.ts::"allowed destination connects"` AND `src/outbound/outbound.test.ts::"denied destination never opens a socket AND increments denial metric"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Capability imports are permission-gated
|
||||
|
||||
The package SHALL expose capability proxies as WASI host imports (or component-model imports). Every imported function MUST consult `PermissionChecker` from `api-sandbox-permissions` before forwarding to the host proxy. Denied calls return a WASI errno and surface as `PERMISSION_DENIED` inside the guest.
|
||||
|
||||
#### Scenario: denied secret read returns errno
|
||||
|
||||
- WHEN guest calls the `secrets.get` import for a name not in the manifest
|
||||
- THEN the import returns the WASI errno for permission-denied AND the host adapter is never invoked
|
||||
|
||||
**Verified by:** `src/sandbox/sandbox.spec-conformance.test.ts::"denied capability import never reaches host adapter"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Conformance harness exported
|
||||
|
||||
The package SHALL export `runWasmSandboxConformance(label, makeSandbox)` on `./testing`. The in-package fixture sandbox MUST pass every scenario.
|
||||
|
||||
#### Scenario: fixture passes harness
|
||||
|
||||
- WHEN `runWasmSandboxConformance("self", makeFixtureSandbox)` runs
|
||||
- THEN every harness `it(...)` passes
|
||||
|
||||
**Verified by:** `src/testing/run-wasm-conformance.test.ts::"in-package fixture passes runWasmSandboxConformance"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Disposal is idempotent
|
||||
|
||||
The package SHALL ensure `WasmSandboxHandle.dispose()` tears down the WASM instance, closes every preopen file descriptor, drops the outbound socket allowlist reference, and is safe to call multiple times.
|
||||
|
||||
#### Scenario: dispose twice is a no-op
|
||||
|
||||
- WHEN `dispose()` is called twice
|
||||
- THEN the second call returns without error
|
||||
|
||||
**Verified by:** `src/sandbox/sandbox.spec-conformance.test.ts::"dispose is idempotent"`
|
||||
80
openspec/archive/2026-05-26-api-sandbox-wasm/tasks.md
Normal file
80
openspec/archive/2026-05-26-api-sandbox-wasm/tasks.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Tasks — `@suaveplan/api-sandbox-wasm`
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change.
|
||||
|
||||
## 0. Pre-flight (non-negotiable)
|
||||
|
||||
- [x] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
|
||||
- [x] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
|
||||
- [x] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read design doc (§2.2, §5.2b, §14)
|
||||
- [x] 1.2 Read `openspec/AGENTS.md` Sections 13 and 14
|
||||
- [x] 1.3 Draft `proposal.md`
|
||||
- [x] 1.4 Draft `specs/api-sandbox-wasm/spec.md`
|
||||
- [x] 1.5 Every requirement carries `**Verified by:**`
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] 2.1 Scaffold `packages/api-stack/api/api-sandbox-wasm/`
|
||||
- [x] 2.2 `src/sandbox/` — `createWasmSandbox`, `WasmSandboxHandle`
|
||||
- [x] 2.3 `src/preopens/` — `buildWasiPreopens(policy)` + `matchPreopen`
|
||||
- [x] 2.4 `src/outbound/` — `buildWasiOutboundAllowlist(policy)` + `gateOutboundConnect`
|
||||
- [x] 2.5 `src/imports/install-capability-imports/`
|
||||
- [x] 2.6 `src/error-codes/error-codes.ts` via `defineCodes`
|
||||
- [x] 2.7 `src/testing/run-wasm-conformance.ts`
|
||||
- [x] 2.8 Fixture WASM module builder under `tests/fixtures/build-wasm.ts` (hand-encoded valid `wasi_snapshot_preview1` module — no `wabt` build step)
|
||||
- [x] 2.9 `src/index.ts` — explicit named re-exports
|
||||
- [x] 2.10 `package.json` exports — `./testing`, `./sandbox`, `./preopens`, `./outbound`, `./error-codes`, `./otel` subpaths
|
||||
- [x] 2.11 `src/wasi/wasi.ts` — `wasi_snapshot_preview1` snapshot with policy-gated `path_open`
|
||||
- [x] 2.12 `src/policy/policy.ts` — `PermissionPolicy` structural type + `resolvePermissionPolicy` Zod boundary
|
||||
- [x] 2.13 `src/otel/otel.ts` — `plugin.permission_denied_total` counter wiring
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [x] 3.1 `src/sandbox/sandbox.test.ts` — 9 cases
|
||||
- [x] 3.2 `src/sandbox/sandbox.spec-conformance.test.ts` — denial + dispose-idempotent
|
||||
- [x] 3.3 `src/preopens/preopens.test.ts` — read-only / rw-prefix / dedup / matchPreopen
|
||||
- [x] 3.4 `src/outbound/outbound.test.ts` — allow / deny / wildcard / port-pinned
|
||||
- [x] 3.5 `src/testing/run-wasm-conformance.test.ts` — harness self-test passes
|
||||
- [x] 3.6 `tests/dep-graph.test.ts` — runtime deps pinned to allowlist
|
||||
- [x] 3.7 Every `it(...)` carries `{ timeout: 5000 }` (30000 for WASM instantiation)
|
||||
- [x] 3.8 `src/wasi/wasi.test.ts` — 13 cases covering every snapshot import path
|
||||
- [x] 3.9 `src/otel/otel.test.ts` — instrument creation + denial labels
|
||||
- [x] 3.10 `src/policy/policy.test.ts` — resolve / freeze / type-guard
|
||||
- [x] 3.11 `src/error-codes/error-codes.test.ts` — registry shape + frozen
|
||||
- [x] 3.12 `src/imports/install-capability-imports/install-capability-imports.test.ts` — allow / deny / async forwarding
|
||||
- [x] 3.13 `tests/fixtures/build-wasm.test.ts` — validator + magic-bytes pinning
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [x] 4.1 Sibling `.md` per implementation `.ts` (9 files, all ≥200 words)
|
||||
- [x] 4.2 `README.md` `## Modules`
|
||||
- [x] 4.3 Update `CLAUDE.md` package listing
|
||||
|
||||
## 5. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [x] 5.1 `bun run check` — `bunx biome check src/` returns clean
|
||||
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/api-sandbox-wasm` — green (87 tests pass)
|
||||
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/api-sandbox-wasm` — 100/100/100/100 across every src file
|
||||
- [x] 5.4 `bun scripts/openspec-conformance.ts packages/api-stack/api/api-sandbox-wasm` — PASS (0 error / 0 warn / 0 info)
|
||||
- [x] 5.5 `bunx turbo build --filter=@suaveplan/api-sandbox-wasm` — green
|
||||
|
||||
## 6. Pre-archive 7-row checklist
|
||||
|
||||
- [x] 6.1 `expectTypeOf<WasmSandboxHandle>` properties asserted in `sandbox.test.ts`
|
||||
- [x] 6.2 `resolvePermissionPolicy` runs the Zod schema at the boundary; an out-of-range cpuPercent surfaces a `ZodError`
|
||||
- [x] 6.3 Every `SAND_WASM_*` code thrown by a production path (verified by `openspec-conformance` gate)
|
||||
- [x] 6.4 Strict-mode: writable-preopen requires explicit `rw:` prefix (`preopens.test.ts::"rw prefix enables write"`)
|
||||
- [x] 6.5 OTel instruments asserted with a real-shape `MeterProvider` (`otel.test.ts`)
|
||||
- [x] 6.6 `dispose()` idempotent (`sandbox.spec-conformance.test.ts::"dispose is idempotent"` + harness pin)
|
||||
- [x] 6.7 No retroactive tick — every checkbox in this file marked at the moment the corresponding work landed
|
||||
|
||||
## 7. Archive
|
||||
|
||||
- [x] 7.1 Fold spec delta into `openspec/specs/api-sandbox-wasm/spec.md`
|
||||
- [x] 7.2 Move folder to `openspec/archive/2026-05-26-api-sandbox-wasm/`
|
||||
- [x] 7.3 Tick `8.3` in umbrella tasks (`openspec/changes/api-plugin-foundation/tasks.md`)
|
||||
- [x] 7.4 Refresh `MANIFEST.md`
|
||||
Reference in New Issue
Block a user