Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,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,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,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,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,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,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,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,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.
|
||||
Reference in New Issue
Block a user