first commit

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

View File

@@ -0,0 +1,98 @@
# fetch proxy
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`installFetchProxy` returns the outbound-HTTP surface visible inside
the sandbox. Every request gates through the supplied
`PermissionChecker` before reaching the host adapter, and denied calls
never touch the network — they raise `SAND_VM.PERMISSION_DENIED` and
increment `plugin.permission_denied_total{capability="fetch"}` without
opening a socket, performing a DNS lookup, or hitting the host's
connection pool.
The proxy intentionally exposes only a `request` method with a narrow
shape (`url`, optional `init`) so the sandboxed plugin cannot reach
ambient Node primitives like the connection pool, the DNS resolver,
the timeout default, or the raw streaming body. Anything richer is
the responsibility of an explicit capability (and arrives through
`installCapabilityProxies`).
## Features
- Synchronous permission check; the host adapter call is deferred
until allowance is confirmed.
- Closure-bound adapter — the sandbox cannot pry it back out of the
proxy receiver.
- Denial path records the `plugin` and `capability` attributes on the
shared `plugin.permission_denied_total` counter so dashboards see a
uniform denial-rate series across permission implementations.
- `URL`-parsed host match — the proxy receives the URL as a string and
the checker normalises it through `new URL(...).hostname`; an
unparseable URL is rejected by the checker before reaching the host.
## Usage: Basic
```ts
import { installFetchProxy } from "@suaveplan/api-sandbox-vm/proxies/fetch-proxy/fetch-proxy";
import { createPermissionChecker } from "@suaveplan/api-sandbox-vm/permissions/checker";
const checker = createPermissionChecker({
policy: { fetch: ["api.example.com"] },
});
const fetch = installFetchProxy({
checker,
pluginId: "demo",
adapter: { request: async (url) => realRequest(url) },
});
const res = await fetch.request("https://api.example.com/v1/users");
```
## Usage: Advanced
Wrap the adapter to enforce a per-tenant header before reaching the
upstream:
```ts
import { installFetchProxy } from "@suaveplan/api-sandbox-vm/proxies/fetch-proxy/fetch-proxy";
const adapter = {
request: async (
url: string,
init?: { readonly method?: string; readonly body?: string }
) => {
const merged = {
...init,
method: init?.method ?? "GET",
};
return realRequest(url, {
...merged,
// host-side adapter is free to attach additional headers
// that the sandbox cannot see or override.
});
},
};
const fetch = installFetchProxy({ checker, adapter, pluginId: "demo" });
```
## API Reference
- `installFetchProxy(options): FetchProxy` — build the proxy.
- `interface FetchProxy` — frozen receiver with `request(url, init?)`.
- `interface FetchHostAdapter` — the host-supplied counterpart that
performs the actual outbound HTTP request.
## Implementation Notes
The proxy emits the denial metric with a fixed two-attribute label set
(`plugin`, `capability`) so cardinality stays bounded. The
`reason` from the checker decision is folded into the error
`context` rather than into the metric attribute set so the metric
remains aggregatable. Adapter-side errors (network timeouts, host
unreachable, TLS failures) bubble up through the proxy unchanged; the
proxy only owns the policy boundary, not the transport-level error
mapping.

View File

