Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

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