first commit
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user