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,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,100 @@
{
"name": "@suaveplan/api-sandbox-permissions",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./policy": {
"import": {
"types": "./dist/policy/policy.d.ts",
"default": "./dist/policy/policy.js"
}
},
"./checker": {
"import": {
"types": "./dist/checker/checker.d.ts",
"default": "./dist/checker/checker.js"
}
},
"./budget": {
"import": {
"types": "./dist/budget/budget.d.ts",
"default": "./dist/budget/budget.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./instruments": {
"import": {
"types": "./dist/instruments/instruments.d.ts",
"default": "./dist/instruments/instruments.js"
}
},
"./testing": {
"import": {
"types": "./dist/testing/run-permissions-conformance.d.ts",
"default": "./dist/testing/run-permissions-conformance.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/ tests/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/api-types": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/opentelemetry": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

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,198 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
SAND_PERM_CODES,
SandboxPermissionError,
} from "../error-codes/error-codes.js";
import { createPermissionInstruments } from "../instruments/instruments.js";
import { PermissionPolicy } from "../policy/policy.js";
import { BudgetTracker } from "./budget.js";
interface FakeClock {
(): number;
advance(by: number): void;
set(value: number): void;
}
function fakeClock(start = 0): FakeClock {
let t = start;
const fn = (() => t) as FakeClock;
fn.advance = (by) => {
t += by;
};
fn.set = (v) => {
t = v;
};
return fn;
}
interface CapturedCounter {
calls: { value: number; attrs: Readonly<Record<string, unknown>> }[];
}
function buildInstruments(plugin: string): {
counters: Map<string, CapturedCounter>;
instruments: ReturnType<typeof createPermissionInstruments>;
} {
const counters = new Map<string, CapturedCounter>();
const provider = {
getMeter() {
return {
createCounter(name: string) {
const recorder: CapturedCounter = { calls: [] };
counters.set(name, recorder);
return {
add(
value: number,
attrs?: Readonly<Record<string, unknown>>
) {
recorder.calls.push({ value, attrs: attrs ?? {} });
},
};
},
createHistogram() {
return { record() {} };
},
};
},
};
const instruments = createPermissionInstruments({
plugin,
meterProvider: provider as never,
});
return { counters, instruments };
}
describe("BudgetTracker", () => {
it("does not throw within budget", { timeout: 5000 }, () => {
const policy = new PermissionPolicy({
permissions: {
budgets: {
cpuPercent: 50,
memoryMb: 256,
wallClockMs: 30_000,
},
},
});
const { instruments } = buildInstruments("p");
const tracker = new BudgetTracker({
policy,
instruments,
clock: fakeClock(0),
});
tracker.tick({ cpuPercent: 10, memoryMb: 64 });
});
it("raises BUDGET_EXCEEDED on wall-clock breach", {
timeout: 5000,
}, () => {
const policy = new PermissionPolicy({
permissions: { budgets: { wallClockMs: 30_000 } },
});
const { instruments, counters } = buildInstruments("p");
const clock = fakeClock(0);
const tracker = new BudgetTracker({
policy,
instruments,
clock,
});
clock.set(30_001);
let captured: SandboxPermissionError | undefined;
try {
tracker.tick({ cpuPercent: 0, memoryMb: 0 });
} catch (err) {
captured = err as SandboxPermissionError;
}
expect(captured?.code).toBe(SAND_PERM_CODES.BUDGET_EXCEEDED);
const c = counters.get("plugin.budget_exceeded_total");
expect(c?.calls).toHaveLength(1);
expect(c?.calls[0]?.attrs["budget"]).toBe("wallClockMs");
expect(c?.calls[0]?.attrs["plugin"]).toBe("p");
});
it("raises BUDGET_EXCEEDED on cpu breach", {
timeout: 5000,
}, () => {
const policy = new PermissionPolicy({
permissions: { budgets: { cpuPercent: 50 } },
});
const { instruments } = buildInstruments("p");
const tracker = new BudgetTracker({
policy,
instruments,
clock: fakeClock(),
});
expect(() => tracker.tick({ cpuPercent: 75, memoryMb: 0 })).toThrow(
SandboxPermissionError
);
});
it("raises BUDGET_EXCEEDED on memory breach", {
timeout: 5000,
}, () => {
const policy = new PermissionPolicy({
permissions: { budgets: { memoryMb: 100 } },
});
const { instruments } = buildInstruments("p");
const tracker = new BudgetTracker({
policy,
instruments,
clock: fakeClock(),
});
expect(() => tracker.tick({ cpuPercent: 0, memoryMb: 200 })).toThrow(
SandboxPermissionError
);
});
it("is idempotent on duplicate samples", { timeout: 5000 }, () => {
const policy = new PermissionPolicy({
permissions: { budgets: { cpuPercent: 50 } },
});
const { instruments, counters } = buildInstruments("p");
const tracker = new BudgetTracker({
policy,
instruments,
clock: fakeClock(),
});
let firstErr: unknown;
try {
tracker.tick({ cpuPercent: 75, memoryMb: 0 });
} catch (err) {
firstErr = err;
}
expect(firstErr).toBeInstanceOf(SandboxPermissionError);
// Second tick — same dimension should be a no-op, no second
// counter increment, no second throw.
tracker.tick({ cpuPercent: 75, memoryMb: 0 });
const c = counters.get("plugin.budget_exceeded_total");
expect(c?.calls).toHaveLength(1);
});
it("elapsedMs returns wall-clock progression", {
timeout: 5000,
}, () => {
const policy = new PermissionPolicy({});
const { instruments } = buildInstruments("p");
const clock = fakeClock(1000);
const tracker = new BudgetTracker({
policy,
instruments,
clock,
});
clock.set(1234);
expect(tracker.elapsedMs()).toBe(234);
});
it("Infinity budget never breaches", { timeout: 5000 }, () => {
const policy = new PermissionPolicy({});
const { instruments } = buildInstruments("p");
const tracker = new BudgetTracker({
policy,
instruments,
clock: fakeClock(),
});
tracker.tick({
cpuPercent: Number.MAX_SAFE_INTEGER,
memoryMb: Number.MAX_SAFE_INTEGER,
});
});
});

View File

@@ -0,0 +1,95 @@
import type { Clock } from "@suaveplan/types";
import {
SAND_PERM_CODES,
SandboxPermissionError,
} from "../error-codes/error-codes.js";
import type {
BudgetBreachAttributes,
PermissionInstruments,
} from "../instruments/instruments.js";
import type { PermissionPolicy } from "../policy/policy.js";
/** One CPU sample plus one memory sample produced by the worker host. */
export interface BudgetSample {
readonly cpuPercent: number;
readonly memoryMb: number;
}
/** Construction options for {@link BudgetTracker}. */
export interface BudgetTrackerOptions {
readonly policy: PermissionPolicy;
readonly instruments: PermissionInstruments;
readonly clock: Clock;
}
/**
* Breach record returned by {@link BudgetTracker.tick} when a sample
* pushes the request over a budget. The tracker is idempotent: a
* subsequent identical sample does NOT re-emit the same dimension.
*/
export type BudgetBreach = BudgetBreachAttributes extends {
readonly budget: infer B;
}
? B
: never;
/**
* Tracks per-request budget dimensions. Constructed once per request,
* disposed when the request completes. Samples are caller-supplied so
* the underlying sampling primitives (resource-usage API, V8 heap
* stats, etc.) stay outside this package.
*/
export class BudgetTracker {
public readonly policy: PermissionPolicy;
public readonly instruments: PermissionInstruments;
private readonly clock: Clock;
private readonly startedAt: number;
private readonly breached: Set<BudgetBreach> = new Set();
public constructor(options: BudgetTrackerOptions) {
this.policy = options.policy;
this.instruments = options.instruments;
this.clock = options.clock;
this.startedAt = this.clock();
}
/** Wall-clock elapsed since construction, in milliseconds. */
public elapsedMs(): number {
return this.clock() - this.startedAt;
}
/**
* Record one CPU+memory sample, advance the wall-clock check, and
* throw `SandboxPermissionError` (code `SAND_PERM.BUDGET_EXCEEDED`)
* on the first newly-breached dimension. Subsequent ticks against
* the same dimension are no-ops — the deny counter and the throw
* fire exactly once per (request, dimension) pair.
*/
public tick(sample: BudgetSample): void {
const budgets = this.policy.budgets;
const elapsed = this.elapsedMs();
this.evaluate("cpuPercent", sample.cpuPercent, budgets.cpuPercent);
this.evaluate("memoryMb", sample.memoryMb, budgets.memoryMb);
this.evaluate("wallClockMs", elapsed, budgets.wallClockMs);
}
private evaluate(
dimension: BudgetBreach,
value: number,
limit: number
): void {
if (value <= limit) {
return;
}
if (this.breached.has(dimension)) {
return;
}
this.breached.add(dimension);
this.instruments.recordBudgetBreach({ budget: dimension });
throw new SandboxPermissionError(
SAND_PERM_CODES.BUDGET_EXCEEDED,
`sandbox budget exceeded: ${dimension}`,
{ context: { capability: "budget", reason: dimension } }
);
}
}

View File

@@ -0,0 +1,6 @@
export type {
BudgetBreach,
BudgetSample,
BudgetTrackerOptions,
} from "./budget.js";
export { BudgetTracker } from "./budget.js";

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,145 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { PermissionPolicy } from "../../policy/policy.js";
import { checkCapability } from "./capability.js";
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"],
},
});
describe("checkCapability — db", () => {
it("allows query inside declared tablePrefix", {
timeout: 5000,
}, () => {
expect(
checkCapability(policy, {
capability: "db",
table: "billing_invoices",
})
).toEqual({ outcome: "allow" });
});
it("db denies queries outside declared tablePrefix", {
timeout: 5000,
}, () => {
expect(
checkCapability(policy, {
capability: "db",
table: "users_account",
})
).toEqual({ outcome: "deny", reason: "table-out-of-scope" });
});
it("db denies queries when database manifest absent", {
timeout: 5000,
}, () => {
const empty = new PermissionPolicy({});
expect(
checkCapability(empty, { capability: "db", table: "any_table" })
).toEqual({ outcome: "deny", reason: "db-not-declared" });
});
});
describe("checkCapability — secrets", () => {
it("allows declared name", { timeout: 5000 }, () => {
expect(
checkCapability(policy, {
capability: "secrets",
name: "STRIPE_SECRET_KEY",
}).outcome
).toBe("allow");
});
it("secrets denies undeclared name", { timeout: 5000 }, () => {
expect(
checkCapability(policy, {
capability: "secrets",
name: "AWS_KEY",
})
).toEqual({ outcome: "deny", reason: "secret-not-declared" });
});
it("secrets denies when manifest has no secrets array", {
timeout: 5000,
}, () => {
const empty = new PermissionPolicy({});
expect(
checkCapability(empty, {
capability: "secrets",
name: "FOO",
}).outcome
).toBe("deny");
});
});
describe("checkCapability — events", () => {
it("allows publish of declared topic", { timeout: 5000 }, () => {
expect(
checkCapability(policy, {
capability: "events",
mode: "publish",
topic: "billing.invoice.created",
}).outcome
).toBe("allow");
});
it("allows subscribe of declared topic", { timeout: 5000 }, () => {
expect(
checkCapability(policy, {
capability: "events",
mode: "subscribe",
topic: "users.profile.updated",
}).outcome
).toBe("allow");
});
it("events denies publish outside manifest", { timeout: 5000 }, () => {
expect(
checkCapability(policy, {
capability: "events",
mode: "publish",
topic: "other.topic",
})
).toEqual({ outcome: "deny", reason: "publish-not-declared" });
});
it("events denies subscribe outside manifest", {
timeout: 5000,
}, () => {
expect(
checkCapability(policy, {
capability: "events",
mode: "subscribe",
topic: "other.topic",
})
).toEqual({ outcome: "deny", reason: "subscribe-not-declared" });
});
it("events denies when no events block in manifest", {
timeout: 5000,
}, () => {
const empty = new PermissionPolicy({});
expect(
checkCapability(empty, {
capability: "events",
mode: "publish",
topic: "anything",
}).outcome
).toBe("deny");
expect(
checkCapability(empty, {
capability: "events",
mode: "subscribe",
topic: "anything",
}).outcome
).toBe("deny");
});
});