@@ -0,0 +1,100 @@
# fs proxy
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`installFsProxy` returns the sandbox-side filesystem surface that
plugins consume. Every method gates the call through the supplied
`PermissionChecker` before delegating to the opaque host adapter, so a
denied call never reaches the host's `fs/promises` layer. The proxy is
closure-bound: the adapter reference is reachable only through the
returned methods, so the sandbox cannot retrieve the adapter from the
proxy receiver and bypass the permission gate.
This module owns the sandbox-side half of the `fs` capability surface;
the adapter on the other side of the closure is supplied by `api-host`
and performs the real `node:fs/promises` calls after the policy decision
has been confirmed.
## Features
- Permission-checked `readFile` and `writeFile` with policy decisions
computed synchronously and the host call deferred until allowance is
confirmed.
- Denials raise `SAND_VM.PERMISSION_DENIED` immediately
(`Promise.reject`) and increment the
`plugin.permission_denied_total` counter with
`{ plugin, capability: "fs", mode }` attributes — identical labels
to the metric emitted by `api-sandbox-permissions` so the host's
dashboard aggregates across implementations.
- Frozen receiver: the returned object is `Object.freeze`d and the
adapter reference is not exposed.
## Usage: Basic
```ts
import { installFsProxy } from "@suaveplan/api-sandbox-vm/proxies/fs-proxy/fs-proxy";
import { createPermissionChecker } from "@suaveplan/api-sandbox-vm/permissions/checker";
const checker = createPermissionChecker({
policy: { fs: ["/srv/data/**"] },
});
const fs = installFsProxy({
checker,
pluginId: "demo",
adapter: {
readFile: (p) => realReadFile(p),
writeFile: (p, d) => realWriteFile(p, d),
},
});
await fs.readFile("/srv/data/users.json");
```
## Usage: Advanced
Wrap the adapter to record host-side latency without polluting the
proxy contract:
```ts
import { installFsProxy } from "@suaveplan/api-sandbox-vm/proxies/fs-proxy/fs-proxy";
import { hrtime } from "node:process";
const adapter = {
readFile: async (p: string) => {
const start = hrtime.bigint();
try {
return await realReadFile(p);
} finally {
const elapsed = Number(hrtime.bigint() - start) / 1e6;
telemetry.recordFsLatency("read", elapsed);
}
},
writeFile: (p, d) => realWriteFile(p, d),
};
const fs = installFsProxy({ checker, adapter, pluginId: "demo" });
```
## API Reference
- `installFsProxy(options): FsProxy` — build the proxy.
`options.checker`, `options.adapter`, `options.pluginId` are all
required.
- `interface FsProxy` — frozen receiver with `readFile`, `writeFile`.
- `interface FsHostAdapter` — the host-supplied counterpart that
actually performs the IO.
## Implementation Notes
The proxy is the only place where sandbox-side `fs` calls cross the
permission boundary. The adapter MUST never be exposed back to the
sandbox; the closure-binding guarantees that property at the language
level. Denials are accounted for with the same metric name as the
sister `api-sandbox-permissions` package emits, so dashboards see one
denial-rate series rather than two. The `mode` attribute on the
counter is `"read"` or `"write"` — keeping it on the metric (rather
than in a separate counter) means cardinality remains bounded to two
values regardless of the path being requested.

View File

@@ -0,0 +1,99 @@
# Install capability proxies
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`installCapabilityProxies` wires the host-supplied capability bags
into the sandbox's `vm.Context`. The sandbox sees one consolidated
`__suaveplan` global containing `fs`, `fetch`, and a `capabilities`
map; every method on the map is wrapped so the permission checker is
consulted before the host adapter is reached. The receiver and every
nested bag are frozen so a sandboxed plugin cannot replace a
permission-gated method with an open one.
This module is the single source of truth for how capabilities cross
the sandbox-host boundary — both the fs/fetch proxies and the wider
capability surface enter through this entry point so the permission
gate is applied uniformly.
## Features
- One `__suaveplan` global with `fs`, `fetch`, `capabilities` —
installed as non-writable, non-configurable, enumerable so the
sandbox can read it but never replace it.
- Permission gating on every capability method: the checker decides
before the host adapter is called.
- Denials emit `plugin.permission_denied_total{plugin, capability}`
with stable label cardinality (no per-method label).
- Frozen receivers prevent monkey-patching from inside the sandbox.
## Usage: Basic
```ts
import { installCapabilityProxies } from "@suaveplan/api-sandbox-vm/proxies/install-capability-proxies/install-capability-proxies";
import { createContext } from "node:vm";
const context = createContext({});
installCapabilityProxies({
context,
checker,
fs,
fetch,
pluginId: "demo",
capabilities: {
db: {
query: async (sql: unknown) => realQuery(String(sql)),
},
},
});
```
## Usage: Advanced
Compose with a host-side audit sink so every denial is reproducible:
```ts
import { installCapabilityProxies } from "@suaveplan/api-sandbox-vm/proxies/install-capability-proxies/install-capability-proxies";
const wrapAudit = (checker: PermissionChecker, audit: AuditSink) => ({
...checker,
checkCapability(id: string, p: Record<string, unknown>) {
const d = checker.checkCapability(id, p);
if (!d.allow) audit.record({ capability: id, reason: d.reason });
return d;
},
});
installCapabilityProxies({
context,
checker: wrapAudit(checker, audit),
fs,
fetch,
capabilities,
pluginId: "demo",
});
```
## API Reference
- `installCapabilityProxies(options)` — wire the proxy surface into
`options.context`. Returns nothing; side-effects the context.
- `interface InstallCapabilityProxiesOptions` — `context`, `checker`,
`capabilities`, `fs`, `fetch`, `pluginId`.
- `type CapabilityMap` — frozen record of capability id → method bag.
- `type CapabilityBag` — frozen record of method name → async fn.
## Implementation Notes
The receiver is installed via `Object.defineProperty` with
`writable: false, configurable: false` so the sandbox cannot replace
or delete `__suaveplan`. Each method wrapper closes over the host
function reference; the closure is the only path to the host call, so
patching the wrapper from inside the sandbox would not bypass the
permission gate (it would only break the path that exists). The
`method` argument is not added to the OTel attribute set so
cardinality stays bounded; method-level granularity belongs in logs,
not metrics.