Initial Commit
This commit is contained in:
99
packages/api-stack/api/api-sandbox-permissions/README.md
Normal file
99
packages/api-stack/api/api-sandbox-permissions/README.md
Normal 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`.
|
||||
|
||||
100
packages/api-stack/api/api-sandbox-permissions/package.json
Normal file
100
packages/api-stack/api/api-sandbox-permissions/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 } }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type {
|
||||
BudgetBreach,
|
||||
BudgetSample,
|
||||
BudgetTrackerOptions,
|
||||
} from "./budget.js";
|
||||
export { BudgetTracker } from "./budget.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { checkCapability } from "./capability.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { checkFetch } from "./fetch.js";
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { checkFs } from "./fs.js";
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { SandboxPermissionCode } from "./error-codes.js";
|
||||
export {
|
||||
SAND_PERM_CODES,
|
||||
SandboxPermissionError,
|
||||
} from "./error-codes.js";
|
||||
57
packages/api-stack/api/api-sandbox-permissions/src/index.ts
Normal file
57
packages/api-stack/api/api-sandbox-permissions/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,11 @@
|
||||
export type {
|
||||
BudgetBreachAttributes,
|
||||
PermissionCounterName,
|
||||
PermissionDenyAttributes,
|
||||
PermissionInstruments,
|
||||
PermissionInstrumentsConfig,
|
||||
} from "./instruments.js";
|
||||
export {
|
||||
createPermissionInstruments,
|
||||
PERMISSION_COUNTER_NAMES,
|
||||
} from "./instruments.js";
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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".
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}>;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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());
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user