Initial Commit
This commit is contained in:
129
openspec/specs/api-sandbox-permissions/spec.md
Normal file
129
openspec/specs/api-sandbox-permissions/spec.md
Normal file
@@ -0,0 +1,129 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec: api-sandbox-permissions (api-sandbox-permissions)
|
||||
|
||||
**Package**: `@suaveplan/api-sandbox-permissions`
|
||||
**Status**: SHIPPED (2026-05-26)
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
|
||||
|
||||
## 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 `SandboxPermissionError` has `code === "SAND_PERM.DENIED"` AND `context = { capability, reason }` (the redaction-safe wire envelope) AND `message` does not include the denied path or host
|
||||
|
||||
**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"`
|
||||
Reference in New Issue
Block a user