View File

@@ -0,0 +1,53 @@
import type { PermissionPolicy } from "../../policy/policy.js";
import type { CapabilityPayload, CheckResult } from "../checker.types.js";
/**
* Gate a capability call against the manifest declarations the plugin
* shipped at registration time.
*
* - `db` — table name must start with `database.tablePrefix`.
* When the manifest declares no prefix, every table is
* rejected with `"db-not-declared"` (a manifest that
* doesn't declare `database.tablePrefix` cannot use the
* DB capability at all).
* - `secrets` — secret name must appear in `secrets[]`.
* - `events` — topic must appear in `events.publishes[]` for
* publishes, or `events.subscribes[]` for subscribes.
*/
export function checkCapability(
policy: PermissionPolicy,
payload: CapabilityPayload
): CheckResult {
if (payload.capability === "db") {
const prefix = policy.manifest.database?.tablePrefix;
if (prefix === undefined) {
return { outcome: "deny", reason: "db-not-declared" };
}
if (!payload.table.startsWith(prefix)) {
return { outcome: "deny", reason: "table-out-of-scope" };
}
return { outcome: "allow" };
}
if (payload.capability === "secrets") {
const declared = policy.manifest.secrets ?? [];
const found = declared.some((s) => s.name === payload.name);
if (!found) {
return { outcome: "deny", reason: "secret-not-declared" };
}
return { outcome: "allow" };
}
const declared =
payload.mode === "publish"
? (policy.manifest.events?.publishes ?? [])
: (policy.manifest.events?.subscribes ?? []);
if (!declared.includes(payload.topic)) {
return {
outcome: "deny",
reason:
payload.mode === "publish"
? "publish-not-declared"
: "subscribe-not-declared",
};
}
return { outcome: "allow" };
}

View File

@@ -0,0 +1 @@
export { checkCapability } from "./capability.js";

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,208 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
SAND_PERM_CODES,
SandboxPermissionError,
} from "../error-codes/error-codes.js";
import { createPermissionInstruments } from "../instruments/instruments.js";
import { PermissionPolicy } from "../policy/policy.js";
import { PermissionChecker } from "./checker.js";
interface CapturedCall {
name: string;
value: number;
attrs: Readonly<Record<string, unknown>>;
}
function buildChecker(): {
captures: CapturedCall[];
checker: PermissionChecker;
} {
const captures: CapturedCall[] = [];
const provider = {
getMeter() {
return {
createCounter(name: string) {
return {
add(
value: number,
attrs?: Readonly<Record<string, unknown>>
) {
captures.push({ name, value, attrs: attrs ?? {} });
},
};
},
createHistogram() {
return { record() {} };
},
};
},
};
const policy = new PermissionPolicy({
permissions: {
fs: ["./uploads/*"],
fetch: ["api.stripe.com"],
},
database: {
required: true,
schemaVersion: 1,
tablePrefix: "billing_",
},
secrets: [{ name: "STRIPE_SECRET_KEY", required: true }],
events: { publishes: ["billing.invoice.created"] },
});
const instruments = createPermissionInstruments({
plugin: "billing",
meterProvider: provider as never,
});
const checker = new PermissionChecker({ policy, instruments });
return { captures, checker };
}
describe("checker.spec-conformance", () => {
it("fs denial emits permission_denied_total with reason label", {
timeout: 5000,
}, () => {
const { captures, checker } = buildChecker();
checker.checkFs("./etc/passwd", "read");
const denied = captures.filter(
(c) => c.name === "plugin.permission_denied_total"
);
expect(denied).toHaveLength(1);
expect(denied[0]?.attrs).toEqual({
plugin: "billing",
capability: "fs",
reason: "path-not-in-allowlist",
});
});
it("fetch denial emits permission_denied_total", {
timeout: 5000,
}, () => {
const { captures, checker } = buildChecker();
checker.checkFetch("https://evil.example.org");
const denied = captures.filter(
(c) => c.name === "plugin.permission_denied_total"
);
expect(denied).toHaveLength(1);
expect(denied[0]?.attrs["capability"]).toBe("fetch");
expect(denied[0]?.attrs["reason"]).toBe("host-not-in-allowlist");
});
it("PERMISSION_DENIED error envelope is redaction-safe", {
timeout: 5000,
}, () => {
const { checker } = buildChecker();
let err: SandboxPermissionError | undefined;
try {
checker.assertFs("./etc/passwd", "read");
} catch (e) {
err = e as SandboxPermissionError;
}
expect(err).toBeInstanceOf(SandboxPermissionError);
expect(err?.code).toBe(SAND_PERM_CODES.DENIED);
expect(err?.context).toEqual({
capability: "fs",
reason: "path-not-in-allowlist",
});
// Message MUST NOT include the denied path
expect(err?.message).not.toContain("/etc/passwd");
});
it("warn mode records denial without throwing", {
timeout: 5000,
}, () => {
const { captures, checker } = buildChecker();
const warn = new PermissionChecker({
policy: checker.policy,
instruments: checker.instruments,
mode: "warn",
});
const result = warn.assertFs("./etc/passwd", "read");
expect(result.outcome).toBe("deny");
const denied = captures.filter(
(c) => c.name === "plugin.permission_denied_total"
);
expect(denied).toHaveLength(1);
});
it("enforce mode throws SAND_PERM.DENIED for assertFetch", {
timeout: 5000,
}, () => {
const { checker } = buildChecker();
expect(() => checker.assertFetch("https://evil.example.org")).toThrow(
SandboxPermissionError
);
});
it("assertCapability throws in enforce mode and uses payload capability label", {
timeout: 5000,
}, () => {
const { captures, checker } = buildChecker();
let err: SandboxPermissionError | undefined;
try {
checker.assertCapability({
capability: "secrets",
name: "AWS_KEY",
});
} catch (e) {
err = e as SandboxPermissionError;
}
expect(err?.context).toEqual({
capability: "secrets",
reason: "secret-not-declared",
});
const denied = captures.filter(
(c) => c.name === "plugin.permission_denied_total"
);
expect(denied).toHaveLength(1);
expect(denied[0]?.attrs["capability"]).toBe("secrets");
});
it("allow paths do not record the denial counter", {
timeout: 5000,
}, () => {
const { captures, checker } = buildChecker();
checker.checkFs("./uploads/x.png", "read");
checker.checkFetch("https://api.stripe.com/v1/charges");
checker.checkCapability({
capability: "db",
table: "billing_invoices",
});
const denied = captures.filter(
(c) => c.name === "plugin.permission_denied_total"
);
expect(denied).toHaveLength(0);
});
it("default mode is enforce", { timeout: 5000 }, () => {
const { checker } = buildChecker();
expect(checker.mode).toBe("enforce");
});
it("assertFs returns allow result without throw on allowed path", {
timeout: 5000,
}, () => {
const { checker } = buildChecker();
const result = checker.assertFs("./uploads/x.png", "read");
expect(result.outcome).toBe("allow");
});
it("assertFetch returns allow result on allowed url", {
timeout: 5000,
}, () => {
const { checker } = buildChecker();
const result = checker.assertFetch("https://api.stripe.com/v1/charges");
expect(result.outcome).toBe("allow");
});
it("assertCapability returns allow result on allowed payload", {
timeout: 5000,
}, () => {
const { checker } = buildChecker();
const result = checker.assertCapability({
capability: "db",
table: "billing_invoices",
});
expect(result.outcome).toBe("allow");
});
});

