Initial Commit
This commit is contained in:
@@ -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.
|
||||
@@ -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"`
|
||||
72
openspec/archive/2026-05-26-api-sandbox-permissions/tasks.md
Normal file
72
openspec/archive/2026-05-26-api-sandbox-permissions/tasks.md
Normal 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`
|
||||
Reference in New Issue
Block a user