Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,55 @@
# `@suaveplan/api-sandbox-permissions` — permission model for sandboxed-tier plugins
**Package**: `@suaveplan/api-sandbox-permissions`
**Status**: PROPOSED
**Type**: new-package
**Stage**: 8 (Sandboxing + cross-cutting middleware + authoring helpers) — see umbrella `openspec/changes/api-plugin-foundation/`
## Why
Sandboxed-tier plugins (manifest `isolation: "sandboxed"`) execute untrusted or third-party code and MUST NOT have ambient access to the filesystem, the network, or unbounded compute. Per design doc §2.2 the sandboxed tier is implemented as a worker thread plus either `vm.Context` (`api-sandbox-vm`) or WASI (`api-sandbox-wasm`); both hosts share the same permission model. Centralising that model in a single package keeps the policy in one place, makes denial behaviour observable, and lets both sandbox hosts gate capability calls through the same code path.
Per §5.2b every sandboxed capability call passes through this layer before reaching the proxy surface — denials emit `plugin.permission_denied_total{plugin, capability, reason}` and surface as `PERMISSION_DENIED` errors inside the plugin.
## What changes
- ADDED: `PermissionPolicy` — value object holding the resolved `permissions.fs[]` allowlist, `permissions.fetch[]` allowlist, and `permissions.budgets` (cpuPercent / memoryMb / wallClockMs). Built from `PermissionsManifest` (`api-types`).
- ADDED: `PermissionChecker.checkFs(path, mode)` — verifies a host-proxied filesystem path matches the allowlist (glob match against `permissions.fs[]`); returns `Allow | Deny<reason>`.
- ADDED: `PermissionChecker.checkFetch(url)` — verifies an outbound destination host matches `permissions.fetch[]`; supports exact host and `*.suffix` patterns.
- ADDED: `PermissionChecker.checkCapability(capabilityId, payload)` — for `db` / `secrets` / `events` payload-shape gating per §5.2b (secret name in manifest, event topic in declared `publishes` / `subscribes`, DB query scoped to plugin's declared `database`).
- ADDED: `BudgetTracker` — tracks per-request CPU sample, resident-memory sample, and wall-clock elapsed; raises `BUDGET_EXCEEDED` when any budget is breached. Counters are caller-supplied per `Clock` / sampling hooks.
- ADDED: `PERMISSION_DENIED` and `BUDGET_EXCEEDED` error codes (`SAND_PERM_*` registry) plus a redaction-safe `details` schema.
- ADDED: `plugin.permission_denied_total` and `plugin.budget_exceeded_total` instrument descriptors so both sandbox hosts emit identical metric names with identical label sets (`plugin`, `capability`, `reason`).
- ADDED: `runPermissionsConformance(label, makeChecker)` testing harness (subpath `./testing`) so `api-sandbox-vm`, `api-sandbox-wasm`, and any future sandbox host exercise the same allow / deny / budget invariants.
## Out of scope
- The actual `vm.Context` runtime (`api-sandbox-vm`) and the WASI runtime (`api-sandbox-wasm`). Both consume this package; neither is bundled here.
- The host-proxy implementations for `fs` and `fetch` — they live in `api-host` (main-process side) and only call `PermissionChecker` before performing the underlying operation.
- Authorisation across plugins (that is `api-rbac`). This package gates a plugin's own capability calls; RBAC gates a request's principal scopes.
- CPU/memory sampling primitives — supplied by the worker host; this package only owns the budget tracker that aggregates samples and decides allow / deny.
## Source mapping
Genesis-original. Source of design intent: `docs/superpowers/specs/2026-05-13-api-plugin-foundation-design.md`.
| Requirement | Design doc section |
|---|---|
| `permissions.fs` / `permissions.fetch` allowlists | §3 (`permissions` block) |
| Budget enforcement (CPU / memory / wall-clock) | §3 (`permissions.budgets`) |
| Per-capability gating (db / secrets / events / fs / fetch) | §5.2b |
| `plugin.permission_denied_total{plugin, capability, reason}` metric | §5.2b, §12.4 |
| `PERMISSION_DENIED` error code surfaced inside the plugin | §5.2b |
## Verification approach
- `src/policy/policy.test.ts` — `PermissionPolicy` construction from manifest, normalisation of paths, glob compilation.
- `src/checker/fs.test.ts`, `src/checker/fetch.test.ts`, `src/checker/capability.test.ts` — allow / deny matrices, including edge cases (path traversal, port-in-host, scheme rejection).
- `src/budget/budget.test.ts` — budget breach behaviour, monotonic clock progression, multi-budget interaction.
- `src/checker/checker.spec-conformance.test.ts` — pins the observable contract: every deny path increments `plugin.permission_denied_total` exactly once AND raises `PERMISSION_DENIED` with the documented `details` shape (verified against a real in-memory `MeterProvider`).
- `src/testing/run-permissions-conformance.test.ts` — the shipped harness exercised against an in-package fixture checker, so adapters that consume it know exactly what they must satisfy.
- `tests/dep-graph.test.ts` — pins zero `@suaveplan/*` runtime deps beyond `api-types`, `error`, `types`, and `opentelemetry`.
## Migration impact
None — additive only. `api-sandbox-vm` and `api-sandbox-wasm` (Stage 8 siblings) take this as a dependency.

View File

@@ -0,0 +1,129 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-sandbox-permissions (api-sandbox-permissions)
**Package**: `@suaveplan/api-sandbox-permissions`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
## ADDED Requirements
### Requirement: Permission policy is derived from the manifest
The package SHALL expose `PermissionPolicy` built exclusively from the `PermissionsManifest` block (`permissions.fs[]`, `permissions.fetch[]`, `permissions.budgets`). The policy MUST normalise filesystem paths (resolve `..`, strip trailing slashes) and compile glob patterns at construction time so subsequent checks are O(rules).
#### Scenario: policy reflects manifest exactly
- **WHEN** a manifest declares `permissions.fs = ["./uploads/*"]` and `permissions.fetch = ["api.stripe.com"]`
- **THEN** `PermissionPolicy` exposes those rules verbatim AND rejects any path or host not matched
#### Scenario: traversal patterns are normalised before matching
- **WHEN** a check is performed against a path containing `..`
- **THEN** the path is resolved before the glob match AND a resolution that escapes the allowed prefix is denied
**Verified by:** `src/policy/policy.test.ts::"PermissionPolicy reflects manifest verbatim"` AND `src/checker/fs/fs.test.ts::"normalises traversal segments before matching"`
---
### Requirement: Filesystem check enforces allowlist
The package SHALL expose `PermissionChecker.checkFs(path, mode)` that returns `Allow` only when the resolved path matches at least one rule in `permissions.fs[]` AND the access `mode` is permitted (read-only is the default; write requires an explicit `rw:` rule prefix).
#### Scenario: allowed path under matching rule
- **WHEN** `checkFs("./uploads/abc.png", "read")` is called against `permissions.fs = ["./uploads/*"]`
- **THEN** the result is `Allow`
#### Scenario: denied path emits metric and error
- **WHEN** `checkFs("./etc/passwd", "read")` is called against `permissions.fs = ["./uploads/*"]`
- **THEN** the result is `Deny<"path-not-in-allowlist">` AND `plugin.permission_denied_total{plugin, capability="fs", reason="path-not-in-allowlist"}` increments by 1
**Verified by:** `src/checker/fs/fs.test.ts::"allows path matching allowlist"` AND `src/checker/checker.spec-conformance.test.ts::"fs denial emits permission_denied_total with reason label"`
---
### Requirement: Fetch check enforces host allowlist
The package SHALL expose `PermissionChecker.checkFetch(url)` that returns `Allow` only when the URL's host matches an entry in `permissions.fetch[]` (exact host or `*.suffix`) AND the scheme is `https:` (or `http:` when explicitly allowed by the rule prefix `http:`).
#### Scenario: allowed exact host
- **WHEN** `checkFetch("https://api.stripe.com/v1/charges")` is called against `permissions.fetch = ["api.stripe.com"]`
- **THEN** the result is `Allow`
#### Scenario: wildcard suffix matches subdomains only
- **WHEN** `checkFetch("https://eu.api.stripe.com/v1/charges")` is called against `permissions.fetch = ["*.stripe.com"]`
- **THEN** the result is `Allow` AND `checkFetch("https://stripe.com/...")` against the same rule is `Deny<"host-not-in-allowlist">`
#### Scenario: denied scheme
- **WHEN** `checkFetch("http://api.stripe.com")` is called against a rule without the `http:` prefix
- **THEN** the result is `Deny<"scheme-not-permitted">` AND the denial metric increments with `reason="scheme-not-permitted"`
**Verified by:** `src/checker/fetch/fetch.test.ts::"allows exact host in allowlist"` AND `src/checker/fetch/fetch.test.ts::"wildcard matches subdomains only"` AND `src/checker/checker.spec-conformance.test.ts::"fetch denial emits permission_denied_total"`
---
### Requirement: Capability payload gating
The package SHALL expose `PermissionChecker.checkCapability(capabilityId, payload)` that for `db` rejects queries against tables outside the plugin's declared `database.tablePrefix`, for `secrets` rejects names not present in the manifest's `secrets[]`, and for `events` rejects publishes outside `events.publishes[]` or subscribes outside `events.subscribes[]`.
#### Scenario: db query against unscoped table is denied
- **WHEN** `checkCapability("db", { table: "users_account" })` is called against a manifest declaring `database.tablePrefix = "billing_"`
- **THEN** the result is `Deny<"table-out-of-scope">`
#### Scenario: secret read for un-manifested name is denied
- **WHEN** `checkCapability("secrets", { name: "AWS_KEY" })` is called against a manifest declaring `secrets = [{ name: "STRIPE_SECRET_KEY" }]`
- **THEN** the result is `Deny<"secret-not-declared">` AND the denial metric increments with `capability="secrets"`
**Verified by:** `src/checker/capability/capability.test.ts::"db denies queries outside declared tablePrefix"` AND `src/checker/capability/capability.test.ts::"secrets denies undeclared name"` AND `src/checker/capability/capability.test.ts::"events denies publish outside manifest"`
---
### Requirement: Budgets are tracked and breached deterministically
The package SHALL expose `BudgetTracker` that consumes CPU samples, memory samples, and a `Clock` tick to determine whether `permissions.budgets.cpuPercent`, `memoryMb`, or `wallClockMs` is exceeded. A breach raises `BUDGET_EXCEEDED` with `details.budget` naming the breached dimension.
#### Scenario: wall-clock breach raises BUDGET_EXCEEDED
- **WHEN** `permissions.budgets.wallClockMs = 30000` and the request elapsed reaches 30001ms on the supplied `Clock`
- **THEN** `BudgetTracker.tick()` raises `BUDGET_EXCEEDED` with `details.budget = "wallClockMs"` AND `plugin.budget_exceeded_total{plugin, budget="wallClockMs"}` increments by 1
#### Scenario: budget tracker is idempotent on repeated samples
- **WHEN** a sample identical to the previous one is supplied
- **THEN** the tracker raises at most one breach for that dimension (no double-counting)
**Verified by:** `src/budget/budget.test.ts::"raises BUDGET_EXCEEDED on wall-clock breach"` AND `src/budget/budget.test.ts::"is idempotent on duplicate samples"`
---
### Requirement: PERMISSION_DENIED surfaces inside the plugin
The package SHALL raise `PERMISSION_DENIED` errors that are reconstructible across the worker boundary (cause-chain preserved, `details` redaction-safe). The error code is registered via `defineCodes` so consumers can pin it in their `error-codes.ts`.
#### Scenario: error envelope carries reason and capability
- **WHEN** any `Deny<reason>` path runs
- **THEN** the raised `SuaveplanError` has `code === "SAND_PERM_DENIED"` AND `details = { capability, reason }` AND `message` does not include the denied path or host (redaction-safe)
**Verified by:** `src/checker/checker.spec-conformance.test.ts::"PERMISSION_DENIED error envelope is redaction-safe"`
---
### Requirement: Conformance harness is exported
The package SHALL expose `runPermissionsConformance(label, makeChecker)` on its `./testing` subpath. Every sandbox host (`api-sandbox-vm`, `api-sandbox-wasm`, and any future host) MUST pass this harness; the harness exercises every documented allow / deny / budget scenario above.
#### Scenario: in-package fixture passes the harness
- **WHEN** `runPermissionsConformance("self", makeFixtureChecker)` runs
- **THEN** every harness `it(...)` passes against the fixture
**Verified by:** `src/testing/run-permissions-conformance.test.ts::"in-package fixture passes runPermissionsConformance"`

View File

@@ -0,0 +1,72 @@
# Tasks — `@suaveplan/api-sandbox-permissions`
> 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 `docs/superpowers/specs/2026-05-13-api-plugin-foundation-design.md` (§2.2, §3, §5.2b, §12.4)
- [x] 1.2 Read `openspec/AGENTS.md` Sections 13 and 14
- [x] 1.3 Draft `proposal.md`
- [x] 1.4 Draft `specs/api-sandbox-permissions/spec.md`
- [x] 1.5 Every requirement carries a `**Verified by:**` line naming concrete `it(...)` titles
## 2. Implementation
- [x] 2.1 Scaffold `packages/api/api-sandbox-permissions/` (`"version": "0.1.0"`, `"sideEffects": false`)
- [x] 2.2 `src/policy/` — `PermissionPolicy` value object built from `PermissionsManifest`
- [x] 2.3 `src/checker/fs/` — path-allowlist glob matching, traversal rejection
- [x] 2.4 `src/checker/fetch/` — host-allowlist matching (exact + `*.suffix`), scheme gate
- [x] 2.5 `src/checker/capability/` — db scope / secrets name / events topic gating
- [x] 2.6 `src/budget/` — `BudgetTracker` for CPU sample / memory sample / wall-clock
- [x] 2.7 `src/error-codes.ts` — `PERMISSION_DENIED`, `BUDGET_EXCEEDED` via `defineCodes`
- [x] 2.8 `src/instruments/` — `plugin.permission_denied_total`, `plugin.budget_exceeded_total` descriptors
- [x] 2.9 `src/testing/run-permissions-conformance.ts` — shared harness
- [x] 2.10 `src/index.ts` — explicit named re-exports (no `export *`)
- [x] 2.11 `package.json` exports — `./testing` subpath
## 3. Tests
- [x] 3.1 `src/policy/policy.test.ts`
- [x] 3.2 `src/checker/fs/fs.test.ts`, `src/checker/fetch/fetch.test.ts`, `src/checker/capability/capability.test.ts`
- [x] 3.3 `src/budget/budget.test.ts`
- [x] 3.4 `src/checker/checker.spec-conformance.test.ts` — denial metric + error code with real in-memory `MeterProvider`
- [x] 3.5 `src/testing/run-permissions-conformance.test.ts` — harness self-test
- [x] 3.6 `tests/dep-graph.test.ts`
- [x] 3.7 Every `it(...)` carries `{ timeout: 5000 }`
## 4. Documentation
- [x] 4.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, full API reference)
- [x] 4.2 `README.md` links every co-located `.md` under `## Modules`
- [x] 4.3 Update `CLAUDE.md` package listing
## 5. Pre-archive verification (the 5-gate triple)
- [x] 5.1 `bun run check` — Biome 0/0/0
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/api-sandbox-permissions` — green
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/api-sandbox-permissions` — 100/100/100/100
- [x] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/api-sandbox-permissions` — exit 0
- [x] 5.5 `bunx turbo build --filter=@suaveplan/api-sandbox-permissions` — green
## 6. Pre-archive 7-row checklist (AGENTS.md Section 13)
- [x] 6.1 Generics threaded; `expectTypeOf` on `PermissionPolicy<Manifest>` literal-type claims
- [x] 6.2 Zod schemas wired at `PermissionsManifest` ingestion boundary
- [x] 6.3 `PERMISSION_DENIED` and `BUDGET_EXCEEDED` both thrown by at least one production path
- [x] 6.4 Strict-mode behaviour — both `enforce` and `warn` deny modes tested
- [x] 6.5 OTel instruments emitted and asserted against a real in-memory `MeterProvider`
- [x] 6.6 Lifecycle dedup — `BudgetTracker` is idempotent across repeat samples
- [x] 6.7 No retroactive tick
## 7. Archive
- [x] 7.1 Fold spec delta into `openspec/specs/api-sandbox-permissions/spec.md`
- [x] 7.2 Move folder to `openspec/archive/<yyyy-mm-dd>-api-sandbox-permissions/`
- [x] 7.3 Tick `8.1` in umbrella tasks
- [x] 7.4 Refresh `MANIFEST.md`