View File

@@ -0,0 +1,129 @@
import {
SAND_PERM_CODES,
SandboxPermissionError,
} from "../error-codes/error-codes.js";
import type { PermissionInstruments } from "../instruments/instruments.js";
import type { PermissionPolicy } from "../policy/policy.js";
import { checkCapability } from "./capability/capability.js";
import type {
CapabilityPayload,
CheckResult,
FsAccessMode,
} from "./checker.types.js";
import { checkFetch } from "./fetch/fetch.js";
import { checkFs } from "./fs/fs.js";
/** Logical sources that emit denial telemetry. */
export type CheckerCapabilitySource =
| "fs"
| "fetch"
| "db"
| "secrets"
| "events";
/**
* The unified deny-mode contract: `"enforce"` raises a
* `SandboxPermissionError` and increments the deny counter; `"warn"`
* only increments the counter and lets the call proceed. Sandbox hosts
* use `"warn"` during the manifest-canary phase of a hot reload so a
* misconfigured manifest does not take a healthy plugin down.
*/
export type DenyMode = "enforce" | "warn";
/** Construction options for {@link PermissionChecker}. */
export interface PermissionCheckerOptions {
readonly policy: PermissionPolicy;
readonly instruments: PermissionInstruments;
/** Default deny mode for every `assert*` helper. Defaults to `"enforce"`. */
readonly mode?: DenyMode;
}
/**
* Composite checker — one instance per plugin-worker pair. Wraps the
* three pure functions (`checkFs` / `checkFetch` / `checkCapability`)
* with denial telemetry and the configurable
* `enforce` / `warn` deny mode.
*/
export class PermissionChecker {
public readonly policy: PermissionPolicy;
public readonly instruments: PermissionInstruments;
public readonly mode: DenyMode;
public constructor(options: PermissionCheckerOptions) {
this.policy = options.policy;
this.instruments = options.instruments;
this.mode = options.mode ?? "enforce";
}
public checkFs(path: string, mode: FsAccessMode): CheckResult {
const result = checkFs(this.policy, path, mode);
if (result.outcome === "deny") {
this.instruments.recordDenial({
capability: "fs",
reason: result.reason,
});
}
return result;
}
public checkFetch(url: string): CheckResult {
const result = checkFetch(this.policy, url);
if (result.outcome === "deny") {
this.instruments.recordDenial({
capability: "fetch",
reason: result.reason,
});
}
return result;
}
public checkCapability(payload: CapabilityPayload): CheckResult {
const result = checkCapability(this.policy, payload);
if (result.outcome === "deny") {
this.instruments.recordDenial({
capability: payload.capability,
reason: result.reason,
});
}
return result;
}
/**
* Assert a fs check. Raises `SandboxPermissionError` in
* `enforce` mode; returns the result silently in `warn` mode so
* the caller can still log / metric the deny.
*/
public assertFs(path: string, mode: FsAccessMode): CheckResult {
const result = this.checkFs(path, mode);
return this.maybeThrow(result, "fs");
}
public assertFetch(url: string): CheckResult {
const result = this.checkFetch(url);
return this.maybeThrow(result, "fetch");
}
public assertCapability(payload: CapabilityPayload): CheckResult {
const result = this.checkCapability(payload);
return this.maybeThrow(result, payload.capability);
}
private maybeThrow(
result: CheckResult,
capability: CheckerCapabilitySource
): CheckResult {
if (result.outcome === "allow") {
return result;
}
if (this.mode === "warn") {
return result;
}
throw new SandboxPermissionError(
SAND_PERM_CODES.DENIED,
`sandbox permission denied for capability "${capability}"`,
{
context: { capability, reason: result.reason },
}
);
}
}

View File

@@ -0,0 +1,20 @@
/** Result returned by every `PermissionChecker.check*` call. */
export type CheckResult =
| { readonly outcome: "allow" }
| { readonly outcome: "deny"; readonly reason: string };
/** Filesystem access modes the checker supports. */
export type FsAccessMode = "read" | "write";
/** Capability ids gated by `checkCapability`. */
export type GatedCapabilityId = "db" | "secrets" | "events";
/** Payload shapes understood by `checkCapability`. */
export type CapabilityPayload =
| { readonly capability: "db"; readonly table: string }
| { readonly capability: "secrets"; readonly name: string }
| {
readonly capability: "events";
readonly mode: "publish" | "subscribe";
readonly topic: string;
};

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,76 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { PermissionPolicy } from "../../policy/policy.js";
import { checkFetch } from "./fetch.js";
function makePolicy(fetch: readonly string[]): PermissionPolicy {
return new PermissionPolicy({ permissions: { fetch } });
}
describe("checkFetch", () => {
it("allows exact host in allowlist", { timeout: 5000 }, () => {
const policy = makePolicy(["api.stripe.com"]);
expect(checkFetch(policy, "https://api.stripe.com/v1/charges")).toEqual(
{ outcome: "allow" }
);
});
it("wildcard matches subdomains only", { timeout: 5000 }, () => {
const policy = makePolicy(["*.stripe.com"]);
expect(
checkFetch(policy, "https://eu.api.stripe.com/v1/charges").outcome
).toBe("allow");
expect(checkFetch(policy, "https://stripe.com/v1").outcome).toBe(
"deny"
);
});
it("denies when host is not in allowlist", { timeout: 5000 }, () => {
const policy = makePolicy(["api.stripe.com"]);
const r = checkFetch(policy, "https://api.example.com");
expect(r).toEqual({
outcome: "deny",
reason: "host-not-in-allowlist",
});
});
it("denies http: against https-only rule", { timeout: 5000 }, () => {
const policy = makePolicy(["api.stripe.com"]);
const r = checkFetch(policy, "http://api.stripe.com");
expect(r).toEqual({
outcome: "deny",
reason: "scheme-not-permitted",
});
});
it("allows http: when rule is http: prefixed", {
timeout: 5000,
}, () => {
const policy = makePolicy(["http:localhost"]);
expect(checkFetch(policy, "http://localhost:8080/dev")).toEqual({
outcome: "allow",
});
});
it("denies unsupported scheme (ws:)", { timeout: 5000 }, () => {
const policy = makePolicy(["api.stripe.com"]);
expect(checkFetch(policy, "ws://api.stripe.com")).toEqual({
outcome: "deny",
reason: "scheme-not-permitted",
});
});
it("denies on invalid URL", { timeout: 5000 }, () => {
const policy = makePolicy(["api.stripe.com"]);
expect(checkFetch(policy, "::not-a-url::")).toEqual({
outcome: "deny",
reason: "invalid-url",
});
});
it("lowercases host on input side", { timeout: 5000 }, () => {
const policy = makePolicy(["api.stripe.com"]);
expect(checkFetch(policy, "https://API.STRIPE.COM").outcome).toBe(
"allow"
);
});
});

