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. (Seeopenspec/AGENTS.mdSection 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/*"]andpermissions.fetch = ["api.stripe.com"] - THEN
PermissionPolicyexposes 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 againstpermissions.fs = ["./uploads/*"] - THEN the result is
Allow
Scenario: denied path emits metric and error
- WHEN
checkFs("./etc/passwd", "read")is called againstpermissions.fs = ["./uploads/*"] - THEN the result is
Deny<"path-not-in-allowlist">ANDplugin.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 againstpermissions.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 againstpermissions.fetch = ["*.stripe.com"] - THEN the result is
AllowANDcheckFetch("https://stripe.com/...")against the same rule isDeny<"host-not-in-allowlist">
Scenario: denied scheme
- WHEN
checkFetch("http://api.stripe.com")is called against a rule without thehttp:prefix - THEN the result is
Deny<"scheme-not-permitted">AND the denial metric increments withreason="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 declaringdatabase.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 declaringsecrets = [{ name: "STRIPE_SECRET_KEY" }] - THEN the result is
Deny<"secret-not-declared">AND the denial metric increments withcapability="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 = 30000and the request elapsed reaches 30001ms on the suppliedClock - THEN
BudgetTracker.tick()raisesBUDGET_EXCEEDEDwithdetails.budget = "wallClockMs"ANDplugin.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
SandboxPermissionErrorhascode === "SAND_PERM.DENIED"ANDcontext = { capability, reason }(the redaction-safe wire envelope) ANDmessagedoes 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"