Files
suaveplan/openspec/specs/api-sandbox-permissions/spec.md
2026-05-27 23:29:53 +01:00

7.4 KiB

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"