View File

@@ -0,0 +1,44 @@
import type { PermissionPolicy } from "../../policy/policy.js";
import type { CheckResult } from "../checker.types.js";
/**
* Test a URL against the compiled `permissions.fetch[]` allowlist.
*
* Returns `Allow` only when:
* - the URL parses and its scheme is `https:` OR `http:` and at least
* one matching rule carried the `http:` prefix, AND
* - the URL's host matches at least one compiled rule.
*
* `deny.reason` is one of:
* - `"invalid-url"` — the input could not be parsed as a URL
* - `"scheme-not-permitted"` — `http:` requested but no rule allows it
* - `"host-not-in-allowlist"` — host did not match any rule
*/
export function checkFetch(policy: PermissionPolicy, url: string): CheckResult {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return { outcome: "deny", reason: "invalid-url" };
}
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
return { outcome: "deny", reason: "scheme-not-permitted" };
}
const host = parsed.hostname.toLowerCase();
const wantHttp = parsed.protocol === "http:";
let hostMatched = false;
for (const rule of policy.fetch) {
if (!rule.test(host)) {
continue;
}
hostMatched = true;
if (wantHttp && !rule.allowHttp) {
continue;
}
return { outcome: "allow" };
}
if (wantHttp && hostMatched) {
return { outcome: "deny", reason: "scheme-not-permitted" };
}
return { outcome: "deny", reason: "host-not-in-allowlist" };
}

View File

@@ -0,0 +1 @@
export { checkFetch } from "./fetch.js";

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,107 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { PermissionPolicy } from "../../policy/policy.js";
import { checkFs } from "./fs.js";
function makePolicy(fs: readonly string[]): PermissionPolicy {
return new PermissionPolicy({ permissions: { fs } });
}
describe("checkFs", () => {
it("allows path matching allowlist", { timeout: 5000 }, () => {
const policy = makePolicy(["./uploads/*"]);
expect(checkFs(policy, "./uploads/abc.png", "read")).toEqual({
outcome: "allow",
});
});
it("denies path outside allowlist", { timeout: 5000 }, () => {
const policy = makePolicy(["./uploads/*"]);
expect(checkFs(policy, "./etc/passwd", "read")).toEqual({
outcome: "deny",
reason: "path-not-in-allowlist",
});
});
it("normalises traversal segments before matching", {
timeout: 5000,
}, () => {
const policy = makePolicy(["./uploads/*"]);
// Resolves to "etc/passwd" — escapes ./uploads root and denies
const result = checkFs(policy, "./uploads/../etc/passwd", "read");
expect(result.outcome).toBe("deny");
if (result.outcome === "deny") {
expect(result.reason).toBe("path-not-in-allowlist");
}
});
it("denies write when rule is read-only", { timeout: 5000 }, () => {
const policy = makePolicy(["./uploads/*"]);
expect(checkFs(policy, "./uploads/x.png", "write")).toEqual({
outcome: "deny",
reason: "mode-not-permitted",
});
});
it("allows write when rule is rw:", { timeout: 5000 }, () => {
const policy = makePolicy(["rw:./uploads/*"]);
expect(checkFs(policy, "./uploads/x.png", "write")).toEqual({
outcome: "allow",
});
});
it("read still allowed against rw: rule", { timeout: 5000 }, () => {
const policy = makePolicy(["rw:./uploads/*"]);
expect(checkFs(policy, "./uploads/x.png", "read")).toEqual({
outcome: "allow",
});
});
it("returns mode-not-permitted only when matching rules are read-only", {
timeout: 5000,
}, () => {
// Two rules — one ro, one rw. A write should be allowed via the rw.
const policy = makePolicy(["./uploads/*", "rw:./uploads/*"]);
expect(checkFs(policy, "./uploads/x.png", "write").outcome).toBe(
"allow"
);
});
it("denies when allowlist is empty", { timeout: 5000 }, () => {
const policy = makePolicy([]);
expect(checkFs(policy, "./anything", "read")).toEqual({
outcome: "deny",
reason: "path-not-in-allowlist",
});
});
it("allows the root-equal path (rule with no glob)", {
timeout: 5000,
}, () => {
const policy = makePolicy(["./uploads/file.txt"]);
expect(checkFs(policy, "./uploads/file.txt", "read")).toEqual({
outcome: "allow",
});
});
it("denies path under root that does not match glob", {
timeout: 5000,
}, () => {
// root=./uploads; path is under root but glob "./uploads/*"
// does not match a nested subdir (single-segment * stops at /).
const policy = makePolicy(["./uploads/*"]);
expect(checkFs(policy, "./uploads/sub/file.png", "read")).toEqual({
outcome: "deny",
reason: "path-not-in-allowlist",
});
});
it("denies path that matches glob suffix but escapes root", {
timeout: 5000,
}, () => {
const policy = makePolicy(["./uploads/*"]);
expect(checkFs(policy, "./not-uploads/a.png", "read")).toEqual({
outcome: "deny",
reason: "path-not-in-allowlist",
});
});
});

View File

@@ -0,0 +1,44 @@
import type { PermissionPolicy } from "../../policy/policy.js";
import { normalisePath } from "../../policy/policy.js";
import type { CheckResult, FsAccessMode } from "../checker.types.js";
/**
* Test a path against the compiled `permissions.fs[]` allowlist.
*
* Returns `Allow` only when:
* - the resolved path matches at least one compiled rule's glob, AND
* - the resolved path is inside the rule's literal root (no escape via `..`), AND
* - the requested mode is `read` OR the matched rule was prefixed `rw:`.
*
* `deny.reason` is one of:
* - `"path-not-in-allowlist"` — no rule matched the path at all
* - `"mode-not-permitted"` — a rule matched but write was requested
* against a read-only rule
*/
export function checkFs(
policy: PermissionPolicy,
path: string,
mode: FsAccessMode
): CheckResult {
const resolved = normalisePath(path);
let modeBlocked = false;
for (const rule of policy.fs) {
const startsAtRoot =
resolved === rule.root || resolved.startsWith(`${rule.root}/`);
if (!startsAtRoot) {
continue;
}
if (!rule.test(resolved)) {
continue;
}
if (mode === "write" && !rule.write) {
modeBlocked = true;
continue;
}
return { outcome: "allow" };
}
return {
outcome: "deny",
reason: modeBlocked ? "mode-not-permitted" : "path-not-in-allowlist",
};
}

View File

@@ -0,0 +1 @@
export { checkFs } from "./fs.js";

View File

@@ -0,0 +1,15 @@
export { checkCapability } from "./capability/capability.js";
export type {
CheckerCapabilitySource,
DenyMode,
PermissionCheckerOptions,
} from "./checker.js";
export { PermissionChecker } from "./checker.js";
export type {
CapabilityPayload,
CheckResult,
FsAccessMode,
GatedCapabilityId,
} from "./checker.types.js";
export { checkFetch } from "./fetch/fetch.js";
export { checkFs } from "./fs/fs.js";

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,40 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SAND_PERM_CODES, SandboxPermissionError } from "./error-codes.js";
describe("SAND_PERM_CODES", () => {
it("registers both code constants under the SAND_PERM namespace", {
timeout: 5000,
}, () => {
expect(SAND_PERM_CODES.DENIED).toBe("SAND_PERM.DENIED");
expect(SAND_PERM_CODES.BUDGET_EXCEEDED).toBe(
"SAND_PERM.BUDGET_EXCEEDED"
);
});
it("SandboxPermissionError sets name and code", {
timeout: 5000,
}, () => {
const err = new SandboxPermissionError(
SAND_PERM_CODES.DENIED,
"denied",
{ context: { capability: "fs", reason: "test" } }
);
expect(err.name).toBe("SandboxPermissionError");
expect(err.code).toBe("SAND_PERM.DENIED");
expect(err.context).toEqual({
capability: "fs",
reason: "test",
});
});
it("SandboxPermissionError works without options", {
timeout: 5000,
}, () => {
const err = new SandboxPermissionError(
SAND_PERM_CODES.BUDGET_EXCEEDED,
"budget"
);
expect(err.code).toBe("SAND_PERM.BUDGET_EXCEEDED");
expect(err).toBeInstanceOf(SandboxPermissionError);
});
});

View File

