# 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` 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"`