first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,99 @@
# @suaveplan/api-sandbox-permissions - Permission model for sandboxed-tier API plugins. PermissionPolicy + PermissionChecker (fs/fetch/capability) + BudgetTracker + PERMISSION_DENIED / BUDGET_EXCEEDED error codes + OTel denial / budget instrument descriptors + runPermissionsConformance harness. Consumed by api-sandbox-vm and api-sandbox-wasm.
Permission model for sandboxed-tier API plugins. Centralises the
`permissions.fs[]` / `permissions.fetch[]` allowlists, the
`permissions.budgets` enforcement, and the capability-payload gating
that both sandbox hosts (`api-sandbox-vm`, `api-sandbox-wasm`) require.
Per design doc §2.2 and §5.2b every sandboxed capability call passes
through this layer before reaching the worker capability proxy. A deny
increments `plugin.permission_denied_total{plugin, capability, reason}`
and surfaces inside the plugin as `SandboxPermissionError`
(`code: SAND_PERM.DENIED`).
<!-- begin:auto-docs -->
## Documentation tree
- [budget](./src/budget/budget.md) — This module owns the per-request resource-budget enforcement for the sandboxed tier. Sampling primitives (CPU percentag…
- [budget](./src/budget/budget.md) — This module owns the per-request resource-budget enforcement for the sandboxed tier. Sampling primitives (CPU percentag…
- [checker](./src/checker/checker.md) — This module is the composite entry point that the two sandbox hosts (`api-sandbox-vm`, `api-sandbox-wasm`) wire into th…
- [capability](./src/checker/capability/capability.md) — This module gates the three capability surfaces whose grain is finer than a single host-proxy can express: database acc…
- [capability](./src/checker/capability/capability.md) — This module gates the three capability surfaces whose grain is finer than a single host-proxy can express: database acc…
- [fetch](./src/checker/fetch/fetch.md) — This module owns the `permissions.fetch[]` enforcement path. Every outbound HTTP call routed through the sandbox host M…
- [fetch](./src/checker/fetch/fetch.md) — This module owns the `permissions.fetch[]` enforcement path. Every outbound HTTP call routed through the sandbox host M…
- [fs](./src/checker/fs/fs.md) — This module owns the `permissions.fs[]` enforcement path. Every filesystem capability call routed through the sandbox h…
- [fs](./src/checker/fs/fs.md) — This module owns the `permissions.fs[]` enforcement path. Every filesystem capability call routed through the sandbox h…
- [checker](./src/checker/checker.md) — This module is the composite entry point that the two sandbox hosts (`api-sandbox-vm`, `api-sandbox-wasm`) wire into th…
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable error-code registry for the `@suaveplan/api-sandbox-permissions` package. Every deny path r…
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable error-code registry for the `@suaveplan/api-sandbox-permissions` package. Every deny path r…
- [instruments](./src/instruments/instruments.md) — This module owns the OTel counter pair that every sandboxed-tier host emits on a deny / budget-breach path. Centralisin…
- [instruments](./src/instruments/instruments.md) — This module owns the OTel counter pair that every sandboxed-tier host emits on a deny / budget-breach path. Centralisin…
- [policy](./src/policy/policy.md) — This module owns the construction-time value object that holds the compiled allowlists derived from a `PermissionsManif…
- [policy](./src/policy/policy.md) — This module owns the construction-time value object that holds the compiled allowlists derived from a `PermissionsManif…
- [testing](./src/testing/run-permissions-conformance.md) — This module exports the `runPermissionsConformance(label, factory)` harness that every sandbox host (`api-sandbox-vm`,…
- [run-permissions-conformance](./src/testing/run-permissions-conformance.md) — This module exports the `runPermissionsConformance(label, factory)` harness that every sandbox host (`api-sandbox-vm`,…
<!-- end:auto-docs -->
## Features
- `PermissionPolicy` — compiled allowlists + resolved budgets, built
once at registration time. Zod-validated at the ingestion boundary.
- `PermissionChecker` — composite `checkFs / checkFetch / checkCapability`
plus `assertFs / assertFetch / assertCapability` for the
enforce-by-default surface. `mode: "warn"` for canary-phase reloads.
- `BudgetTracker` — per-request CPU / memory / wall-clock breach
detection. Idempotent across repeat samples on the same dimension.
- `SAND_PERM.*` error code registry: `DENIED`, `BUDGET_EXCEEDED`.
- OTel counter pair: `plugin.permission_denied_total`,
`plugin.budget_exceeded_total`.
- `runPermissionsConformance` harness on the `./testing` subpath —
every sandbox host MUST pass it.
## Modules
- [`src/policy/policy.md`](src/policy/policy.md) — `PermissionPolicy`,
glob compiler, Zod ingestion schema.
- [`src/checker/checker.md`](src/checker/checker.md) — composite
`PermissionChecker` + `assert*` helpers.
- [`src/checker/fs/fs.md`](src/checker/fs/fs.md) — pure `checkFs`.
- [`src/checker/fetch/fetch.md`](src/checker/fetch/fetch.md) — pure
`checkFetch`.
- [`src/checker/capability/capability.md`](src/checker/capability/capability.md)
— pure `checkCapability`.
- [`src/budget/budget.md`](src/budget/budget.md) — `BudgetTracker`.
- [`src/error-codes/error-codes.md`](src/error-codes/error-codes.md) —
`SAND_PERM.*` registry.
- [`src/instruments/instruments.md`](src/instruments/instruments.md) —
OTel counter descriptors.
- [`src/testing/run-permissions-conformance.md`](src/testing/run-permissions-conformance.md)
— shared host conformance harness.
## Quick start
```ts
import { PermissionChecker } from "@suaveplan/api-sandbox-permissions";
import { createPermissionInstruments } from "@suaveplan/api-sandbox-permissions/instruments";
import { PermissionPolicy } from "@suaveplan/api-sandbox-permissions/policy";
const policy = new PermissionPolicy({
permissions: {
fs: ["./uploads/*"],
fetch: ["api.stripe.com"],
budgets: { wallClockMs: 30_000 },
},
});
const instruments = createPermissionInstruments({ plugin: "billing" });
const checker = new PermissionChecker({ policy, instruments });
const result = checker.checkFetch("https://api.stripe.com/v1/charges");
if (result.outcome === "deny") {
// permission denied — host fabricates Deny envelope on the worker channel
}
```
## Dependencies
Runtime: `@suaveplan/api-types`, `@suaveplan/error`, `@suaveplan/types`,
`@suaveplan/opentelemetry`, `@opentelemetry/api`, `tslib`, `zod`. No
`@suaveplan/api-*` runtime beyond `api-types`.

View File

@@ -0,0 +1,108 @@
# `budget` — `BudgetTracker`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the per-request resource-budget enforcement for the
sandboxed tier. Sampling primitives (CPU percentage, resident memory,
wall-clock progress) are supplied by the worker host because they live
outside this package's dependency budget; this module aggregates
samples against the policy's `budgets` block and raises
`SandboxPermissionError(code: SAND_PERM.BUDGET_EXCEEDED)` on the first
newly-breached dimension.
The tracker is idempotent across repeat samples on the SAME dimension:
once a dimension has breached, subsequent ticks against that dimension
do NOT re-emit the deny counter or re-throw — the dimension is
considered "tripped" until the tracker is discarded at request end.
## Features
- `BudgetTracker.tick(sample)` — consumes one `{ cpuPercent, memoryMb }`
sample plus the wall-clock from the injected `Clock`.
- `BudgetTracker.elapsedMs()` — milliseconds elapsed since construction.
- Idempotent breaches — `cpuPercent`, `memoryMb`, `wallClockMs` each
emit at most once per tracker instance.
- Throws `SandboxPermissionError` with `code === SAND_PERM.BUDGET_EXCEEDED`
on the first newly-breached dimension and increments
`plugin.budget_exceeded_total{plugin, budget}`.
## Usage: Basic
```ts
import { BudgetTracker } from "@suaveplan/api-sandbox-permissions/budget";
import { createPermissionInstruments } from "@suaveplan/api-sandbox-permissions/instruments";
import { PermissionPolicy } from "@suaveplan/api-sandbox-permissions/policy";
const policy = new PermissionPolicy({
permissions: { budgets: { cpuPercent: 50, memoryMb: 256, wallClockMs: 30_000 } },
});
const instruments = createPermissionInstruments({ plugin: "billing" });
const clock = { now: () => Date.now() };
const tracker = new BudgetTracker({ policy, instruments, clock });
tracker.tick({ cpuPercent: 10, memoryMb: 64 }); // within budget — no throw
```
## Usage: Advanced
```ts
import { BudgetTracker } from "@suaveplan/api-sandbox-permissions/budget";
import { createPermissionInstruments } from "@suaveplan/api-sandbox-permissions/instruments";
import { PermissionPolicy } from "@suaveplan/api-sandbox-permissions/policy";
import { SAND_PERM_CODES, SandboxPermissionError } from "@suaveplan/api-sandbox-permissions/error-codes";
const policy = new PermissionPolicy({
permissions: { budgets: { wallClockMs: 30_000 } },
});
const instruments = createPermissionInstruments({ plugin: "billing" });
// Injectable Clock — drive the wall-clock breach deterministically.
let t = 0;
const clock = { now: () => t };
const tracker = new BudgetTracker({ policy, instruments, clock });
t = 30_001;
try {
tracker.tick({ cpuPercent: 0, memoryMb: 0 });
} catch (err) {
if (err instanceof SandboxPermissionError) {
console.assert(err.code === SAND_PERM_CODES.BUDGET_EXCEEDED);
// err.context = { capability: "budget", reason: "wallClockMs" }
}
}
// Subsequent ticks against the same dimension are no-ops — the
// dimension is already tripped.
t = 35_000;
tracker.tick({ cpuPercent: 0, memoryMb: 0 }); // returns silently
```
## API Reference
- `interface BudgetSample { cpuPercent: number; memoryMb: number; }`
- `interface BudgetTrackerOptions { policy; instruments; clock: Clock; }`
- `type BudgetBreach = "cpuPercent" | "memoryMb" | "wallClockMs"`
- `class BudgetTracker`
- `constructor(options)`
- `tick(sample): void` — throws on first newly-breached dimension.
- `elapsedMs(): number`
- `readonly policy: PermissionPolicy`
- `readonly instruments: PermissionInstruments`
## Implementation Notes
The `Clock` is injected per `@suaveplan/types` — never pulled from
`Date.now()` directly so test code can drive a deterministic timeline
without `vi.useFakeTimers()`. The constructor records `startedAt = clock.now()`
once; every subsequent `elapsedMs()` call subtracts from a fresh
`clock.now()` so wall-clock progress is monotonic with the clock.
`evaluate` is private and centralises the breach record so all three
dimensions go through the SAME counter + throw codepath; this guarantees
the conformance harness's "deny counter increments exactly once per
breach" invariant holds for every dimension without per-dimension
duplication.

View File

@@ -0,0 +1,117 @@
# `checker/capability` — payload-shape gating
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module gates the three capability surfaces whose grain is finer
than a single host-proxy can express: database access (table-prefix
scope), secret reads (declared name allowlist), and event topics
(publish / subscribe declarations). Per design doc §5.2b every
sandboxed capability call passes through this check before reaching
the host-side execution.
The check is payload-only — it does not run the query, read the secret,
or dispatch the event. It returns allow / deny so the host can decide
whether to fabricate a `Deny` envelope on the worker channel or throw
`SandboxPermissionError` on the main process.
## Features
- `checkCapability(policy, payload)` — discriminated-union dispatch:
- `payload.capability === "db"` checks `table` against
`database.tablePrefix`.
- `payload.capability === "secrets"` checks `name` against
`secrets[]`.
- `payload.capability === "events"` checks `topic` against
`events.publishes[]` or `events.subscribes[]` based on `mode`.
- Deny reasons are stable strings emitted on the
`plugin.permission_denied_total` counter so dashboards can slice by
`capability` label without re-parsing message text.
## Usage: Basic
```ts
import { PermissionPolicy } from "@suaveplan/api-sandbox-permissions/policy";
import { checkCapability } from "@suaveplan/api-sandbox-permissions/checker";
const policy = new PermissionPolicy({
database: {
required: true,
schemaVersion: 1,
tablePrefix: "billing_",
},
secrets: [{ name: "STRIPE_SECRET_KEY", required: true }],
events: {
publishes: ["billing.invoice.created"],
subscribes: ["users.profile.updated"],
},
});
checkCapability(policy, { capability: "db", table: "billing_invoices" });
// { outcome: "allow" }
checkCapability(policy, { capability: "secrets", name: "AWS_KEY" });
// { outcome: "deny", reason: "secret-not-declared" }
```
## Usage: Advanced
```ts
import { PermissionPolicy } from "@suaveplan/api-sandbox-permissions/policy";
import { checkCapability } from "@suaveplan/api-sandbox-permissions/checker";
import {
SAND_PERM_CODES,
SandboxPermissionError,
} from "@suaveplan/api-sandbox-permissions/error-codes";
const policy = new PermissionPolicy({
events: {
publishes: ["billing.invoice.created"],
subscribes: [],
},
});
function gatedPublish(plugin: string, topic: string): void {
const result = checkCapability(policy, {
capability: "events",
mode: "publish",
topic,
});
if (result.outcome === "deny") {
throw new SandboxPermissionError(
SAND_PERM_CODES.DENIED,
"events publish denied",
{ context: { capability: "events", reason: result.reason } }
);
}
}
```
## API Reference
- `checkCapability(policy: PermissionPolicy, payload: CapabilityPayload): CheckResult`
- `CapabilityPayload` discriminated union:
- `{ capability: "db"; table: string }`
- `{ capability: "secrets"; name: string }`
- `{ capability: "events"; mode: "publish"|"subscribe"; topic: string }`
- Deny `reason` values: `"db-not-declared"`, `"table-out-of-scope"`,
`"secret-not-declared"`, `"publish-not-declared"`,
`"subscribe-not-declared"`.
## Implementation Notes
A manifest that does not declare `database.tablePrefix` cannot use the
DB capability at all — every query denies with `"db-not-declared"`.
This is intentional: the DB capability is opt-in via manifest, not
implicit. Plugins that have no DB access should never see a deny here
because the host-side proxy refuses to wire the capability at all when
the manifest is missing `database`.
The discriminated-union dispatch means TypeScript narrows `payload`
inside each branch — there is no `as` cast or runtime instanceof check
needed. The `payload.mode` arm uses ternary instead of conditional
`if` so coverage hits both `publish` and `subscribe` branches in a
single test pair.

View File

@@ -0,0 +1,118 @@
# `checker/checker` — `PermissionChecker`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module is the composite entry point that the two sandbox hosts
(`api-sandbox-vm`, `api-sandbox-wasm`) wire into their worker capability
proxy. It binds a `PermissionPolicy` to a `PermissionInstruments` handle
and exposes the three production check methods plus three `assert*`
helpers that throw `SandboxPermissionError` in the default `"enforce"`
deny mode.
Construction is per plugin-worker pair — never global — because the
policy and instruments both pin the `plugin` label and the worker
boundary is the natural granularity for the check + telemetry pair.
## Features
- `PermissionChecker.checkFs / checkFetch / checkCapability` — invoke
the underlying pure function AND record the deny counter on a deny
outcome.
- `PermissionChecker.assertFs / assertFetch / assertCapability` — same
as above, but in `"enforce"` mode raise `SandboxPermissionError`
on deny; in `"warn"` mode return the deny result silently after
recording the counter.
- `DenyMode` — `"enforce"` (default) or `"warn"`.
- Deny telemetry — every deny path records exactly one
`plugin.permission_denied_total` increment with the documented
`{ plugin, capability, reason }` label set.
## Usage: Basic
```ts
import { PermissionChecker } from "@suaveplan/api-sandbox-permissions/checker";
import { createPermissionInstruments } from "@suaveplan/api-sandbox-permissions/instruments";
import { PermissionPolicy } from "@suaveplan/api-sandbox-permissions/policy";
const checker = new PermissionChecker({
policy: new PermissionPolicy({
permissions: { fs: ["./uploads/*"], fetch: ["api.stripe.com"] },
}),
instruments: createPermissionInstruments({ plugin: "billing" }),
});
const fs = checker.checkFs("./uploads/avatar.png", "read");
console.assert(fs.outcome === "allow");
```
## Usage: Advanced
```ts
import { PermissionChecker } from "@suaveplan/api-sandbox-permissions/checker";
import { createPermissionInstruments } from "@suaveplan/api-sandbox-permissions/instruments";
import { PermissionPolicy } from "@suaveplan/api-sandbox-permissions/policy";
import { SAND_PERM_CODES, SandboxPermissionError } from "@suaveplan/api-sandbox-permissions/error-codes";
// Canary mode — record denies on metrics, do not block the call.
const canary = new PermissionChecker({
policy: new PermissionPolicy({
permissions: { fs: ["./uploads/*"] },
}),
instruments: createPermissionInstruments({ plugin: "billing" }),
mode: "warn",
});
const warnResult = canary.assertFs("./etc/passwd", "read");
console.assert(warnResult.outcome === "deny");
// `plugin.permission_denied_total{plugin: "billing", capability: "fs",
// reason: "path-not-in-allowlist"}` incremented; no throw.
// Enforcing mode — throws on deny.
const strict = new PermissionChecker({
policy: canary.policy,
instruments: canary.instruments,
// mode: "enforce" — default
});
try {
strict.assertFs("./etc/passwd", "read");
} catch (err) {
if (err instanceof SandboxPermissionError) {
console.assert(err.code === SAND_PERM_CODES.DENIED);
}
}
```
## API Reference
- `class PermissionChecker`
- `constructor({ policy, instruments, mode? })`
- `readonly policy: PermissionPolicy`
- `readonly instruments: PermissionInstruments`
- `readonly mode: DenyMode`
- `checkFs(path, mode): CheckResult`
- `checkFetch(url): CheckResult`
- `checkCapability(payload): CheckResult`
- `assertFs(path, mode): CheckResult` (throws in enforce mode)
- `assertFetch(url): CheckResult`
- `assertCapability(payload): CheckResult`
- `type DenyMode = "enforce" | "warn"`
- `type CheckerCapabilitySource = "fs" | "fetch" | "db" | "secrets" | "events"`
## Implementation Notes
The class is deliberately stateless apart from its three readonly fields —
deny telemetry is delegated to the `PermissionInstruments` handle, which
is itself stateless behind the OTel counters. This means the checker is
safe to instantiate once per plugin-worker pair and share across every
in-flight capability call.
The `assert*` helpers wrap `check*` rather than the pure functions so
the counter increments exactly once per deny: the underlying pure
functions do not call `recordDenial`, only the checker methods do.
A deny-mode-`warn` call therefore records the same counter the
`enforce` mode would, which is what dashboards rely on to detect
canary-phase regressions.

View File

@@ -0,0 +1,94 @@
# `checker/fetch` — outbound host allowlist
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the `permissions.fetch[]` enforcement path. Every
outbound HTTP call routed through the sandbox host MUST pass through
`checkFetch` before the underlying `fetch(...)` runs. The matcher is
host-only — request bodies, methods, headers, and paths are NOT part of
this allowlist. The point is to bound which destinations a plugin can
exfiltrate to, not to model an HTTP firewall.
The scheme gate is part of the permission decision: by default only
`https:` is permitted, and the rule must be authored as `http:<host>`
to allow plaintext requests against that host (typically used only for
local development against `localhost`).
## Features
- `checkFetch(policy, url)` — returns allow / deny.
- Scheme-aware — `https:` always passes the protocol gate; `http:`
requires an explicit `http:` rule prefix.
- Wildcard suffix — a rule like `*.stripe.com` matches `eu.api.stripe.com`
but NOT the bare apex `stripe.com`.
- URL-parse failure returns `deny:invalid-url` rather than throwing.
## Usage: Basic
```ts
import { PermissionPolicy } from "@suaveplan/api-sandbox-permissions/policy";
import { checkFetch } from "@suaveplan/api-sandbox-permissions/checker";
const policy = new PermissionPolicy({
permissions: { fetch: ["api.stripe.com", "*.example.com"] },
});
checkFetch(policy, "https://api.stripe.com/v1/charges"); // allow
checkFetch(policy, "https://eu.example.com/api"); // allow
checkFetch(policy, "https://example.com"); // deny: host-not-in-allowlist
```
## Usage: Advanced
```ts
import { PermissionPolicy } from "@suaveplan/api-sandbox-permissions/policy";
import { checkFetch } from "@suaveplan/api-sandbox-permissions/checker";
import {
SAND_PERM_CODES,
SandboxPermissionError,
} from "@suaveplan/api-sandbox-permissions/error-codes";
const policy = new PermissionPolicy({
permissions: { fetch: ["http:localhost", "api.stripe.com"] },
});
function gatedFetch(plugin: string, url: string): Response | never {
const result = checkFetch(policy, url);
if (result.outcome === "deny") {
throw new SandboxPermissionError(
SAND_PERM_CODES.DENIED,
"fetch capability denied",
{ context: { capability: "fetch", reason: result.reason } }
);
}
// …perform the actual fetch on the host side…
return new Response();
}
gatedFetch("billing", "http://localhost:8080/dev"); // allowed
gatedFetch("billing", "https://api.stripe.com/v1/charges"); // allowed
```
## API Reference
- `checkFetch(policy: PermissionPolicy, url: string): CheckResult`
- `CheckResult = { outcome: "allow" } | { outcome: "deny"; reason: string }`
- `reason` is one of `"invalid-url"`, `"scheme-not-permitted"`,
`"host-not-in-allowlist"`.
## Implementation Notes
The host comparator lowercases both sides because the manifest is
operator-authored and case may differ from the request URL. The
wildcard form `*.suffix` matches only proper subdomains — the bare apex
must be authored as its own exact-match rule.
URL parse failures degrade to a deny rather than a throw so the host
worker boundary doesn't need an extra try/catch around the check call.
The deny reason `"invalid-url"` is observable on the
`plugin.permission_denied_total` counter exactly like every other deny
reason.

View File

@@ -0,0 +1,104 @@
# `checker/fs` — filesystem allowlist
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the `permissions.fs[]` enforcement path. Every
filesystem capability call routed through the sandbox host MUST pass
through `checkFs` before the underlying I/O runs. The check is
synchronous, deterministic, and O(rules) — never network, never disk,
no closure state beyond the compiled rule list on the policy.
The function is the only enforcement layer in the package that has to
defend against path-traversal: a manifest rule like `./uploads/*` is
intended to scope access to the uploads dir, and a request supplying
`./uploads/../etc/passwd` MUST resolve to `./etc/passwd` and be denied
even though the literal string still starts with `./uploads/`.
## Features
- `checkFs(policy, path, mode)` — returns `{ outcome: "allow" }`,
`{ outcome: "deny", reason: "path-not-in-allowlist" }`, or
`{ outcome: "deny", reason: "mode-not-permitted" }`.
- Mode-aware — read access defaults are wide, write access requires
the rule to be authored as `rw:<glob>`.
- Traversal-safe — paths are normalised through the policy's
`normalisePath` helper before the rule's compiled regex sees them.
## Usage: Basic
```ts
import { PermissionPolicy } from "@suaveplan/api-sandbox-permissions/policy";
import { checkFs } from "@suaveplan/api-sandbox-permissions/checker";
const policy = new PermissionPolicy({
permissions: { fs: ["./uploads/*"] },
});
checkFs(policy, "./uploads/avatar.png", "read"); // { outcome: "allow" }
checkFs(policy, "./etc/passwd", "read");
// { outcome: "deny", reason: "path-not-in-allowlist" }
```
## Usage: Advanced
```ts
import { PermissionPolicy } from "@suaveplan/api-sandbox-permissions/policy";
import { checkFs } from "@suaveplan/api-sandbox-permissions/checker";
import {
SAND_PERM_CODES,
SandboxPermissionError,
} from "@suaveplan/api-sandbox-permissions/error-codes";
const policy = new PermissionPolicy({
permissions: {
fs: ["rw:./uploads/**", "./readonly/*.txt"],
},
});
function gatedFsCall(
plugin: string,
path: string,
mode: "read" | "write"
): void {
const result = checkFs(policy, path, mode);
if (result.outcome === "deny") {
throw new SandboxPermissionError(
SAND_PERM_CODES.DENIED,
"fs capability denied",
{ context: { capability: "fs", reason: result.reason } }
);
}
}
gatedFsCall("billing", "./uploads/temp/log.txt", "write"); // allowed by rw: rule
gatedFsCall("billing", "./readonly/config.txt", "read"); // allowed read-only
```
Hosts MAY also wire `checkFs` directly into the worker capability proxy
without throwing — when the result is `Deny`, the host fabricates a
`{ ok: false, error: ... }` envelope so the worker boundary never sees
a stack trace from the main process.
## API Reference
- `checkFs(policy: PermissionPolicy, path: string, mode: FsAccessMode): CheckResult`
- `mode = "read" | "write"`
- `CheckResult = { outcome: "allow" } | { outcome: "deny"; reason: string }`
- `reason` is one of `"path-not-in-allowlist"`, `"mode-not-permitted"`.
## Implementation Notes
The literal-root check (`startsAtRoot`) is what defends against `..`
escape: a rule like `./uploads/*` has `root = "./uploads"`, and a
resolved input that doesn't start with `"./uploads"` (or equal it)
fails the gate before the regex runs.
The mode check is a soft veto inside the loop — a write attempt against
a read-only matching rule sets `modeBlocked` and keeps scanning, so a
later `rw:` rule on the same path still wins. Only when every matching
rule was read-only AND write was requested does the deny reason become
`"mode-not-permitted"`. This keeps the rule list order-insensitive.

View File

@@ -0,0 +1,96 @@
# `error-codes` — `SAND_PERM.*` registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the stable error-code registry for the
`@suaveplan/api-sandbox-permissions` package. Every deny path raised by
`PermissionChecker` or `BudgetTracker` surfaces as a
`SandboxPermissionError` carrying a code from `SAND_PERM_CODES`. The
namespace is pinned at module-import time so consumers can match either
structurally (`instanceof SandboxPermissionError`) or by stable string
constant (`error.code === SAND_PERM_CODES.DENIED`).
Pinning the registry in a leaf module that depends only on
`@suaveplan/error` keeps the deny-code surface tree-shakeable: a host
that only consumes the budget tracker pulls one code, not the whole
package, and avoids the OTel metric package on cold paths.
## Features
- `SAND_PERM_CODES.DENIED` (`"SAND_PERM.DENIED"`) — raised by every
`PermissionChecker.check*` deny return that the caller chooses to
surface as a thrown error.
- `SAND_PERM_CODES.BUDGET_EXCEEDED` (`"SAND_PERM.BUDGET_EXCEEDED"`) —
raised by `BudgetTracker.tick()` when any of `cpuPercent`,
`memoryMb`, or `wallClockMs` is exceeded.
- `SandboxPermissionCode` discriminated string union.
- `SandboxPermissionError` typed subclass of `SuaveplanError` with
pre-set `name = "SandboxPermissionError"` for stack-trace clarity.
## Usage: Basic
```ts
import {
SAND_PERM_CODES,
SandboxPermissionError,
} from "@suaveplan/api-sandbox-permissions/error-codes";
try {
// call into the checker
} catch (err) {
if (err instanceof SandboxPermissionError) {
if (err.code === SAND_PERM_CODES.DENIED) {
// map to PERMISSION_DENIED inside the plugin
}
}
}
```
## Usage: Advanced
```ts
import {
SAND_PERM_CODES,
SandboxPermissionError,
} from "@suaveplan/api-sandbox-permissions/error-codes";
function reraiseDeny(capability: string, reason: string): never {
throw new SandboxPermissionError(
SAND_PERM_CODES.DENIED,
`sandbox permission denied for capability "${capability}"`,
{ context: { capability, reason } }
);
}
```
The constructor signature mirrors `SuaveplanError`, so
`{ cause, traceId, clock, context }` are all supported. The
`context` object is what the wire boundary serialises as
`details` — it MUST stay redaction-safe (no paths, no hosts, no
secret names), exactly the `{ capability, reason }` envelope
documented in the conformance spec.
## API Reference
- `SAND_PERM_CODES: { readonly DENIED: "SAND_PERM.DENIED"; readonly BUDGET_EXCEEDED: "SAND_PERM.BUDGET_EXCEEDED" }`
- `SandboxPermissionCode = "SAND_PERM.DENIED" | "SAND_PERM.BUDGET_EXCEEDED"`
- `class SandboxPermissionError extends SuaveplanError<SandboxPermissionCode>`
- `constructor(code, message, options?)` — accepts `{ context, cause, traceId, clock }`.
## Implementation Notes
The registry uses `defineCodes` rather than a hand-rolled object literal
so the union type is inferred from the `as const` tuple of reasons. This
is the same pattern as `@suaveplan/api-host`'s `HOST_CODES` /
`PLUGIN_CODES` etc. — every host-stack package collapses its codes to a
single `defineCodes` call so adding a reason is one-line and the type
flows through the rest of the package automatically.
No OTel or zod imports here on purpose — the registry must compile
even when the rest of the package is tree-shaken out. The instrument
descriptors and `Zod` ingestion validators live in their own
sub-modules.

View File

@@ -0,0 +1,83 @@
# `instruments` — OTel counter descriptors
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the OTel counter pair that every sandboxed-tier host
emits on a deny / budget-breach path. Centralising them here ensures
both `api-sandbox-vm` and `api-sandbox-wasm` (and any future host) emit
the SAME metric name with the SAME label set, so dashboards built
against one host work identically against the other.
The descriptors are also enumerated by the conformance harness, so any
host that omits a counter or emits a different label set is caught at
test time rather than in production.
## Features
- `PERMISSION_COUNTER_NAMES` — frozen tuple naming every counter in the
registry: `plugin.permission_denied_total`,
`plugin.budget_exceeded_total`.
- `createPermissionInstruments({ plugin, meterProvider?, meterName? })`
— bundles every counter pre-bound to the supplied (or global) meter
provider.
- `PermissionInstruments` — runtime handle whose `recordDenial` /
`recordBudgetBreach` methods auto-attach the stable `plugin` label.
- `PermissionDenyAttributes` / `BudgetBreachAttributes` — label-set
contracts consumed by both production code and the conformance test.
## Usage: Basic
```ts
import { createPermissionInstruments } from "@suaveplan/api-sandbox-permissions/instruments";
const inst = createPermissionInstruments({ plugin: "billing" });
inst.recordDenial({ capability: "fs", reason: "path-not-in-allowlist" });
```
## Usage: Advanced
```ts
import { metrics } from "@opentelemetry/api";
import { createPermissionInstruments } from "@suaveplan/api-sandbox-permissions/instruments";
// Wire to a custom provider for in-process unit tests so denials can be
// asserted against the captured `add()` calls.
const provider = metrics.getMeterProvider();
const inst = createPermissionInstruments({
plugin: "billing",
meterProvider: provider,
meterName: "@billing/sandbox",
});
inst.recordBudgetBreach({ budget: "wallClockMs" });
```
The label-set contract is intentionally narrow — `plugin`, `capability`,
`reason` for denials and `plugin`, `budget` for budget breaches. New
labels MUST be added through the spec because OTel exporter
cardinality is bounded by the union of every label observed.
## API Reference
- `PERMISSION_COUNTER_NAMES: readonly ["plugin.permission_denied_total", "plugin.budget_exceeded_total"]`
- `type PermissionCounterName = (typeof PERMISSION_COUNTER_NAMES)[number]`
- `interface PermissionDenyAttributes { plugin; capability; reason; }`
- `interface BudgetBreachAttributes { plugin; budget: "cpuPercent"|"memoryMb"|"wallClockMs"; }`
- `interface PermissionInstrumentsConfig { plugin; meterProvider?; meterName?; }`
- `interface PermissionInstruments { plugin; recordDenial; recordBudgetBreach; }`
- `createPermissionInstruments(config): PermissionInstruments`
## Implementation Notes
The module never installs a `MeterProvider` — operator config owns SDK
installation. When the global provider is the no-op default,
`recordDenial` and `recordBudgetBreach` succeed silently, which is the
documented production behaviour when telemetry is disabled.
The handle pre-binds the `plugin` label so call sites cannot forget
it. The `recordDenial` / `recordBudgetBreach` signatures take the
remaining labels — `Omit<…, "plugin">` — so the type system enforces
the partition.

View File

@@ -0,0 +1,127 @@
# `policy` — `PermissionPolicy`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the construction-time value object that holds the
compiled allowlists derived from a `PermissionsManifest`. Building the
policy is the only place where untrusted manifest input crosses a trust
boundary in this package — `Zod` validation runs once here, and every
subsequent `PermissionChecker` call is O(rules) with no further string
parsing.
The policy is intentionally immutable. Hot-reload constructs a fresh
`PermissionPolicy` and the sandbox host swaps the reference atomically;
checker invocations in flight against the old policy keep using the old
allowlist, so a deny / allow decision is always consistent within a
single request.
## Features
- `PermissionPolicy` — compiled value object exposing
`fs[]`, `fetch[]`, `budgets`, and the original `manifest` for
capability gating.
- `POLICY_MANIFEST_SCHEMA` — Zod schema used at the ingestion boundary.
- `compileFsRule(rule)` — converts a `permissions.fs[]` entry into a
`CompiledFsRule` with `write` flag and resolved root.
- `compileFetchRule(rule)` — converts a `permissions.fetch[]` entry
into a `CompiledFetchRule` supporting exact host and `*.suffix`
matching plus optional `http:` prefix.
- `normalisePath(input)` — resolves `..`, strips trailing slashes,
always returns POSIX form.
- `globToRegex(pattern)` — minimal glob → regex translation
(`*`, `**`, `?`, `[abc]`).
## Usage: Basic
```ts
import { PermissionPolicy } from "@suaveplan/api-sandbox-permissions/policy";
const policy = new PermissionPolicy({
permissions: {
fs: ["./uploads/*"],
fetch: ["api.stripe.com"],
budgets: { wallClockMs: 30_000 },
},
});
console.log(policy.budgets.wallClockMs); // 30000
```
## Usage: Advanced
```ts
import {
PermissionPolicy,
compileFsRule,
compileFetchRule,
globToRegex,
} from "@suaveplan/api-sandbox-permissions/policy";
// Inspect compiled rules — used by adapter test fixtures to assert
// the policy was built the way the manifest declared it.
const policy = new PermissionPolicy({
permissions: {
fs: ["rw:./uploads/**", "./readonly/*.txt"],
fetch: ["*.stripe.com", "http:localhost"],
budgets: { cpuPercent: 25, memoryMb: 128, wallClockMs: 30_000 },
},
database: { required: true, schemaVersion: 1, tablePrefix: "billing_" },
events: { publishes: ["billing.invoice.created"], subscribes: [] },
secrets: [{ name: "STRIPE_SECRET_KEY", required: true }],
});
const writeRule = policy.fs.find((rule) => rule.write);
console.assert(writeRule?.root === "./uploads");
// Reusable: build a one-off rule outside a policy (for tests).
const oneRule = compileFsRule("./tmp/*.log");
console.assert(oneRule.test("./tmp/app.log"));
const httpsRule = compileFetchRule("api.stripe.com");
console.assert(httpsRule.test("api.stripe.com"));
const re = globToRegex("./uploads/**");
console.assert(re.test("./uploads/a/b/c"));
```
## API Reference
- `class PermissionPolicy`
- `constructor(manifest: PolicyManifestView)` — throws
`SandboxPermissionError(SAND_PERM.DENIED)` if the input fails the
Zod schema.
- `readonly fs: readonly CompiledFsRule[]`
- `readonly fetch: readonly CompiledFetchRule[]`
- `readonly budgets: ResolvedBudgets` (unset budgets default to `Infinity`).
- `readonly manifest: PolicyManifestView`
- `POLICY_MANIFEST_SCHEMA: ZodObject<…>` — manifest validation schema.
- `compileFsRule(rule: string): CompiledFsRule`
- `compileFetchRule(rule: string): CompiledFetchRule`
- `normalisePath(input: string): string`
- `globToRegex(pattern: string): RegExp`
## Implementation Notes
The glob compiler is deliberately minimal — `*`, `**`, `?`, `[abc]` and
nothing more. Sandbox permission lists are short and operator-authored,
so the rules are written by hand against a constrained vocabulary.
A full POSIX glob engine would add a dependency and a corresponding
attack surface (catastrophic backtracking, character-class escapes)
without buying any expressive power the manifest needs.
Path normalisation uses Node's `posix.normalize` so the same canonical
form runs on Windows hosts. `..` segments are resolved before matching;
the FS checker uses the rule's `root` field to reject paths that
escape the allowed prefix even when the glob pattern alone would
accept them.
The fetch host comparator lowercases both sides so case differences in
the request URL never affect allow / deny.
Budgets default to `Number.POSITIVE_INFINITY` when unset — a manifest
that omits a dimension grants unlimited usage for that dimension. The
budget tracker treats `Infinity` as "never breach".

View File

@@ -0,0 +1,90 @@
# `testing/run-permissions-conformance` — shared conformance harness
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module exports the `runPermissionsConformance(label, factory)`
harness that every sandbox host (`api-sandbox-vm`, `api-sandbox-wasm`,
and any future host) MUST pass before claiming permission-model
conformance. The harness encodes the observable contract from the spec
delta — every documented allow / deny / budget scenario — so adapters
that diverge from the contract are caught at test time.
The harness ships on the package's `./testing` subpath so test files
import it as `@suaveplan/api-sandbox-permissions/testing`. The in-
package self-test (`run-permissions-conformance.test.ts`) exercises the
harness against the built-in fixture so a regression in the harness
itself is also caught.
## Features
- `runPermissionsConformance(label, factory)` — Vitest-/Bun-test-
agnostic harness using `@suaveplan/testing/runner`.
- `makeFixtureChecker(overrides?)` — the in-package factory exercising
the same production code paths the host would wire.
- Asserts deny telemetry as a `Record<string, number>` snapshot from
the factory — no real OTel exporter required.
- Verifies `SandboxPermissionError` envelope is redaction-safe: the
thrown error's `message` does NOT contain the denied path / host.
## Usage: Basic
```ts
import { runPermissionsConformance, makeFixtureChecker } from "@suaveplan/api-sandbox-permissions/testing";
runPermissionsConformance("self", makeFixtureChecker());
```
## Usage: Advanced
```ts
import {
runPermissionsConformance,
type PermissionsConformanceFactory,
} from "@suaveplan/api-sandbox-permissions/testing";
import { PermissionChecker } from "@suaveplan/api-sandbox-permissions/checker";
import { BudgetTracker } from "@suaveplan/api-sandbox-permissions/budget";
import { PermissionPolicy } from "@suaveplan/api-sandbox-permissions/policy";
// Adapter (api-sandbox-vm / api-sandbox-wasm) supplies its own factory
// pointing at its composition root.
const adapterFactory: PermissionsConformanceFactory = {
make() {
const policy = new PermissionPolicy({
permissions: { fs: ["./uploads/*"] },
});
// …adapter-specific instruments + clock wiring…
return {
plugin: "adapter-under-test",
checker: new PermissionChecker({ policy, instruments: /* … */ } as never),
budget: new BudgetTracker({ policy, instruments: /* … */, clock: /* … */ } as never),
snapshot: () => ({}),
};
},
};
runPermissionsConformance("api-sandbox-vm", adapterFactory);
```
## API Reference
- `interface PermissionsConformanceFixture { plugin; checker; budget; snapshot(); }`
- `interface PermissionsConformanceFactory { make(): PermissionsConformanceFixture; }`
- `makeFixtureChecker(overrides?): PermissionsConformanceFactory`
- `runPermissionsConformance(label: string, factory: PermissionsConformanceFactory): void`
## Implementation Notes
The harness uses `@suaveplan/testing/runner` so it works under both
Vitest (default) and `bun:test` (under the `bun` condition); §11
forbids direct imports of either runner in non-exempt packages, and the
package's CI gate enforces that.
The snapshot is intentionally a flat `Record<string, number>` keyed by
`${counter}|${capability|budget}|${reason|budget}`. Adapter factories
that emit denials through a real `MeterProvider` need only ensure their
`snapshot()` returns the same key shape; the harness compares string
equality, not OTel attribute objects.