@@ -0,0 +1,35 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable code registry for the permission / budget enforcement layer
* consumed by every sandboxed-tier host (`api-sandbox-vm`,
* `api-sandbox-wasm`, future hosts). Both codes are raised on a
* production deny path AND surfaced inside the plugin via the worker
* boundary as an `instanceof SandboxPermissionError`.
*/
export const SAND_PERM_CODES = defineCodes("SAND_PERM", [
"DENIED",
"BUDGET_EXCEEDED",
] as const);
export type SandboxPermissionCode =
(typeof SAND_PERM_CODES)[keyof typeof SAND_PERM_CODES];
/**
* Typed error raised by every public path in
* `@suaveplan/api-sandbox-permissions`. Extends {@link SuaveplanError}
* so consumers can branch via `instanceof SandboxPermissionError`
* or by the stable `code` string. The `name` field is fixed so stack
* traces self-identify regardless of bundler renaming.
*/
export class SandboxPermissionError extends SuaveplanError<SandboxPermissionCode> {
public constructor(
code: SandboxPermissionCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.name = "SandboxPermissionError";
}
}

View File

@@ -0,0 +1,5 @@
export type { SandboxPermissionCode } from "./error-codes.js";
export {
SAND_PERM_CODES,
SandboxPermissionError,
} from "./error-codes.js";

View File

@@ -0,0 +1,57 @@
// Budget tracker
export type {
BudgetBreach,
BudgetSample,
BudgetTrackerOptions,
} from "./budget/budget.js";
export { BudgetTracker } from "./budget/budget.js";
// Checker (composite)
export { checkCapability } from "./checker/capability/capability.js";
export type {
CheckerCapabilitySource,
DenyMode,
PermissionCheckerOptions,
} from "./checker/checker.js";
export { PermissionChecker } from "./checker/checker.js";
export type {
CapabilityPayload,
CheckResult,
FsAccessMode,
GatedCapabilityId,
} from "./checker/checker.types.js";
export { checkFetch } from "./checker/fetch/fetch.js";
export { checkFs } from "./checker/fs/fs.js";
export type { SandboxPermissionCode } from "./error-codes/error-codes.js";
// Error codes
export {
SAND_PERM_CODES,
SandboxPermissionError,
} from "./error-codes/error-codes.js";
export type {
BudgetBreachAttributes,
PermissionCounterName,
PermissionDenyAttributes,
PermissionInstruments,
PermissionInstrumentsConfig,
} from "./instruments/instruments.js";
// Instruments
export {
createPermissionInstruments,
PERMISSION_COUNTER_NAMES,
} from "./instruments/instruments.js";
// Policy
export {
compileFetchRule,
compileFsRule,
globToRegex,
normalisePath,
PermissionPolicy,
POLICY_MANIFEST_SCHEMA,
} from "./policy/policy.js";
export type {
CompiledFetchRule,
CompiledFsRule,
PolicyManifestView,
ResolvedBudgets,
} from "./policy/policy.types.js";

View File

@@ -0,0 +1,11 @@
export type {
BudgetBreachAttributes,
PermissionCounterName,
PermissionDenyAttributes,
PermissionInstruments,
PermissionInstrumentsConfig,
} from "./instruments.js";
export {
createPermissionInstruments,
PERMISSION_COUNTER_NAMES,
} from "./instruments.js";

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,114 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
createPermissionInstruments,
PERMISSION_COUNTER_NAMES,
} from "./instruments.js";
interface Captured {
name: string;
calls: { value: number; attrs: Readonly<Record<string, unknown>> }[];
}
function buildProvider(): {
provider: { getMeter(name: string): unknown };
meters: { name: string; instruments: Captured[] }[];
} {
const meters: { name: string; instruments: Captured[] }[] = [];
return {
meters,
provider: {
getMeter(name: string) {
const m: { name: string; instruments: Captured[] } = {
name,
instruments: [],
};
meters.push(m);
return {
createCounter(n: string) {
const c: Captured = { name: n, calls: [] };
m.instruments.push(c);
return {
add(
value: number,
attrs?: Readonly<Record<string, unknown>>
) {
c.calls.push({ value, attrs: attrs ?? {} });
},
};
},
};
},
},
};
}
describe("createPermissionInstruments", () => {
it("creates both documented counters on the configured meter", {
timeout: 5000,
}, () => {
const { provider, meters } = buildProvider();
createPermissionInstruments({
plugin: "p",
meterProvider: provider as never,
});
const names = meters[0]?.instruments.map((i) => i.name);
expect(names).toEqual([...PERMISSION_COUNTER_NAMES]);
});
it("recordDenial attaches plugin + capability + reason", {
timeout: 5000,
}, () => {
const { provider, meters } = buildProvider();
const inst = createPermissionInstruments({
plugin: "p",
meterProvider: provider as never,
});
inst.recordDenial({ capability: "fs", reason: "x" });
const denied = meters[0]?.instruments.find(
(i) => i.name === "plugin.permission_denied_total"
);
expect(denied?.calls[0]).toEqual({
value: 1,
attrs: { plugin: "p", capability: "fs", reason: "x" },
});
});
it("recordBudgetBreach attaches plugin + budget", {
timeout: 5000,
}, () => {
const { provider, meters } = buildProvider();
const inst = createPermissionInstruments({
plugin: "p",
meterProvider: provider as never,
meterName: "@custom/meter",
});
inst.recordBudgetBreach({ budget: "wallClockMs" });
expect(meters[0]?.name).toBe("@custom/meter");
const c = meters[0]?.instruments.find(
(i) => i.name === "plugin.budget_exceeded_total"
);
expect(c?.calls[0]?.attrs).toEqual({
plugin: "p",
budget: "wallClockMs",
});
});
it("uses global meter provider when none supplied", {
timeout: 5000,
}, () => {
const inst = createPermissionInstruments({ plugin: "default" });
// No throw — global provider returns no-op meters.
inst.recordDenial({ capability: "fs", reason: "x" });
inst.recordBudgetBreach({ budget: "cpuPercent" });
expect(inst.plugin).toBe("default");
});
it("PERMISSION_COUNTER_NAMES enumerates both counters", {
timeout: 5000,
}, () => {
expect(PERMISSION_COUNTER_NAMES).toEqual([
"plugin.permission_denied_total",
"plugin.budget_exceeded_total",
]);
});
});

View File

@@ -0,0 +1,104 @@
import type {
Meter,
MeterProvider,
Counter as OtelCounter,
} from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
/**
* Every counter the sandbox permission layer emits on a production
* deny / breach path. The list mirrors design doc §5.2b and §12.4 — a
* missing entry is a spec deviation, and the registry is enumerated
* verbatim by the conformance harness so both sandbox hosts produce the
* same metric surface.
*/
export const PERMISSION_COUNTER_NAMES = [
"plugin.permission_denied_total",
"plugin.budget_exceeded_total",
] as const;
export type PermissionCounterName = (typeof PERMISSION_COUNTER_NAMES)[number];
/** Stable label set on every permission counter measurement. */
export interface PermissionDenyAttributes {
readonly plugin: string;
readonly capability: string;
readonly reason: string;
}
/** Stable label set on every budget-exceeded counter measurement. */
export interface BudgetBreachAttributes {
readonly plugin: string;
readonly budget: "cpuPercent" | "memoryMb" | "wallClockMs";
}
/** Configuration for {@link createPermissionInstruments}. */
export interface PermissionInstrumentsConfig {
/** Stable plugin id pre-tagged on every measurement. */
readonly plugin: string;
/** Optional override; defaults to the global meter provider. */
readonly meterProvider?: MeterProvider;
/** Meter name; defaults to `@suaveplan/api-sandbox-permissions`. */
readonly meterName?: string;
}
/**
* Bundle of every permission-layer counter, pre-bound to a meter
* created from the supplied (or global) MeterProvider. Both sandbox
* hosts construct exactly one instance per plugin-worker pair.
*/
export interface PermissionInstruments {
readonly plugin: string;
/**
* Increment the deny counter. Implementations MUST always emit the
* full `{ plugin, capability, reason }` label set so dashboards can
* aggregate over any subset without sparse-label fallout.
*/
recordDenial(attrs: Omit<PermissionDenyAttributes, "plugin">): void;
/**
* Increment the budget-exceeded counter. Same label-stability
* contract as {@link recordDenial}.
*/
recordBudgetBreach(attrs: Omit<BudgetBreachAttributes, "plugin">): void;
}
/**
* Build the deny + budget counter pair against the supplied
* MeterProvider. The function does not install an SDK — operator
* config owns SDK installation; this module only constructs
* instrument handles against whatever provider is registered (no-op
* provider by default — measurements vanish silently).
*/
export function createPermissionInstruments(
config: PermissionInstrumentsConfig
): PermissionInstruments {
const provider = config.meterProvider ?? metrics.getMeterProvider();
const meter: Meter = provider.getMeter(
config.meterName ?? "@suaveplan/api-sandbox-permissions"
);
const denied: OtelCounter = meter.createCounter(
"plugin.permission_denied_total",
{
description:
"Sandbox plugin capability call denied by PermissionChecker",
unit: "1",
}
);
const budget: OtelCounter = meter.createCounter(
"plugin.budget_exceeded_total",
{
description: "Sandbox plugin budget exceeded by BudgetTracker",
unit: "1",
}
);
const plugin = config.plugin;
return {
plugin,
recordDenial(attrs) {
denied.add(1, { plugin, ...attrs });
},
recordBudgetBreach(attrs) {
budget.add(1, { plugin, ...attrs });
},
};
}

View File

@@ -0,0 +1,14 @@
export {
compileFetchRule,
compileFsRule,
globToRegex,
normalisePath,
PermissionPolicy,
POLICY_MANIFEST_SCHEMA,
} from "./policy.js";
export type {
CompiledFetchRule,
CompiledFsRule,
PolicyManifestView,
ResolvedBudgets,
} from "./policy.types.js";

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,181 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
SAND_PERM_CODES,
SandboxPermissionError,
} from "../error-codes/error-codes.js";
import {
compileFetchRule,
compileFsRule,
globToRegex,
normalisePath,
PermissionPolicy,
} from "./policy.js";
describe("PermissionPolicy", () => {
it("PermissionPolicy reflects manifest verbatim", {
timeout: 5000,
}, () => {
const policy = new PermissionPolicy({
permissions: {
fs: ["./uploads/*"],
fetch: ["api.stripe.com"],
budgets: { wallClockMs: 30_000 },
},
});
expect(policy.fs).toHaveLength(1);
expect(policy.fs[0]?.source).toBe("./uploads/*");
expect(policy.fs[0]?.write).toBe(false);
expect(policy.fetch).toHaveLength(1);
expect(policy.fetch[0]?.source).toBe("api.stripe.com");
expect(policy.budgets.wallClockMs).toBe(30_000);
expect(policy.budgets.cpuPercent).toBe(Number.POSITIVE_INFINITY);
expect(policy.budgets.memoryMb).toBe(Number.POSITIVE_INFINITY);
});
it("empty manifest yields empty arrays and Infinity budgets", {
timeout: 5000,
}, () => {
const policy = new PermissionPolicy({});
expect(policy.fs).toEqual([]);
expect(policy.fetch).toEqual([]);
expect(policy.budgets.cpuPercent).toBe(Number.POSITIVE_INFINITY);
expect(policy.budgets.memoryMb).toBe(Number.POSITIVE_INFINITY);
expect(policy.budgets.wallClockMs).toBe(Number.POSITIVE_INFINITY);
});
it("rw: prefix on fs rule sets write=true", { timeout: 5000 }, () => {
const policy = new PermissionPolicy({
permissions: { fs: ["rw:./uploads/*"] },
});
expect(policy.fs[0]?.write).toBe(true);
});
it("invalid manifest throws SAND_PERM.DENIED", {
timeout: 5000,
}, () => {
let err: SandboxPermissionError | undefined;
try {
// @ts-expect-error — intentionally invalid input shape
new PermissionPolicy({ permissions: { fs: [123] } });
} catch (e) {
err = e as SandboxPermissionError;
}
expect(err).toBeInstanceOf(SandboxPermissionError);
expect(err?.code).toBe(SAND_PERM_CODES.DENIED);
});
});
describe("normalisePath", () => {
it("resolves .. and strips trailing slashes", {
timeout: 5000,
}, () => {
expect(normalisePath("./uploads/../etc/passwd")).toBe("./etc/passwd");
expect(normalisePath("./uploads/")).toBe("./uploads");
expect(normalisePath("./uploads/inner/")).toBe("./uploads/inner");
});
it("preserves absolute paths", { timeout: 5000 }, () => {
expect(normalisePath("/var/log/")).toBe("/var/log");
expect(normalisePath("/")).toBe("/");
});
it("normalises backslashes to posix", { timeout: 5000 }, () => {
expect(normalisePath(".\\uploads\\a")).toBe("./uploads/a");
});
it("collapses current-dir input to .", { timeout: 5000 }, () => {
expect(normalisePath(".")).toBe(".");
});
it("keeps .. escape paths without re-prefixing", {
timeout: 5000,
}, () => {
expect(normalisePath("..")).toBe("..");
expect(normalisePath("../sibling")).toBe("../sibling");
});
});
describe("globToRegex", () => {
it("translates * to single-segment match", { timeout: 5000 }, () => {
const re = globToRegex("./uploads/*");
expect(re.test("./uploads/a.png")).toBe(true);
expect(re.test("./uploads/sub/a.png")).toBe(false);
});
it("translates ** to multi-segment match", { timeout: 5000 }, () => {
const re = globToRegex("./uploads/**");
expect(re.test("./uploads/a")).toBe(true);
expect(re.test("./uploads/a/b/c")).toBe(true);
});
it("translates ? to single non-slash character", {
timeout: 5000,
}, () => {
const re = globToRegex("a?c");
expect(re.test("abc")).toBe(true);
expect(re.test("a/c")).toBe(false);
});
it("preserves character class [abc]", { timeout: 5000 }, () => {
const re = globToRegex("file.[tj]s");
expect(re.test("file.ts")).toBe(true);
expect(re.test("file.js")).toBe(true);
expect(re.test("file.cs")).toBe(false);
});
it("escapes regex metacharacters", { timeout: 5000 }, () => {
const re = globToRegex("a.b+c(d)");
expect(re.test("a.b+c(d)")).toBe(true);
expect(re.test("aXbXc-d-")).toBe(false);
});
it("treats unclosed [ as literal", { timeout: 5000 }, () => {
const re = globToRegex("a[b");
expect(re.test("a[b")).toBe(true);
});
});
describe("compileFsRule + compileFetchRule", () => {
it("compileFsRule extracts root prefix", { timeout: 5000 }, () => {
const rule = compileFsRule("./uploads/*");
expect(rule.root).toBe("./uploads");
});
it("compileFsRule literal-only rule has whole path as root", {
timeout: 5000,
}, () => {
const rule = compileFsRule("./uploads/exact.txt");
expect(rule.root).toBe("./uploads/exact.txt");
expect(rule.test("./uploads/exact.txt")).toBe(true);
});
it("compileFsRule wildcard-only with no slash before star", {
timeout: 5000,
}, () => {
const rule = compileFsRule("*.txt");
// root falls back to the empty prefix when no '/' precedes the wildcard
expect(rule.root).toBe("");
expect(rule.test("a.txt")).toBe(true);
});
it("compileFetchRule recognises *.suffix", { timeout: 5000 }, () => {
const rule = compileFetchRule("*.stripe.com");
expect(rule.test("eu.api.stripe.com")).toBe(true);
expect(rule.test("stripe.com")).toBe(false);
});
it("compileFetchRule recognises http: prefix", {
timeout: 5000,
}, () => {
const rule = compileFetchRule("http:localhost");
expect(rule.allowHttp).toBe(true);
expect(rule.test("localhost")).toBe(true);
});
it("compileFetchRule exact match is case-insensitive", {
timeout: 5000,
}, () => {
const rule = compileFetchRule("API.Stripe.COM");
expect(rule.test("api.stripe.com")).toBe(true);
});
});

View File

@@ -0,0 +1,254 @@
import { posix } from "node:path";
import z from "zod";
import {
SAND_PERM_CODES,
SandboxPermissionError,
} from "../error-codes/error-codes.js";
import type {
CompiledFetchRule,
CompiledFsRule,
PolicyManifestView,
ResolvedBudgets,
} from "./policy.types.js";
/**
* Zod schema applied at the ingestion boundary. The runtime types in
* `@suaveplan/api-types` already model the same shape structurally;
* this schema gives us the trust boundary for untrusted manifest
* payloads (worker boundary, hot-reload, admin patch).
*/
export const POLICY_MANIFEST_SCHEMA = z
.object({
permissions: z
.object({
fs: z.array(z.string().min(1)).optional(),
fetch: z.array(z.string().min(1)).optional(),
budgets: z
.object({
cpuPercent: z.number().positive().optional(),
memoryMb: z.number().positive().optional(),
wallClockMs: z.number().positive().optional(),
})
.optional(),
})
.optional(),
events: z
.object({
publishes: z.array(z.string().min(1)).optional(),
subscribes: z.array(z.string().min(1)).optional(),
})
.optional(),
database: z
.object({
required: z.boolean(),
schemaVersion: z.number(),
tablePrefix: z.string().min(1).optional(),
tenantIsolation: z
.enum(["row-level", "schema", "database"])
.optional(),
preferredEngine: z.string().optional(),
compatibleEngines: z.array(z.string()).optional(),
migrationsDir: z.string().optional(),
})
.optional(),
secrets: z
.array(
z.object({
name: z.string().min(1),
required: z.boolean(),
})
)
.optional(),
})
.strict();
/**
* Normalise a filesystem path for comparison against the allowlist.
* Resolves `..` segments and strips trailing slashes. Always returns a
* POSIX-style path; rules and check inputs are compared on the same
* canonical form regardless of host platform.
*/
export function normalisePath(input: string): string {
const cleaned = input.replace(/\\/g, "/");
if (cleaned.startsWith("/")) {
return posix.normalize(cleaned).replace(/\/+$/, "") || "/";
}
const hadDot = cleaned.startsWith("./") || cleaned === ".";
const normalised = posix.normalize(cleaned).replace(/\/+$/, "");
if (normalised === ".") {
return ".";
}
if (normalised.startsWith("../") || normalised === "..") {
// Path escaped its starting point — keep as-is (no leading ./).
return normalised;
}
if (hadDot) {
return `./${normalised}`;
}
return normalised;
}
/**
* Compile a single `permissions.fs[]` entry into a {@link CompiledFsRule}.
* The optional `rw:` prefix toggles write access; everything else is
* read-only.
*/
export function compileFsRule(rule: string): CompiledFsRule {
let source = rule;
let write = false;
if (rule.startsWith("rw:")) {
write = true;
source = rule.slice(3);
}
const normalised = normalisePath(source);
const root = extractGlobRoot(normalised);
const pattern = globToRegex(normalised);
return {
source: rule,
write,
root,
test(resolved: string): boolean {
return pattern.test(resolved);
},
};
}
/**
* Extract the literal-path prefix from a glob pattern (everything up to
* the first `*` / `?` / `[`). Used as the deny-reason boundary check —
* resolved paths that escape this prefix are rejected before the regex
* even runs.
*/
function extractGlobRoot(pattern: string): string {
const wildcardAt = pattern.search(/[*?[]/);
if (wildcardAt === -1) {
return pattern;
}
const prefix = pattern.slice(0, wildcardAt);
const lastSlash = prefix.lastIndexOf("/");
return lastSlash >= 0 ? prefix.slice(0, lastSlash) : prefix;
}
/**
* Translate a simple glob pattern (`*`, `**`, `?`, `[abc]`) into a
* Regex with anchored ends. The transformation is intentionally minimal
* — sandboxed permissions are simple allowlists, not full POSIX glob.
*/
export function globToRegex(pattern: string): RegExp {
let out = "";
let i = 0;
while (i < pattern.length) {
const ch = pattern[i] as string;
if (ch === "*") {
const next = pattern[i + 1];
if (next === "*") {
out += ".*";
i += 2;
continue;
}
out += "[^/]*";
i += 1;
continue;
}
if (ch === "?") {
out += "[^/]";
i += 1;
continue;
}
if (ch === "[") {
const close = pattern.indexOf("]", i + 1);
if (close !== -1) {
out += pattern.slice(i, close + 1);
i = close + 1;
continue;
}
// Unclosed bracket — treat as literal.
out += "\\[";
i += 1;
continue;
}
if (/[.+^${}()|\\]/.test(ch)) {
out += `\\${ch}`;
} else {
out += ch;
}
i += 1;
}
return new RegExp(`^${out}$`);
}
/**
* Compile a single `permissions.fetch[]` host pattern. Optional
* `http:` prefix toggles insecure-scheme permission.
*/
export function compileFetchRule(rule: string): CompiledFetchRule {
let allowHttp = false;
let source = rule;
if (rule.startsWith("http:")) {
allowHttp = true;
source = rule.slice(5);
}
const lowered = source.toLowerCase();
if (lowered.startsWith("*.")) {
const suffix = lowered.slice(2);
return {
source: rule,
allowHttp,
test(host) {
return host.endsWith(`.${suffix}`);
},
};
}
return {
source: rule,
allowHttp,
test(host) {
return host === lowered;
},
};
}
/**
* Resolved permission policy. Built once per plugin start; consumed
* by `PermissionChecker` and `BudgetTracker`.
*/
export class PermissionPolicy {
public readonly fs: readonly CompiledFsRule[];
public readonly fetch: readonly CompiledFetchRule[];
public readonly budgets: ResolvedBudgets;
public readonly manifest: PolicyManifestView;
/**
* Construct from a manifest view. The manifest is validated via
* {@link POLICY_MANIFEST_SCHEMA} so untrusted ingestion paths
* cannot bypass the contract. Throws `SandboxPermissionError`
* (code `SAND_PERM.DENIED`) when the input does not satisfy the
* schema — same code surface as a deny at request time, so
* upstream catch-blocks need only one branch.
*/
public constructor(manifest: PolicyManifestView) {
const parsed = POLICY_MANIFEST_SCHEMA.safeParse(manifest);
if (!parsed.success) {
throw new SandboxPermissionError(
SAND_PERM_CODES.DENIED,
"sandbox permission manifest failed validation",
{
context: {
capability: "policy",
reason: "manifest-invalid",
},
cause: parsed.error,
}
);
}
this.manifest = manifest;
this.fs = (manifest.permissions?.fs ?? []).map(compileFsRule);
this.fetch = (manifest.permissions?.fetch ?? []).map(compileFetchRule);
const budgets = manifest.permissions?.budgets ?? {};
this.budgets = {
cpuPercent: budgets.cpuPercent ?? Number.POSITIVE_INFINITY,
memoryMb: budgets.memoryMb ?? Number.POSITIVE_INFINITY,
wallClockMs: budgets.wallClockMs ?? Number.POSITIVE_INFINITY,
};
}
}

View File

@@ -0,0 +1,52 @@
import type {
DatabaseManifest,
EventManifest,
PermissionsManifest,
} from "@suaveplan/api-types/manifest";
/** Compiled glob row used by the filesystem checker. */
export interface CompiledFsRule {
/** Original rule string (kept for diagnostics — never leaked into errors). */
readonly source: string;
/** `true` when the rule was prefixed `rw:` — write access is permitted. */
readonly write: boolean;
/** Normalised allowlist root (path-prefix). */
readonly root: string;
/** Compiled glob test for the suffix portion (after the root). */
test(resolved: string): boolean;
}
/** Compiled host row used by the fetch checker. */
export interface CompiledFetchRule {
readonly source: string;
/** When `true`, the rule permits insecure `http:` requests. */
readonly allowHttp: boolean;
/** Test the URL's host (lowercased, no port). */
test(host: string): boolean;
}
/** Resolved per-request budgets, with `Infinity` for unset dimensions. */
export interface ResolvedBudgets {
readonly cpuPercent: number;
readonly memoryMb: number;
readonly wallClockMs: number;
}
/**
* The portion of the {@link PluginManifest} this layer needs at
* construction time. Kept narrow so the layer does not depend on
* unrelated fields (routes, jobs, telemetry, etc.).
*/
export interface PolicyManifestView {
/** Permissions block per design doc §3. */
readonly permissions?: PermissionsManifest;
/** Declared event topics — used by capability gating. */
readonly events?: EventManifest;
/** Declared database surface — used by capability gating. */
readonly database?: DatabaseManifest;
/** Manifest-declared secret entries. */
readonly secrets?: ReadonlyArray<{
readonly name: string;
readonly required: boolean;
}>;
}

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.

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { BudgetTracker } from "../budget/budget.js";
import { PermissionChecker } from "../checker/checker.js";
import {
makeFixtureChecker,
type PermissionsConformanceFixture,
runPermissionsConformance,
} from "./run-permissions-conformance.js";
describe("run-permissions-conformance — self-test", () => {
it("in-package fixture passes runPermissionsConformance", {
timeout: 5000,
}, () => {
// Sanity-check the factory produces a structurally-valid
// fixture before delegating to the full harness invocation
// below. The harness suite contributes the assertion-level
// proof against every documented scenario.
const factory = makeFixtureChecker();
const fx: PermissionsConformanceFixture = factory.make();
expect(fx.checker).toBeInstanceOf(PermissionChecker);
expect(fx.budget).toBeInstanceOf(BudgetTracker);
expect(typeof fx.snapshot).toBe("function");
expect(fx.snapshot()).toEqual({});
});
});
// Top-level harness invocation — exercises every documented
// allow / deny / budget scenario against the in-package fixture.
runPermissionsConformance("self", makeFixtureChecker());

View File

@@ -0,0 +1,259 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { BudgetTracker } from "../budget/budget.js";
import { PermissionChecker } from "../checker/checker.js";
import type { CheckResult } from "../checker/checker.types.js";
import {
SAND_PERM_CODES,
SandboxPermissionError,
} from "../error-codes/error-codes.js";
import { createPermissionInstruments } from "../instruments/instruments.js";
import { PermissionPolicy } from "../policy/policy.js";
/** Construction hooks the harness invokes to set up the system under test. */
export interface PermissionsConformanceFixture {
/** Plugin id used by both the policy + instruments. */
readonly plugin: string;
/** The checker the host wires into its capability proxy. */
readonly checker: PermissionChecker;
/** Budget tracker bound to the same policy + instruments. */
readonly budget: BudgetTracker;
/**
* Counter snapshot keyed by `${counter}|${capability}|${reason|budget}`.
* Tests assert specific keys increment.
*/
snapshot(): Readonly<Record<string, number>>;
}
/** Returned by the maker so the harness can drive deterministic time. */
export interface PermissionsConformanceFactory {
make(): PermissionsConformanceFixture;
}
/**
* Default in-package factory — wires the production `PermissionPolicy`,
* `PermissionChecker`, `BudgetTracker`, and a counter-capturing
* `MeterProvider`. Adapters that ship sandbox hosts override this
* factory to point at their own composition root before passing the
* factory back into the harness.
*/
export function makeFixtureChecker(
overrides?: Partial<{
clockStart: number;
clockStep: number;
}>
): PermissionsConformanceFactory {
const clockStart = overrides?.clockStart ?? 0;
const clockStep = overrides?.clockStep ?? 0;
return {
make(): PermissionsConformanceFixture {
const calls: Record<string, number> = {};
const provider = {
getMeter() {
return {
createCounter(name: string) {
return {
add(
value: number,
attrs: Readonly<Record<string, unknown>>
) {
const capability = String(
attrs["capability"] ?? attrs["budget"]
);
const reason = String(
attrs["reason"] ?? attrs["budget"]
);
const key = `${name}|${capability}|${reason}`;
calls[key] = (calls[key] ?? 0) + value;
},
};
},
};
},
};
const instruments = createPermissionInstruments({
plugin: "fixture",
meterProvider: provider as never,
});
const policy = new PermissionPolicy({
permissions: {
fs: ["./uploads/*"],
fetch: [
"api.stripe.com",
"*.example.com",
"http:localhost",
],
budgets: { wallClockMs: 100 },
},
database: {
required: true,
schemaVersion: 1,
tablePrefix: "billing_",
},
events: {
publishes: ["billing.invoice.created"],
subscribes: ["users.profile.updated"],
},
secrets: [{ name: "STRIPE_SECRET_KEY", required: true }],
});
const checker = new PermissionChecker({ policy, instruments });
let nowValue = clockStart;
const clock = () => {
const value = nowValue;
nowValue += clockStep;
return value;
};
const budget = new BudgetTracker({ policy, instruments, clock });
return {
plugin: "fixture",
checker,
budget,
snapshot: () => ({ ...calls }),
};
},
};
}
/**
* Shared conformance harness — every sandbox host MUST call this with
* its own `makeChecker` factory. The harness exercises every
* allow / deny / budget scenario documented in the spec delta against
* the supplied checker so any host that diverges from the contract is
* caught at test time.
*/
export function runPermissionsConformance(
label: string,
factory: PermissionsConformanceFactory
): void {
describe(`runPermissionsConformance(${label})`, () => {
it("allow path: fs read inside allowlist", { timeout: 5000 }, () => {
const fx = factory.make();
const result = fx.checker.checkFs("./uploads/avatar.png", "read");
expect(result.outcome).toBe("allow");
});
it("deny path: fs outside allowlist increments counter", {
timeout: 5000,
}, () => {
const fx = factory.make();
const result = fx.checker.checkFs("./etc/passwd", "read");
expect(result).toEqual({
outcome: "deny",
reason: "path-not-in-allowlist",
});
const snap = fx.snapshot();
expect(
snap["plugin.permission_denied_total|fs|path-not-in-allowlist"]
).toBe(1);
});
it("allow path: fetch exact host", { timeout: 5000 }, () => {
const fx = factory.make();
const result = fx.checker.checkFetch(
"https://api.stripe.com/v1/charges"
);
expect(result.outcome).toBe("allow");
});
it("deny path: fetch host outside allowlist", {
timeout: 5000,
}, () => {
const fx = factory.make();
const result = fx.checker.checkFetch("https://evil.example.org");
expect(result).toEqual({
outcome: "deny",
reason: "host-not-in-allowlist",
});
});
it("deny path: capability db table out of scope", {
timeout: 5000,
}, () => {
const fx = factory.make();
const result = fx.checker.checkCapability({
capability: "db",
table: "users_account",
});
expect(result.outcome).toBe("deny");
});
it("deny path: capability secrets undeclared name", {
timeout: 5000,
}, () => {
const fx = factory.make();
const result = fx.checker.checkCapability({
capability: "secrets",
name: "AWS_KEY",
});
expect(result.outcome).toBe("deny");
});
it("deny path: capability events publish undeclared topic", {
timeout: 5000,
}, () => {
const fx = factory.make();
const result = fx.checker.checkCapability({
capability: "events",
mode: "publish",
topic: "billing.unknown",
});
expect(result.outcome).toBe("deny");
});
it("PERMISSION_DENIED envelope carries capability+reason", {
timeout: 5000,
}, () => {
const fx = factory.make();
let captured: SandboxPermissionError | undefined;
try {
fx.checker.assertFs("./etc/passwd", "read");
} catch (err) {
captured = err as SandboxPermissionError;
}
expect(captured).toBeInstanceOf(SandboxPermissionError);
expect(captured?.code).toBe(SAND_PERM_CODES.DENIED);
// Context preserves capability + reason; message must not
// include the denied path.
expect(captured?.context?.["capability"]).toBe("fs");
expect(captured?.context?.["reason"]).toBe("path-not-in-allowlist");
expect(captured?.message).not.toContain("/etc/passwd");
});
it("BUDGET_EXCEEDED raised on wall-clock breach", {
timeout: 5000,
}, () => {
const fx = factory.make();
// Drive the clock past wallClockMs=100 by tick'ing once.
// The fixture's clock is constructed so the second
// `clock.now()` returns startedAt + clockStep — set
// clockStep above the budget by overriding the factory.
const driven = makeFixtureChecker({
clockStart: 0,
clockStep: 200,
}).make();
let captured: SandboxPermissionError | undefined;
try {
driven.budget.tick({ cpuPercent: 0, memoryMb: 0 });
} catch (err) {
captured = err as SandboxPermissionError;
}
// Either captured here or thrown inside tick — either
// way the fixture's `budget` should also surface the
// breach on a fresh budget tracker.
expect(captured?.code).toBe(SAND_PERM_CODES.BUDGET_EXCEEDED);
// Touch the harness-provided fixture to ensure it's
// wired (avoid unused-variable in the surface API).
expect(fx.budget).toBeInstanceOf(BudgetTracker);
});
it("CheckResult is the documented union shape", {
timeout: 5000,
}, () => {
const fx = factory.make();
const allowResult: CheckResult = fx.checker.checkFs(
"./uploads/x.png",
"read"
);
expect(allowResult.outcome).toBe("allow");
});
});
}

View File

@@ -0,0 +1,45 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("dep-graph", () => {
it("@suaveplan/* runtime deps are minimal (api-types, error, opentelemetry, types)", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as {
dependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
};
const runtime = Object.keys(pkg.dependencies ?? {})
.filter((name) => name.startsWith("@suaveplan/"))
.sort();
expect(runtime).toEqual([
"@suaveplan/api-types",
"@suaveplan/error",
"@suaveplan/opentelemetry",
"@suaveplan/types",
]);
const peers = Object.keys(pkg.peerDependencies ?? {});
expect(peers).toEqual([]);
});
it("full runtime dependency set is the documented allowlist", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { dependencies?: Record<string, string> };
const runtime = Object.keys(pkg.dependencies ?? {}).sort();
expect(runtime).toEqual([
"@opentelemetry/api",
"@suaveplan/api-types",
"@suaveplan/error",
"@suaveplan/opentelemetry",
"@suaveplan/types",
"tslib",
"zod",
]);
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,49 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../../node_modules/.vite/packages/api-sandbox-permissions",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"policy/policy": "src/policy/policy.ts",
"checker/checker": "src/checker/checker.ts",
"budget/budget": "src/budget/budget.ts",
"error-codes/error-codes": "src/error-codes/error-codes.ts",
"instruments/instruments": "src/instruments/instruments.ts",
"testing/run-permissions-conformance":
"src/testing/run-permissions-conformance.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
/^@suaveplan\//,
/^@opentelemetry\//,
/^node:/,
/^bun:/,
"zod",
"tslib",
],
},
},
});

View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});