Initial Commit
This commit is contained in:
92
packages/api-stack/api/api-sandbox-vm/README.md
Normal file
92
packages/api-stack/api/api-sandbox-vm/README.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# @suaveplan/api-sandbox-vm - vm.Context-based sandbox host for Suaveplan API plugins running in the sandboxed tier. Builds a frozen-globals vm.Context, loads the plugin via vm.SourceTextModule, rejects dynamic-code constructors and node built-in imports, and routes every fs/fetch/capability call through a host-installed permission-gated proxy surface. Server-tier.
|
||||
|
||||
`vm.Context`-based sandbox host for Suaveplan API plugins running in
|
||||
the sandboxed tier. The package owns the `vm.Context`+frozen-globals
|
||||
half of the design doc §2.2 sandboxed tier; the worker-thread bulkhead
|
||||
is owned by `@suaveplan/api-worker-host` and the permission policy is
|
||||
owned by `@suaveplan/api-sandbox-permissions`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable `SAND_VM.*` error-code registry used by every public failure path inside the `@suaveplan/ap…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable `SAND_VM.*` error-code registry used by every public failure path inside the `@suaveplan/ap…
|
||||
- [frozen-globals](./src/globals/frozen-globals/frozen-globals.md) — This module assembles the curated global allowlist visible inside the `vm.Context` sandbox. Every binding installed her…
|
||||
- [frozen-globals](./src/globals/frozen-globals/frozen-globals.md) — This module assembles the curated global allowlist visible inside the `vm.Context` sandbox. Every binding installed her…
|
||||
- [otel](./src/otel/instruments.md) — This module owns the OpenTelemetry instrument bundle emitted by the `vm.Context` sandbox host. Instruments are bound la…
|
||||
- [instruments](./src/otel/instruments.md) — This module owns the OpenTelemetry instrument bundle emitted by the `vm.Context` sandbox host. Instruments are bound la…
|
||||
- [permissions](./src/permissions/checker.md) — `createPermissionChecker` compiles a `PermissionsManifest` (from `@suaveplan/api-types`) into a structural `PermissionC…
|
||||
- [checker](./src/permissions/checker.md) — `createPermissionChecker` compiles a `PermissionsManifest` (from `@suaveplan/api-types`) into a structural `PermissionC…
|
||||
- [fetch-proxy](./src/proxies/fetch-proxy/fetch-proxy.md) — `installFetchProxy` returns the outbound-HTTP surface visible inside the sandbox. Every request gates through the suppl…
|
||||
- [fetch-proxy](./src/proxies/fetch-proxy/fetch-proxy.md) — `installFetchProxy` returns the outbound-HTTP surface visible inside the sandbox. Every request gates through the suppl…
|
||||
- [fs-proxy](./src/proxies/fs-proxy/fs-proxy.md) — `installFsProxy` returns the sandbox-side filesystem surface that plugins consume. Every method gates the call through…
|
||||
- [fs-proxy](./src/proxies/fs-proxy/fs-proxy.md) — `installFsProxy` returns the sandbox-side filesystem surface that plugins consume. Every method gates the call through…
|
||||
- [install-capability-proxies](./src/proxies/install-capability-proxies/install-capability-proxies.md) — `installCapabilityProxies` wires the host-supplied capability bags into the sandbox's `vm.Context`. The sandbox sees on…
|
||||
- [install-capability-proxies](./src/proxies/install-capability-proxies/install-capability-proxies.md) — `installCapabilityProxies` wires the host-supplied capability bags into the sandbox's `vm.Context`. The sandbox sees on…
|
||||
- [sandbox](./src/sandbox/sandbox.md) — `createVmSandbox` is the keystone entry point of the package. It materialises a `vm.Context` whose `globalThis` is seal…
|
||||
- [sandbox](./src/sandbox/sandbox.md) — `createVmSandbox` is the keystone entry point of the package. It materialises a `vm.Context` whose `globalThis` is seal…
|
||||
- [testing](./src/testing/run-vm-conformance.md) — `runVmSandboxConformance(label, makeSandbox)` is the public conformance suite every `vm.Context`-based sandbox host mus…
|
||||
- [run-vm-conformance](./src/testing/run-vm-conformance.md) — `runVmSandboxConformance(label, makeSandbox)` is the public conformance suite every `vm.Context`-based sandbox host mus…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Modules
|
||||
|
||||
| Subpath | Purpose |
|
||||
|---|---|
|
||||
| `./sandbox/sandbox` | `createVmSandbox`, `VmSandboxHandle` — builds the frozen `vm.Context` and drives the plugin lifecycle. |
|
||||
| `./globals/frozen-globals/frozen-globals` | Curated allowlist of globals visible inside the sandbox. |
|
||||
| `./proxies/fs-proxy/fs-proxy` | Host-proxied filesystem with permission-gated `readFile`/`writeFile`. |
|
||||
| `./proxies/fetch-proxy/fetch-proxy` | Host-proxied outbound HTTP with permission gating. |
|
||||
| `./proxies/install-capability-proxies/install-capability-proxies` | Wires the consolidated `__suaveplan` receiver into the context. |
|
||||
| `./permissions/checker` | In-package default `PermissionChecker` built from a `PermissionsManifest`. |
|
||||
| `./error-codes/error-codes` | `SAND_VM.*` registry + `SandboxVmError`. |
|
||||
| `./otel/instruments` | OTel histogram + three counters (boot duration, eval rejection, module-import rejection, permission denial). |
|
||||
| `./testing` | `runVmSandboxConformance(label, makeSandbox)` — the conformance harness every vm sandbox host must pass. |
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import {
|
||||
createVmSandbox,
|
||||
createPermissionChecker,
|
||||
} from "@suaveplan/api-sandbox-vm";
|
||||
|
||||
const checker = createPermissionChecker({
|
||||
policy: { fs: ["/srv/data/**"], fetch: ["api.example.com"] },
|
||||
});
|
||||
|
||||
const handle = createVmSandbox({
|
||||
pluginId: "demo",
|
||||
logger,
|
||||
checker,
|
||||
pluginModule: { kind: "path", path: "/srv/plugins/demo.mjs" },
|
||||
proxies: {
|
||||
fs: realFsAdapter,
|
||||
fetch: realFetchAdapter,
|
||||
capabilities: {},
|
||||
},
|
||||
});
|
||||
|
||||
await handle.start();
|
||||
// ... later
|
||||
handle.dispose();
|
||||
```
|
||||
|
||||
## Runtime requirement
|
||||
|
||||
Node ≥ 20 with `--experimental-vm-modules` enabled at process start.
|
||||
`vm.SourceTextModule` is gated behind that flag; the sandbox raises
|
||||
`SAND_VM.SOURCE_TEXT_MODULE_FAILED` with a clear hint when the flag is
|
||||
missing.
|
||||
|
||||
## Conformance
|
||||
|
||||
Pass the package's testing harness against any custom `vm`-based
|
||||
sandbox host:
|
||||
|
||||
```ts
|
||||
import { runVmSandboxConformance } from "@suaveplan/api-sandbox-vm/testing";
|
||||
|
||||
runVmSandboxConformance("my-host", makeMyHandle);
|
||||
```
|
||||
|
||||
122
packages/api-stack/api/api-sandbox-vm/package.json
Normal file
122
packages/api-stack/api/api-sandbox-vm/package.json
Normal file
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"name": "@suaveplan/api-sandbox-vm",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "vm.Context-based sandbox host for Suaveplan API plugins running in the sandboxed tier. Builds a frozen-globals vm.Context, loads the plugin via vm.SourceTextModule, rejects dynamic-code constructors and node built-in imports, and routes every fs/fetch/capability call through a host-installed permission-gated proxy surface. Server-tier.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"./sandbox/sandbox": {
|
||||
"import": {
|
||||
"types": "./dist/sandbox/sandbox.d.ts",
|
||||
"default": "./dist/sandbox/sandbox.js"
|
||||
}
|
||||
},
|
||||
"./globals/frozen-globals/frozen-globals": {
|
||||
"import": {
|
||||
"types": "./dist/globals/frozen-globals/frozen-globals.d.ts",
|
||||
"default": "./dist/globals/frozen-globals/frozen-globals.js"
|
||||
}
|
||||
},
|
||||
"./proxies/fs-proxy/fs-proxy": {
|
||||
"import": {
|
||||
"types": "./dist/proxies/fs-proxy/fs-proxy.d.ts",
|
||||
"default": "./dist/proxies/fs-proxy/fs-proxy.js"
|
||||
}
|
||||
},
|
||||
"./proxies/fetch-proxy/fetch-proxy": {
|
||||
"import": {
|
||||
"types": "./dist/proxies/fetch-proxy/fetch-proxy.d.ts",
|
||||
"default": "./dist/proxies/fetch-proxy/fetch-proxy.js"
|
||||
}
|
||||
},
|
||||
"./proxies/install-capability-proxies/install-capability-proxies": {
|
||||
"import": {
|
||||
"types": "./dist/proxies/install-capability-proxies/install-capability-proxies.d.ts",
|
||||
"default": "./dist/proxies/install-capability-proxies/install-capability-proxies.js"
|
||||
}
|
||||
},
|
||||
"./permissions/checker": {
|
||||
"import": {
|
||||
"types": "./dist/permissions/checker.d.ts",
|
||||
"default": "./dist/permissions/checker.js"
|
||||
}
|
||||
},
|
||||
"./error-codes/error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
}
|
||||
},
|
||||
"./otel/instruments": {
|
||||
"import": {
|
||||
"types": "./dist/otel/instruments.d.ts",
|
||||
"default": "./dist/otel/instruments.js"
|
||||
}
|
||||
},
|
||||
"./testing": {
|
||||
"import": {
|
||||
"types": "./dist/testing/run-vm-conformance.d.ts",
|
||||
"default": "./dist/testing/run-vm-conformance.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"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/logger": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "1.30.0",
|
||||
"@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,92 @@
|
||||
# Error codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the stable `SAND_VM.*` error-code registry used by every
|
||||
public failure path inside the `@suaveplan/api-sandbox-vm` package. The
|
||||
registry is built once via `defineCodes("SAND_VM", […])` so the
|
||||
namespace prefix and per-code suffix are co-derived — that guarantee
|
||||
lets the host's lifecycle driver, the audit sink, and the conformance
|
||||
harness branch on literal code strings without ever decoding free-form
|
||||
error messages.
|
||||
|
||||
Side-effect-importing this module from `src/index.ts` ensures every
|
||||
consumer that imports the package barrel (or any subpath export)
|
||||
participates in the same code registration, which is what
|
||||
`scripts/conformance.ts` validates.
|
||||
|
||||
## Features
|
||||
|
||||
- Five stable codes (`EVAL_REJECTED`, `INDIRECT_EVAL_REJECTED`,
|
||||
`MODULE_IMPORT_FORBIDDEN`, `SOURCE_TEXT_MODULE_FAILED`,
|
||||
`PERMISSION_DENIED`) — every failure surface in the package maps to
|
||||
exactly one.
|
||||
- A typed `SandboxVmError` subclass of `SuaveplanError` so consumers
|
||||
may discriminate via `instanceof` or by `code` string.
|
||||
- Wire-stable `code` string: the namespaced form (e.g.
|
||||
`"SAND_VM.EVAL_REJECTED"`) is the value used in OTel attributes,
|
||||
audit logs, and IPC envelopes back to the host.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
SAND_VM_CODES,
|
||||
SandboxVmError,
|
||||
} from "@suaveplan/api-sandbox-vm/error-codes/error-codes";
|
||||
|
||||
throw new SandboxVmError(
|
||||
SAND_VM_CODES.EVAL_REJECTED,
|
||||
"dynamic code evaluation is disabled in the vm sandbox"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Use the `context` option to attach a redaction-safe payload that flows
|
||||
through to OTel span attributes and the lifecycle driver's crash
|
||||
report:
|
||||
|
||||
```ts
|
||||
import {
|
||||
SAND_VM_CODES,
|
||||
SandboxVmError,
|
||||
} from "@suaveplan/api-sandbox-vm/error-codes/error-codes";
|
||||
|
||||
function denyOutboundFetch(host: string, reason: string): never {
|
||||
throw new SandboxVmError(
|
||||
SAND_VM_CODES.PERMISSION_DENIED,
|
||||
`outbound HTTP to ${host} denied by policy`,
|
||||
{
|
||||
context: { capability: "fetch", host, reason },
|
||||
}
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SAND_VM_CODES` — frozen record mapping enum key (e.g.
|
||||
`EVAL_REJECTED`) to the namespaced string code
|
||||
(`"SAND_VM.EVAL_REJECTED"`).
|
||||
- `type SandboxVmCode` — the literal union of every value in
|
||||
`SAND_VM_CODES`.
|
||||
- `class SandboxVmError extends SuaveplanError<SandboxVmCode>` —
|
||||
constructor takes `(code, message, options?)`. `options.context` is
|
||||
forwarded through the base class redaction pipeline.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry is declared `as const` so the compiler retains literal
|
||||
types (essential for exhaustive `switch` narrowing in consumers). The
|
||||
class sets `this.name = "SandboxVmError"` so heap snapshots, sourcemaps
|
||||
and uncaught-exception handlers attribute failures to this package
|
||||
rather than the generic `SuaveplanError` super-class. Registry
|
||||
ordering reflects severity in roughly increasing order — the first two
|
||||
entries reflect deliberate plugin policy violations, the next two
|
||||
reflect host-environment misconfiguration, and the last reflects
|
||||
permission-policy denials that fan in from `api-sandbox-permissions`.
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SAND_VM_CODES, SandboxVmError } from "./error-codes.js";
|
||||
|
||||
describe("SAND_VM_CODES", () => {
|
||||
it("namespaces every code with SAND_VM.", { timeout: 5000 }, () => {
|
||||
for (const v of Object.values(SAND_VM_CODES)) {
|
||||
expect(v.startsWith("SAND_VM.")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("declares five stable codes", { timeout: 5000 }, () => {
|
||||
const codes = Object.values(SAND_VM_CODES).sort();
|
||||
expect(codes).toEqual([
|
||||
"SAND_VM.EVAL_REJECTED",
|
||||
"SAND_VM.INDIRECT_EVAL_REJECTED",
|
||||
"SAND_VM.MODULE_IMPORT_FORBIDDEN",
|
||||
"SAND_VM.PERMISSION_DENIED",
|
||||
"SAND_VM.SOURCE_TEXT_MODULE_FAILED",
|
||||
]);
|
||||
});
|
||||
|
||||
it("SandboxVmError carries code and is instanceof Error", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new SandboxVmError(SAND_VM_CODES.EVAL_REJECTED, "x");
|
||||
expect(err.code).toBe("SAND_VM.EVAL_REJECTED");
|
||||
expect(err.name).toBe("SandboxVmError");
|
||||
expect(err instanceof Error).toBe(true);
|
||||
});
|
||||
|
||||
it("SandboxVmError forwards options.context", { timeout: 5000 }, () => {
|
||||
const err = new SandboxVmError(
|
||||
SAND_VM_CODES.PERMISSION_DENIED,
|
||||
"denied",
|
||||
{ context: { plugin: "demo" } }
|
||||
);
|
||||
expect(err.context?.plugin).toBe("demo");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/api-sandbox-vm`.
|
||||
*
|
||||
* Every failure path inside the `vm.Context` sandbox host raises a
|
||||
* {@link SandboxVmError} whose `code` is one of the namespaced
|
||||
* `SAND_VM.*` strings declared below. Consumers branch on the literal
|
||||
* code values (e.g. `error.code === SAND_VM_CODES.EVAL_REJECTED`).
|
||||
*
|
||||
* The registry is intentionally narrow:
|
||||
*
|
||||
* - `EVAL_REJECTED` — direct dynamic-code execution (the `eval` global
|
||||
* or the `Function` family of constructors) was reached from inside
|
||||
* the sandbox. Always synchronous-rejection at evaluation time.
|
||||
* - `INDIRECT_EVAL_REJECTED` — indirect dynamic execution (the
|
||||
* `(0, eval)("…")` pattern, or a comma-operator dereference of the
|
||||
* global identifier) tripped the global-property trap.
|
||||
* - `MODULE_IMPORT_FORBIDDEN` — `vm.SourceTextModule` linker refused
|
||||
* an import specifier that fell outside the plugin's module graph
|
||||
* (node built-ins, bare specifiers, parent-traversal paths, etc.).
|
||||
* - `SOURCE_TEXT_MODULE_FAILED` — `SourceTextModule.evaluate()` threw
|
||||
* or the host environment is missing the `--experimental-vm-modules`
|
||||
* flag at process start.
|
||||
* - `PERMISSION_DENIED` — a capability proxy was invoked with a target
|
||||
* outside the resolved {@link PermissionPolicy}. The surfacing of
|
||||
* this code inside the sandbox mirrors the wire shape that
|
||||
* `api-sandbox-permissions` uses on the host side, so plugin code
|
||||
* written against either tier observes the same failure mode.
|
||||
*/
|
||||
export const SAND_VM_CODES = defineCodes("SAND_VM", [
|
||||
"EVAL_REJECTED",
|
||||
"INDIRECT_EVAL_REJECTED",
|
||||
"MODULE_IMPORT_FORBIDDEN",
|
||||
"SOURCE_TEXT_MODULE_FAILED",
|
||||
"PERMISSION_DENIED",
|
||||
] as const);
|
||||
|
||||
/** Union of every fully-qualified code in {@link SAND_VM_CODES}. */
|
||||
export type SandboxVmCode = (typeof SAND_VM_CODES)[keyof typeof SAND_VM_CODES];
|
||||
|
||||
/**
|
||||
* Typed error class for `@suaveplan/api-sandbox-vm`. Extends
|
||||
* {@link SuaveplanError} so callers may discriminate via either
|
||||
* `instanceof SandboxVmError` or by code string.
|
||||
*/
|
||||
export class SandboxVmError extends SuaveplanError<SandboxVmCode> {
|
||||
public constructor(
|
||||
code: SandboxVmCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.name = "SandboxVmError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
SAND_VM_CODES,
|
||||
type SandboxVmCode,
|
||||
SandboxVmError,
|
||||
} from "./error-codes.js";
|
||||
@@ -0,0 +1,95 @@
|
||||
# Frozen globals
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module assembles the curated global allowlist visible inside the
|
||||
`vm.Context` sandbox. Every binding installed here is intentional —
|
||||
the design philosophy is fail-closed: anything not in the bundle is
|
||||
absent, so sandboxed code raises `ReferenceError` when it reaches for
|
||||
an unanticipated global. The narrow surface keeps the auditable
|
||||
attack surface of the sandboxed tier tiny and makes the security
|
||||
review a one-page exercise rather than a tour of Node's runtime.
|
||||
|
||||
The allowlist is mandated by the design doc §2.2 and `api-sandbox-vm`
|
||||
spec ADDED requirement "Sandbox boots inside a frozen `vm.Context`".
|
||||
It MUST stay synchronised with that spec; adding or removing an entry
|
||||
requires an OpenSpec change.
|
||||
|
||||
## Features
|
||||
|
||||
- Five spec-mandated globals (`URL`, `URLSearchParams`, `TextEncoder`,
|
||||
`TextDecoder`, `structuredClone`) installed by reference so they
|
||||
share an identity with the host's intrinsics.
|
||||
- A logger-routed `console` whose five methods (`log`, `info`, `warn`,
|
||||
`error`, `debug`) forward through the host's `LoggerLike` instance.
|
||||
No raw stdio handle is exposed to sandboxed code.
|
||||
- A trimmed `crypto` surface that exposes only `getRandomValues` and
|
||||
`randomUUID`. The full `SubtleCrypto` API is intentionally withheld
|
||||
because most sandboxed plugins should request the `crypto`
|
||||
capability rather than reach for the ambient global.
|
||||
- `FROZEN_GLOBAL_KEYS` — the literal key set, exposed so the
|
||||
conformance suite asserts the surface is exactly what the spec
|
||||
mandates and nothing accidentally leaked.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { buildFrozenGlobals } from "@suaveplan/api-sandbox-vm/globals/frozen-globals/frozen-globals";
|
||||
|
||||
const bundle = buildFrozenGlobals({
|
||||
logger: console as never, // any LoggerLike
|
||||
});
|
||||
bundle.console.info("hello from inside the sandbox");
|
||||
bundle.crypto.randomUUID();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Combine with a custom logger that tags every entry with the plugin id:
|
||||
|
||||
```ts
|
||||
import { buildFrozenGlobals } from "@suaveplan/api-sandbox-vm/globals/frozen-globals/frozen-globals";
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
|
||||
function pluginLogger(base: LoggerLike, pluginId: string): LoggerLike {
|
||||
return {
|
||||
debug: (m, ctx) => base.debug(m, { ...ctx, pluginId }),
|
||||
info: (m, ctx) => base.info(m, { ...ctx, pluginId }),
|
||||
warn: (m, ctx) => base.warn(m, { ...ctx, pluginId }),
|
||||
error: (m, ctx) => base.error(m, { ...ctx, pluginId }),
|
||||
};
|
||||
}
|
||||
|
||||
const bundle = buildFrozenGlobals({
|
||||
logger: pluginLogger(realLogger, "demo-plugin"),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `buildFrozenGlobals(options): FrozenGlobalsBundle` — produce the
|
||||
frozen allowlist for one sandbox instance.
|
||||
- `interface FrozenGlobalsBundle` — typed bundle with `URL`,
|
||||
`URLSearchParams`, `TextEncoder`, `TextDecoder`, `structuredClone`,
|
||||
`console`, `crypto`.
|
||||
- `interface SandboxConsole` — the five-method console surface.
|
||||
- `interface SandboxCrypto` — the two-method RNG surface.
|
||||
- `FROZEN_GLOBAL_KEYS` — the canonical key list (used by tests).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The five spec-mandated globals are exported by reference rather than
|
||||
by clone. This is safe because `vm.Context.runInContext` does not let
|
||||
the sandbox mutate the prototype of an intrinsic on the host side
|
||||
(modifications inside the sandbox apply to the sandbox's own
|
||||
intrinsics chain). The `crypto.getRandomValues` shim is a thin pass
|
||||
through `node:crypto.webcrypto.getRandomValues` so the sandbox sees
|
||||
the same RNG quality as the host — handcrafting a polyfill would be
|
||||
strictly worse than delegating. The `randomUUID` shim delegates
|
||||
identically so its v4 properties are preserved. The two intrinsics
|
||||
are themselves wrapped in a frozen object so the sandbox cannot
|
||||
overwrite `crypto.randomUUID` and observe a different RNG.
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
import { buildFrozenGlobals, FROZEN_GLOBAL_KEYS } from "./frozen-globals.js";
|
||||
|
||||
function recordingLogger(): {
|
||||
readonly logger: LoggerLike;
|
||||
readonly entries: Array<{ level: string; message: string }>;
|
||||
} {
|
||||
const entries: Array<{ level: string; message: string }> = [];
|
||||
return {
|
||||
entries,
|
||||
logger: {
|
||||
debug: (m) => entries.push({ level: "debug", message: m }),
|
||||
info: (m) => entries.push({ level: "info", message: m }),
|
||||
warn: (m) => entries.push({ level: "warn", message: m }),
|
||||
error: (m) => entries.push({ level: "error", message: m }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildFrozenGlobals", () => {
|
||||
it("returns a frozen bundle with exactly the spec-mandated keys", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { logger } = recordingLogger();
|
||||
const bundle = buildFrozenGlobals({ logger });
|
||||
expect(Object.isFrozen(bundle)).toBe(true);
|
||||
const keys = Object.keys(bundle).sort();
|
||||
expect(keys).toEqual([...FROZEN_GLOBAL_KEYS].sort());
|
||||
});
|
||||
|
||||
it("console.log routes to logger.info", { timeout: 5000 }, () => {
|
||||
const { logger, entries } = recordingLogger();
|
||||
const bundle = buildFrozenGlobals({ logger });
|
||||
bundle.console.log("hello", { x: 1 });
|
||||
expect(entries[0]?.level).toBe("info");
|
||||
expect(entries[0]?.message).toContain("hello");
|
||||
expect(entries[0]?.message).toContain('"x":1');
|
||||
});
|
||||
|
||||
it("console maps debug, info, warn, error", { timeout: 5000 }, () => {
|
||||
const { logger, entries } = recordingLogger();
|
||||
const bundle = buildFrozenGlobals({ logger });
|
||||
bundle.console.debug("d");
|
||||
bundle.console.info("i");
|
||||
bundle.console.warn("w");
|
||||
bundle.console.error("e");
|
||||
expect(entries.map((e) => e.level)).toEqual([
|
||||
"debug",
|
||||
"info",
|
||||
"warn",
|
||||
"error",
|
||||
]);
|
||||
});
|
||||
|
||||
it("console serialises circular refs without crashing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { logger, entries } = recordingLogger();
|
||||
const bundle = buildFrozenGlobals({ logger });
|
||||
const circ: Record<string, unknown> = {};
|
||||
circ.self = circ;
|
||||
bundle.console.log(circ);
|
||||
expect(entries[0]?.message).toBe("[object Object]");
|
||||
});
|
||||
|
||||
it("crypto.getRandomValues fills the view in place", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { logger } = recordingLogger();
|
||||
const bundle = buildFrozenGlobals({ logger });
|
||||
const buf = new Uint8Array(8);
|
||||
bundle.crypto.getRandomValues(buf);
|
||||
expect(buf.some((b) => b !== 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("crypto.getRandomValues passes null through unchanged", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { logger } = recordingLogger();
|
||||
const bundle = buildFrozenGlobals({ logger });
|
||||
expect(bundle.crypto.getRandomValues(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("crypto.randomUUID returns a v4-shaped string", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { logger } = recordingLogger();
|
||||
const bundle = buildFrozenGlobals({ logger });
|
||||
expect(bundle.crypto.randomUUID()).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { webcrypto } from "node:crypto";
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
|
||||
/**
|
||||
* Curated allowlist of globals that the `vm.Context` sandbox sees.
|
||||
*
|
||||
* Everything not present in this record is unreachable from sandboxed
|
||||
* code — references resolve to `ReferenceError` because the context's
|
||||
* `globalThis` is frozen empty before this allowlist is installed.
|
||||
*
|
||||
* The allowlist is shaped by design doc §2.2 and §5.2b:
|
||||
*
|
||||
* - Spec-mandated globals: `URL`, `URLSearchParams`, `TextEncoder`,
|
||||
* `TextDecoder`, `structuredClone`.
|
||||
* - Logger-routed `console`: a thin shim whose `log` / `info` /
|
||||
* `warn` / `error` / `debug` methods forward into the host's
|
||||
* `LoggerLike` instance. No raw stdio handle is exposed.
|
||||
* - Subset of `crypto`: only `getRandomValues` and `randomUUID` are
|
||||
* exposed. The full WebCrypto SubtleCrypto API is intentionally
|
||||
* withheld in this default surface because most sandboxed plugins
|
||||
* should request the `crypto` capability rather than reach for the
|
||||
* ambient global.
|
||||
*
|
||||
* `process`, `Buffer`, raw `require`, `import.meta`, and node built-in
|
||||
* modules are absent. Dynamic-code constructors (`Function`,
|
||||
* `AsyncFunction`, `GeneratorFunction`, `AsyncGeneratorFunction`) and
|
||||
* the `eval` identifier are explicitly trapped to throw rather than
|
||||
* silently absent — see `sandbox.ts` for the install path.
|
||||
*/
|
||||
export interface FrozenGlobalsOptions {
|
||||
/** Logger that backs the sandbox-side `console` surface. */
|
||||
readonly logger: LoggerLike;
|
||||
}
|
||||
|
||||
/** Materialised allowlist returned by {@link buildFrozenGlobals}. */
|
||||
export interface FrozenGlobalsBundle {
|
||||
readonly URL: typeof URL;
|
||||
readonly URLSearchParams: typeof URLSearchParams;
|
||||
readonly TextEncoder: typeof TextEncoder;
|
||||
readonly TextDecoder: typeof TextDecoder;
|
||||
readonly structuredClone: typeof structuredClone;
|
||||
readonly console: SandboxConsole;
|
||||
readonly crypto: SandboxCrypto;
|
||||
}
|
||||
|
||||
/** Logger-routed `console` surface visible to sandboxed code. */
|
||||
export interface SandboxConsole {
|
||||
readonly log: (...args: ReadonlyArray<unknown>) => void;
|
||||
readonly info: (...args: ReadonlyArray<unknown>) => void;
|
||||
readonly warn: (...args: ReadonlyArray<unknown>) => void;
|
||||
readonly error: (...args: ReadonlyArray<unknown>) => void;
|
||||
readonly debug: (...args: ReadonlyArray<unknown>) => void;
|
||||
}
|
||||
|
||||
/** Trimmed crypto surface — only RNG primitives, no Subtle. */
|
||||
export interface SandboxCrypto {
|
||||
readonly getRandomValues: <T extends ArrayBufferView | null>(view: T) => T;
|
||||
readonly randomUUID: () => `${string}-${string}-${string}-${string}-${string}`;
|
||||
}
|
||||
|
||||
function joinArgs(args: ReadonlyArray<unknown>): string {
|
||||
return args
|
||||
.map((a) => {
|
||||
if (typeof a === "string") return a;
|
||||
try {
|
||||
return JSON.stringify(a);
|
||||
} catch {
|
||||
return String(a);
|
||||
}
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function buildConsole(logger: LoggerLike): SandboxConsole {
|
||||
return Object.freeze<SandboxConsole>({
|
||||
log: (...args) => {
|
||||
logger.info(joinArgs(args));
|
||||
},
|
||||
info: (...args) => {
|
||||
logger.info(joinArgs(args));
|
||||
},
|
||||
warn: (...args) => {
|
||||
logger.warn(joinArgs(args));
|
||||
},
|
||||
error: (...args) => {
|
||||
logger.error(joinArgs(args));
|
||||
},
|
||||
debug: (...args) => {
|
||||
logger.debug(joinArgs(args));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function buildCrypto(): SandboxCrypto {
|
||||
return Object.freeze<SandboxCrypto>({
|
||||
getRandomValues: <T extends ArrayBufferView | null>(view: T): T => {
|
||||
if (view === null) return view;
|
||||
webcrypto.getRandomValues(
|
||||
view as unknown as Uint8Array<ArrayBuffer>
|
||||
);
|
||||
return view;
|
||||
},
|
||||
randomUUID: () => webcrypto.randomUUID(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the curated allowlist of globals the sandbox sees. The returned
|
||||
* object is frozen — installation into a `vm.Context` is performed by
|
||||
* `installFrozenGlobals` from `sandbox.ts` so the freeze chain
|
||||
* (allowlist → context → `globalThis`) is uniform.
|
||||
*/
|
||||
export function buildFrozenGlobals(
|
||||
options: FrozenGlobalsOptions
|
||||
): FrozenGlobalsBundle {
|
||||
return Object.freeze<FrozenGlobalsBundle>({
|
||||
URL,
|
||||
URLSearchParams,
|
||||
TextEncoder,
|
||||
TextDecoder,
|
||||
structuredClone,
|
||||
console: buildConsole(options.logger),
|
||||
crypto: buildCrypto(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The fixed key set advertised by {@link buildFrozenGlobals}. Exposed
|
||||
* separately so the conformance suite can assert that the allowlist is
|
||||
* exactly what the spec mandates and nothing accidentally leaked.
|
||||
*/
|
||||
export const FROZEN_GLOBAL_KEYS: ReadonlyArray<keyof FrozenGlobalsBundle> =
|
||||
Object.freeze([
|
||||
"URL",
|
||||
"URLSearchParams",
|
||||
"TextEncoder",
|
||||
"TextDecoder",
|
||||
"structuredClone",
|
||||
"console",
|
||||
"crypto",
|
||||
]);
|
||||
@@ -0,0 +1,10 @@
|
||||
export type {
|
||||
FrozenGlobalsBundle,
|
||||
FrozenGlobalsOptions,
|
||||
SandboxConsole,
|
||||
SandboxCrypto,
|
||||
} from "./frozen-globals.js";
|
||||
export {
|
||||
buildFrozenGlobals,
|
||||
FROZEN_GLOBAL_KEYS,
|
||||
} from "./frozen-globals.js";
|
||||
64
packages/api-stack/api/api-sandbox-vm/src/index.ts
Normal file
64
packages/api-stack/api/api-sandbox-vm/src/index.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
// Side-effect registers the SAND_VM.* code namespace so subpath
|
||||
// consumers participate in the same registry as the package barrel.
|
||||
import "./error-codes/error-codes.js";
|
||||
|
||||
// Error codes
|
||||
export {
|
||||
SAND_VM_CODES,
|
||||
type SandboxVmCode,
|
||||
SandboxVmError,
|
||||
} from "./error-codes/error-codes.js";
|
||||
export type {
|
||||
FrozenGlobalsBundle,
|
||||
FrozenGlobalsOptions,
|
||||
SandboxConsole,
|
||||
SandboxCrypto,
|
||||
} from "./globals/frozen-globals/frozen-globals.js";
|
||||
// Frozen globals
|
||||
export {
|
||||
buildFrozenGlobals,
|
||||
FROZEN_GLOBAL_KEYS,
|
||||
} from "./globals/frozen-globals/frozen-globals.js";
|
||||
export type { SandboxVmInstruments } from "./otel/instruments.js";
|
||||
// OTel instruments
|
||||
export {
|
||||
getSandboxVmInstruments,
|
||||
resetSandboxVmInstruments,
|
||||
} from "./otel/instruments.js";
|
||||
|
||||
// Permissions checker (in-package default)
|
||||
export { createPermissionChecker } from "./permissions/checker.js";
|
||||
export type {
|
||||
CreatePermissionCheckerOptions,
|
||||
PermissionChecker,
|
||||
PermissionDecision,
|
||||
} from "./permissions/checker.types.js";
|
||||
export type {
|
||||
FetchHostAdapter,
|
||||
FetchProxy,
|
||||
InstallFetchProxyOptions,
|
||||
} from "./proxies/fetch-proxy/fetch-proxy.js";
|
||||
// Proxies
|
||||
export { installFetchProxy } from "./proxies/fetch-proxy/fetch-proxy.js";
|
||||
export type {
|
||||
FsHostAdapter,
|
||||
FsProxy,
|
||||
InstallFsProxyOptions,
|
||||
} from "./proxies/fs-proxy/fs-proxy.js";
|
||||
export { installFsProxy } from "./proxies/fs-proxy/fs-proxy.js";
|
||||
export type {
|
||||
CapabilityBag,
|
||||
CapabilityMap,
|
||||
CapabilityMethod,
|
||||
InstallCapabilityProxiesOptions,
|
||||
} from "./proxies/install-capability-proxies/install-capability-proxies.js";
|
||||
export { installCapabilityProxies } from "./proxies/install-capability-proxies/install-capability-proxies.js";
|
||||
|
||||
// Sandbox
|
||||
export { createVmSandbox } from "./sandbox/sandbox.js";
|
||||
export type {
|
||||
CreateVmSandboxOptions,
|
||||
PluginSource,
|
||||
SandboxProxies,
|
||||
VmSandboxHandle,
|
||||
} from "./sandbox/sandbox.types.js";
|
||||
@@ -0,0 +1,96 @@
|
||||
# OTel instruments
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the OpenTelemetry instrument bundle emitted by the
|
||||
`vm.Context` sandbox host. Instruments are bound lazily against the
|
||||
current global `MeterProvider` so packages downstream may install a
|
||||
real exporter (Prometheus, OTLP, in-memory test reader) and observe
|
||||
the bundle without restarting the process. The bundle is cached after
|
||||
first build so the hot proxy path never reaches the meter registry
|
||||
twice.
|
||||
|
||||
## Features
|
||||
|
||||
- Four named instruments: a boot histogram, two refusal counters
|
||||
(`eval_rejected_total`, `module_import_rejected_total`), and the
|
||||
shared `plugin.permission_denied_total` counter whose name matches
|
||||
`api-sandbox-permissions` so dashboards aggregate cleanly.
|
||||
- Lazy binding via `getSandboxVmInstruments()` so tests may install a
|
||||
fresh `MeterProvider` and then call `resetSandboxVmInstruments()` to
|
||||
drop the cache.
|
||||
- All metric names are namespaced under `sandbox_vm.*` (except the
|
||||
cross-package denial counter), making them easy to filter at the
|
||||
collector.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { getSandboxVmInstruments } from "@suaveplan/api-sandbox-vm/otel/instruments";
|
||||
|
||||
const instruments = getSandboxVmInstruments();
|
||||
instruments.evalRejectedTotal.add(1, { kind: "direct" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Install a real in-memory provider for assertions:
|
||||
|
||||
```ts
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
getSandboxVmInstruments,
|
||||
resetSandboxVmInstruments,
|
||||
} from "@suaveplan/api-sandbox-vm/otel/instruments";
|
||||
|
||||
const exporter = new InMemoryMetricExporter(AggregationTemporality.DELTA);
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter,
|
||||
exportIntervalMillis: 1_000,
|
||||
});
|
||||
const provider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
resetSandboxVmInstruments();
|
||||
|
||||
const instruments = getSandboxVmInstruments();
|
||||
instruments.permissionDeniedTotal.add(1, {
|
||||
plugin: "demo",
|
||||
capability: "fetch",
|
||||
});
|
||||
await reader.forceFlush();
|
||||
const collected = exporter.getMetrics();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `getSandboxVmInstruments(): SandboxVmInstruments` — return the
|
||||
lazily-built instrument bundle. Calling multiple times returns the
|
||||
same frozen reference until `resetSandboxVmInstruments()` is invoked.
|
||||
- `resetSandboxVmInstruments(): void` — drop the cache; the next
|
||||
`getSandboxVmInstruments()` call re-binds against the (possibly new)
|
||||
global `MeterProvider`.
|
||||
- `interface SandboxVmInstruments` — fully-typed instrument bundle.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The module never reads any environment variable at import time and
|
||||
never side-effects the global `MeterProvider`. Caching uses a
|
||||
module-private `let` rather than a `WeakMap` because the lifecycle of
|
||||
the bundle is naturally process-scoped (there is no second
|
||||
`MeterProvider` instance to dispatch to unless the consumer resets the
|
||||
cache deliberately). The `permission_denied_total` counter
|
||||
intentionally uses the same name as the one in
|
||||
`api-sandbox-permissions`; both packages contribute to the same OTLP
|
||||
series so the consumer sees one denial total regardless of whether the
|
||||
decision was made in this package's default checker or in the richer
|
||||
implementation.
|
||||
@@ -0,0 +1,74 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import {
|
||||
getSandboxVmInstruments,
|
||||
resetSandboxVmInstruments,
|
||||
} from "./instruments.js";
|
||||
|
||||
let exporter: InMemoryMetricExporter;
|
||||
let provider: MeterProvider;
|
||||
|
||||
describe("sandbox vm OTel instruments", () => {
|
||||
beforeEach(() => {
|
||||
metrics.disable();
|
||||
exporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
provider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
resetSandboxVmInstruments();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await provider.forceFlush();
|
||||
metrics.disable();
|
||||
resetSandboxVmInstruments();
|
||||
});
|
||||
|
||||
it("returns the same cached bundle until reset", { timeout: 5000 }, () => {
|
||||
const a = getSandboxVmInstruments();
|
||||
const b = getSandboxVmInstruments();
|
||||
expect(a).toBe(b);
|
||||
resetSandboxVmInstruments();
|
||||
const c = getSandboxVmInstruments();
|
||||
expect(a).not.toBe(c);
|
||||
});
|
||||
|
||||
it("emits all four instrument names", { timeout: 5000 }, async () => {
|
||||
const inst = getSandboxVmInstruments();
|
||||
inst.sandboxBootDurationMs.record(12);
|
||||
inst.evalRejectedTotal.add(1, { kind: "direct" });
|
||||
inst.moduleImportRejectedTotal.add(1, { specifier: "node:fs" });
|
||||
inst.permissionDeniedTotal.add(1, {
|
||||
plugin: "demo",
|
||||
capability: "fs",
|
||||
});
|
||||
await provider.forceFlush();
|
||||
const names = new Set<string>();
|
||||
for (const rm of exporter.getMetrics()) {
|
||||
for (const sm of rm.scopeMetrics) {
|
||||
for (const m of sm.metrics) names.add(m.descriptor.name);
|
||||
}
|
||||
}
|
||||
expect(names.has("sandbox_vm.boot_duration_ms")).toBe(true);
|
||||
expect(names.has("sandbox_vm.eval_rejected_total")).toBe(true);
|
||||
expect(names.has("sandbox_vm.module_import_rejected_total")).toBe(true);
|
||||
expect(names.has("plugin.permission_denied_total")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* Bundle of OpenTelemetry instruments emitted by the vm sandbox host.
|
||||
*
|
||||
* - `sandboxBootDurationMs` — histogram of `createVmSandbox` boot wall
|
||||
* time (excluding plugin module evaluation, which is its own counter
|
||||
* so a slow plugin doesn't pollute the host's boot metric).
|
||||
* - `evalRejectedTotal` — incremented every time a sandboxed call hit
|
||||
* `EVAL_REJECTED` or `INDIRECT_EVAL_REJECTED`. The `kind` attribute
|
||||
* is `"direct"` or `"indirect"`.
|
||||
* - `moduleImportRejectedTotal` — incremented when the linker refused
|
||||
* an import specifier. `specifier` is recorded as an attribute.
|
||||
* - `permissionDeniedTotal` — incremented on every denied capability
|
||||
* call. Mirrors the metric name owned by `api-sandbox-permissions`
|
||||
* so dashboards aggregate across both packages.
|
||||
*/
|
||||
export interface SandboxVmInstruments {
|
||||
readonly sandboxBootDurationMs: Histogram;
|
||||
readonly evalRejectedTotal: Counter;
|
||||
readonly moduleImportRejectedTotal: Counter;
|
||||
readonly permissionDeniedTotal: Counter;
|
||||
}
|
||||
|
||||
let cached: SandboxVmInstruments | undefined;
|
||||
|
||||
/**
|
||||
* Lazily build (and cache) the sandbox instruments bundle against the
|
||||
* current global `MeterProvider`. Call {@link resetSandboxVmInstruments}
|
||||
* in test setup to force a re-bind against a fresh provider.
|
||||
*/
|
||||
export function getSandboxVmInstruments(): SandboxVmInstruments {
|
||||
if (cached !== undefined) return cached;
|
||||
const meter = metrics.getMeter("@suaveplan/api-sandbox-vm");
|
||||
cached = {
|
||||
sandboxBootDurationMs: meter.createHistogram(
|
||||
"sandbox_vm.boot_duration_ms",
|
||||
{
|
||||
description:
|
||||
"Wall-time spent in createVmSandbox until the handle is ready",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
evalRejectedTotal: meter.createCounter(
|
||||
"sandbox_vm.eval_rejected_total",
|
||||
{
|
||||
description:
|
||||
"Total dynamic-code evaluations rejected inside the sandbox",
|
||||
}
|
||||
),
|
||||
moduleImportRejectedTotal: meter.createCounter(
|
||||
"sandbox_vm.module_import_rejected_total",
|
||||
{
|
||||
description:
|
||||
"Total module import specifiers refused by the SourceTextModule linker",
|
||||
}
|
||||
),
|
||||
permissionDeniedTotal: meter.createCounter(
|
||||
"plugin.permission_denied_total",
|
||||
{
|
||||
description:
|
||||
"Total permission-denied responses returned to a sandboxed plugin",
|
||||
}
|
||||
),
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** Drop the cached instrument bundle so the next call re-binds. */
|
||||
export function resetSandboxVmInstruments(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
106
packages/api-stack/api/api-sandbox-vm/src/permissions/checker.md
Normal file
106
packages/api-stack/api/api-sandbox-vm/src/permissions/checker.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Permission checker (in-package default)
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`createPermissionChecker` compiles a `PermissionsManifest`
|
||||
(from `@suaveplan/api-types`) into a structural `PermissionChecker` that
|
||||
the `vm.Context` sandbox host consults before forwarding any
|
||||
filesystem, outbound-HTTP, or capability call to the host. The default
|
||||
implementation lives here so the package ships functionally complete
|
||||
even before the full `@suaveplan/api-sandbox-permissions` package
|
||||
arrives .
|
||||
|
||||
This module exists to make policy enforcement a first-class operation
|
||||
of the sandbox host without depending on a heavier policy engine.
|
||||
Callers that need budget tracking or per-capability payload gating
|
||||
construct a stricter checker and inject it through
|
||||
`createVmSandbox({ checker })`.
|
||||
|
||||
## Features
|
||||
|
||||
- Glob-compiled allowlists for `permissions.fs[]` (path globs with
|
||||
`*`, `**`, `?`) and `permissions.fetch[]` (exact host or `*.suffix`
|
||||
pattern).
|
||||
- Path normalisation rejects `..`-traversal before matching so
|
||||
`"/srv/data/../../etc/passwd"` is matched as `"/etc/passwd"`, not
|
||||
fooled into `"/srv/data/passwd"`.
|
||||
- Returns frozen `PermissionDecision` objects so the proxy hot path
|
||||
never accidentally mutates the policy answer.
|
||||
- All decisions are synchronous; the policy answer never performs IO.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createPermissionChecker } from "@suaveplan/api-sandbox-vm/permissions/checker";
|
||||
|
||||
const checker = createPermissionChecker({
|
||||
policy: {
|
||||
fs: ["/srv/data/**"],
|
||||
fetch: ["api.example.com", "*.cdn.example.com"],
|
||||
},
|
||||
});
|
||||
|
||||
checker.checkFs("/srv/data/users.json", "read").allow; // true
|
||||
checker.checkFetch("https://api.example.com/v1/users").allow; // true
|
||||
checker.checkFetch("https://evil.example.org/leak").allow; // false
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Compose with a host-supplied audit sink so every denial is observable:
|
||||
|
||||
```ts
|
||||
import { createPermissionChecker } from "@suaveplan/api-sandbox-vm/permissions/checker";
|
||||
import type { PermissionChecker } from "@suaveplan/api-sandbox-vm/permissions/checker";
|
||||
|
||||
function withAudit(
|
||||
inner: PermissionChecker,
|
||||
audit: (capability: string, reason: string) => void
|
||||
): PermissionChecker {
|
||||
return {
|
||||
checkFs(path, mode) {
|
||||
const d = inner.checkFs(path, mode);
|
||||
if (!d.allow) audit(`fs.${mode}`, d.reason);
|
||||
return d;
|
||||
},
|
||||
checkFetch(url) {
|
||||
const d = inner.checkFetch(url);
|
||||
if (!d.allow) audit("fetch", d.reason);
|
||||
return d;
|
||||
},
|
||||
checkCapability(id, payload) {
|
||||
const d = inner.checkCapability(id, payload);
|
||||
if (!d.allow) audit(id, d.reason);
|
||||
return d;
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createPermissionChecker(options)` — build a checker from a
|
||||
`PermissionsManifest`. `options.policy` is the manifest block exactly
|
||||
as declared in `plugin.json`.
|
||||
- `PermissionChecker` — structural type with synchronous `checkFs`,
|
||||
`checkFetch`, `checkCapability` methods.
|
||||
- `PermissionDecision` — discriminated union of `{ allow: true }` or
|
||||
`{ allow: false; reason: string }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The glob compiler escapes regex meta-characters first, then performs
|
||||
the `**` → `.*`, `*` → `[^/]*`, `?` → `[^/]` substitution in three
|
||||
passes via a sentinel token to keep the ordering safe (replacing `**`
|
||||
to `.*` before `*` to `[^/]*` would otherwise mis-translate `**`).
|
||||
Host patterns use a string-suffix match rather than a regex to keep
|
||||
the hot path branch-predictable. The `checkCapability` default is
|
||||
permissive precisely because the full payload-shape contract is owned
|
||||
by `api-sandbox-permissions`; hosts SHOULD compose a stricter checker
|
||||
once that package ships. The in-package fallback still rejects an
|
||||
empty capability id so that an obviously-malformed call fails closed
|
||||
rather than silently succeeding.
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createPermissionChecker } from "./checker.js";
|
||||
|
||||
describe("createPermissionChecker — fs", () => {
|
||||
it("allows paths matching ** glob", { timeout: 5000 }, () => {
|
||||
const c = createPermissionChecker({ policy: { fs: ["/srv/data/**"] } });
|
||||
expect(c.checkFs("/srv/data/users.json", "read").allow).toBe(true);
|
||||
expect(c.checkFs("/srv/data/nested/x.json", "read").allow).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects paths outside the allowlist", { timeout: 5000 }, () => {
|
||||
const c = createPermissionChecker({ policy: { fs: ["/srv/data/**"] } });
|
||||
const d = c.checkFs("/etc/passwd", "read");
|
||||
expect(d.allow).toBe(false);
|
||||
if (!d.allow) expect(d.reason).toContain("not matched");
|
||||
});
|
||||
|
||||
it("rejects path-traversal escapes after normalisation", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const c = createPermissionChecker({ policy: { fs: ["/srv/data/**"] } });
|
||||
expect(c.checkFs("/srv/data/../../etc/passwd", "read").allow).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("supports single-star segments", { timeout: 5000 }, () => {
|
||||
const c = createPermissionChecker({ policy: { fs: ["/srv/*.json"] } });
|
||||
expect(c.checkFs("/srv/users.json", "read").allow).toBe(true);
|
||||
expect(c.checkFs("/srv/sub/x.json", "read").allow).toBe(false);
|
||||
});
|
||||
|
||||
it("supports ? single-character match", { timeout: 5000 }, () => {
|
||||
const c = createPermissionChecker({ policy: { fs: ["/srv/?.json"] } });
|
||||
expect(c.checkFs("/srv/a.json", "read").allow).toBe(true);
|
||||
expect(c.checkFs("/srv/ab.json", "read").allow).toBe(false);
|
||||
});
|
||||
|
||||
it("empty allowlist denies everything", { timeout: 5000 }, () => {
|
||||
const c = createPermissionChecker({ policy: {} });
|
||||
expect(c.checkFs("/anything", "write").allow).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPermissionChecker — fetch", () => {
|
||||
it("allows exact host match", { timeout: 5000 }, () => {
|
||||
const c = createPermissionChecker({
|
||||
policy: { fetch: ["api.example.com"] },
|
||||
});
|
||||
expect(c.checkFetch("https://api.example.com/x").allow).toBe(true);
|
||||
});
|
||||
|
||||
it("allows *.suffix host match", { timeout: 5000 }, () => {
|
||||
const c = createPermissionChecker({
|
||||
policy: { fetch: ["*.cdn.example"] },
|
||||
});
|
||||
expect(c.checkFetch("https://images.cdn.example/x").allow).toBe(true);
|
||||
expect(c.checkFetch("https://cdn.example/x").allow).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects host outside allowlist", { timeout: 5000 }, () => {
|
||||
const c = createPermissionChecker({
|
||||
policy: { fetch: ["api.example.com"] },
|
||||
});
|
||||
const d = c.checkFetch("https://evil.example/x");
|
||||
expect(d.allow).toBe(false);
|
||||
if (!d.allow) expect(d.reason).toContain("not matched");
|
||||
});
|
||||
|
||||
it("rejects invalid URL", { timeout: 5000 }, () => {
|
||||
const c = createPermissionChecker({
|
||||
policy: { fetch: ["api.example.com"] },
|
||||
});
|
||||
const d = c.checkFetch("not-a-url");
|
||||
expect(d.allow).toBe(false);
|
||||
if (!d.allow) expect(d.reason).toContain("not a valid URL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPermissionChecker — capability", () => {
|
||||
it("default allows non-empty capability id", { timeout: 5000 }, () => {
|
||||
const c = createPermissionChecker({ policy: {} });
|
||||
expect(c.checkCapability("db", { method: "query" }).allow).toBe(true);
|
||||
});
|
||||
|
||||
it("default denies empty capability id", { timeout: 5000 }, () => {
|
||||
const c = createPermissionChecker({ policy: {} });
|
||||
expect(c.checkCapability("", { method: "q" }).allow).toBe(false);
|
||||
});
|
||||
});
|
||||
112
packages/api-stack/api/api-sandbox-vm/src/permissions/checker.ts
Normal file
112
packages/api-stack/api/api-sandbox-vm/src/permissions/checker.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type {
|
||||
CreatePermissionCheckerOptions,
|
||||
PermissionChecker,
|
||||
PermissionDecision,
|
||||
} from "./checker.types.js";
|
||||
|
||||
const ALLOW: PermissionDecision = Object.freeze({ allow: true });
|
||||
|
||||
function deny(reason: string): PermissionDecision {
|
||||
return Object.freeze({ allow: false, reason });
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a single glob entry like `"/srv/data/*.json"` or
|
||||
* `"/srv/data/**"` into a predicate over absolute paths.
|
||||
*
|
||||
* Supported tokens (POSIX-ish):
|
||||
* - `**` matches any path segment, including `/`.
|
||||
* - `*` matches any sequence of non-`/` characters.
|
||||
* - `?` matches a single non-`/` character.
|
||||
* - everything else is matched literally.
|
||||
*
|
||||
* Patterns are anchored at both ends (`^…$`) so a glob of `"/etc/foo"`
|
||||
* never matches `/etc/foo-bar`. Path-traversal sequences (`..`) are
|
||||
* normalised out of the input before matching so `"/a/../b"` is
|
||||
* evaluated as `"/b"`.
|
||||
*/
|
||||
function compilePathGlob(pattern: string): (path: string) => boolean {
|
||||
const re = new RegExp(
|
||||
`^${pattern
|
||||
.replace(/[.+^$()|[\]{}]/g, "\\$&")
|
||||
.replace(/\*\*/g, "::DOUBLESTAR::")
|
||||
.replace(/\*/g, "[^/]*")
|
||||
.replace(/\?/g, "[^/]")
|
||||
.replace(/::DOUBLESTAR::/g, ".*")}$`
|
||||
);
|
||||
return (path) => re.test(path);
|
||||
}
|
||||
|
||||
function normalisePath(p: string): string {
|
||||
const out: string[] = [];
|
||||
for (const seg of p.split("/")) {
|
||||
if (seg === "" || seg === ".") continue;
|
||||
if (seg === "..") {
|
||||
out.pop();
|
||||
continue;
|
||||
}
|
||||
out.push(seg);
|
||||
}
|
||||
return `/${out.join("/")}`;
|
||||
}
|
||||
|
||||
function compileHostPattern(pattern: string): (host: string) => boolean {
|
||||
if (pattern.startsWith("*.")) {
|
||||
const suffix = pattern.slice(1); // ".example.com"
|
||||
return (host) => host === pattern.slice(2) || host.endsWith(suffix);
|
||||
}
|
||||
return (host) => host === pattern;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a {@link PermissionChecker} from a {@link PermissionsManifest}.
|
||||
*
|
||||
* This is the default in-package implementation. The future
|
||||
* `@suaveplan/api-sandbox-permissions` package supplies a richer
|
||||
* implementation with budget tracking, metric emission, and per-capability
|
||||
* payload gating; both implementations conform to the same
|
||||
* {@link PermissionChecker} structural type so `api-sandbox-vm` accepts
|
||||
* either without code changes.
|
||||
*/
|
||||
export function createPermissionChecker(
|
||||
options: CreatePermissionCheckerOptions
|
||||
): PermissionChecker {
|
||||
const fsPatterns = (options.policy.fs ?? []).map(compilePathGlob);
|
||||
const fetchPatterns = (options.policy.fetch ?? []).map(compileHostPattern);
|
||||
|
||||
return {
|
||||
checkFs(path, mode): PermissionDecision {
|
||||
const target = normalisePath(path);
|
||||
for (const matches of fsPatterns) {
|
||||
if (matches(target)) return ALLOW;
|
||||
}
|
||||
return deny(
|
||||
`fs ${mode} ${target} not matched by policy.fs[] allowlist`
|
||||
);
|
||||
},
|
||||
checkFetch(url): PermissionDecision {
|
||||
let host: string;
|
||||
try {
|
||||
host = new URL(url).hostname;
|
||||
} catch {
|
||||
return deny(`fetch ${url} is not a valid URL`);
|
||||
}
|
||||
for (const matches of fetchPatterns) {
|
||||
if (matches(host)) return ALLOW;
|
||||
}
|
||||
return deny(
|
||||
`fetch ${host} not matched by policy.fetch[] allowlist`
|
||||
);
|
||||
},
|
||||
checkCapability(capabilityId, _payload): PermissionDecision {
|
||||
// Capability-payload gating is owned by api-sandbox-permissions;
|
||||
// the in-package fallback allows every capability call so that
|
||||
// sandbox hosts running without a custom checker remain
|
||||
// functional. Hosts SHOULD inject a stricter checker.
|
||||
if (capabilityId === "") {
|
||||
return deny("empty capabilityId");
|
||||
}
|
||||
return ALLOW;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { manifest } from "@suaveplan/api-types";
|
||||
|
||||
export type PermissionsManifest = manifest.PermissionsManifest;
|
||||
|
||||
/** Decision returned by every {@link PermissionChecker} entry point. */
|
||||
export type PermissionDecision =
|
||||
| { readonly allow: true }
|
||||
| { readonly allow: false; readonly reason: string };
|
||||
|
||||
/**
|
||||
* Structural contract every sandbox-permissions implementation must
|
||||
* satisfy. The `@suaveplan/api-sandbox-vm` package consumes the contract
|
||||
* structurally so a future `@suaveplan/api-sandbox-permissions` package
|
||||
* can plug in without a hard dependency cycle while the umbrella moves
|
||||
* through Stage 8.
|
||||
*
|
||||
* Every method is synchronous so the proxy hot path stays cheap (the
|
||||
* downstream host call is async, but the policy decision itself never
|
||||
* needs IO).
|
||||
*/
|
||||
export interface PermissionChecker {
|
||||
/** Decide whether the sandbox may read/write `path` under `mode`. */
|
||||
readonly checkFs: (
|
||||
path: string,
|
||||
mode: "read" | "write"
|
||||
) => PermissionDecision;
|
||||
/** Decide whether the sandbox may issue an outbound HTTP request. */
|
||||
readonly checkFetch: (url: string) => PermissionDecision;
|
||||
/** Decide whether the sandbox may invoke a non-fs/fetch capability. */
|
||||
readonly checkCapability: (
|
||||
capabilityId: string,
|
||||
payload: Readonly<Record<string, unknown>>
|
||||
) => PermissionDecision;
|
||||
}
|
||||
|
||||
/** Options for {@link createPermissionChecker}. */
|
||||
export interface CreatePermissionCheckerOptions {
|
||||
readonly policy: PermissionsManifest;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { createPermissionChecker } from "./checker.js";
|
||||
export type {
|
||||
CreatePermissionCheckerOptions,
|
||||
PermissionChecker,
|
||||
PermissionDecision,
|
||||
} from "./checker.types.js";
|
||||
@@ -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,157 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import {
|
||||
SAND_VM_CODES,
|
||||
SandboxVmError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { resetSandboxVmInstruments } from "../../otel/instruments.js";
|
||||
import { createPermissionChecker } from "../../permissions/checker.js";
|
||||
import { installFetchProxy } from "./fetch-proxy.js";
|
||||
|
||||
let exporter: InMemoryMetricExporter;
|
||||
let provider: MeterProvider;
|
||||
|
||||
describe("installFetchProxy", () => {
|
||||
beforeEach(() => {
|
||||
metrics.disable();
|
||||
exporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
provider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
resetSandboxVmInstruments();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await provider.forceFlush();
|
||||
metrics.disable();
|
||||
resetSandboxVmInstruments();
|
||||
});
|
||||
|
||||
it("allowed request reaches the host once", { timeout: 5000 }, async () => {
|
||||
let calls = 0;
|
||||
const proxy = installFetchProxy({
|
||||
checker: createPermissionChecker({
|
||||
policy: { fetch: ["api.example.com"] },
|
||||
}),
|
||||
pluginId: "demo",
|
||||
adapter: {
|
||||
request: async () => {
|
||||
calls++;
|
||||
return { status: 200, headers: {}, body: "" };
|
||||
},
|
||||
},
|
||||
});
|
||||
await proxy.request("https://api.example.com/v1/users");
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("wildcard suffix hosts are allowed", { timeout: 5000 }, async () => {
|
||||
const proxy = installFetchProxy({
|
||||
checker: createPermissionChecker({
|
||||
policy: { fetch: ["*.cdn.example"] },
|
||||
}),
|
||||
pluginId: "demo",
|
||||
adapter: {
|
||||
request: async () => ({ status: 200, headers: {}, body: "" }),
|
||||
},
|
||||
});
|
||||
const res = await proxy.request("https://images.cdn.example/x.png");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("denied fetch never contacts host AND increments denial metric", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let called = false;
|
||||
const proxy = installFetchProxy({
|
||||
checker: createPermissionChecker({
|
||||
policy: { fetch: ["api.allow.example"] },
|
||||
}),
|
||||
pluginId: "demo",
|
||||
adapter: {
|
||||
request: async () => {
|
||||
called = true;
|
||||
return { status: 200, headers: {}, body: "" };
|
||||
},
|
||||
},
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
await proxy.request("https://evil.example/leak");
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(called).toBe(false);
|
||||
expect(caught).toBeInstanceOf(SandboxVmError);
|
||||
expect((caught as SandboxVmError).code).toBe(
|
||||
SAND_VM_CODES.PERMISSION_DENIED
|
||||
);
|
||||
|
||||
await provider.forceFlush();
|
||||
const collected = exporter.getMetrics();
|
||||
let found = 0;
|
||||
for (const rm of collected) {
|
||||
for (const sm of rm.scopeMetrics) {
|
||||
for (const m of sm.metrics) {
|
||||
if (m.descriptor.name !== "plugin.permission_denied_total")
|
||||
continue;
|
||||
for (const p of m.dataPoints) {
|
||||
if (p.attributes.capability === "fetch") found++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(found).toBe(1);
|
||||
});
|
||||
|
||||
it("invalid URL is denied with a clear reason", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const proxy = installFetchProxy({
|
||||
checker: createPermissionChecker({
|
||||
policy: { fetch: ["api.example.com"] },
|
||||
}),
|
||||
pluginId: "demo",
|
||||
adapter: {
|
||||
request: async () => ({ status: 200, headers: {}, body: "" }),
|
||||
},
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
await proxy.request("not-a-url");
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect((caught as SandboxVmError).code).toBe(
|
||||
SAND_VM_CODES.PERMISSION_DENIED
|
||||
);
|
||||
});
|
||||
|
||||
it("proxy receiver is frozen", { timeout: 5000 }, () => {
|
||||
const proxy = installFetchProxy({
|
||||
checker: createPermissionChecker({ policy: { fetch: [] } }),
|
||||
pluginId: "demo",
|
||||
adapter: {
|
||||
request: async () => ({ status: 200, headers: {}, body: "" }),
|
||||
},
|
||||
});
|
||||
expect(Object.isFrozen(proxy)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
SAND_VM_CODES,
|
||||
SandboxVmError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { getSandboxVmInstruments } from "../../otel/instruments.js";
|
||||
import type { PermissionChecker } from "../../permissions/checker.types.js";
|
||||
|
||||
/**
|
||||
* Sandbox-side outbound HTTP adapter type. The host-supplied adapter
|
||||
* receives only `url` and `init`; the sandbox is never given a raw
|
||||
* `fetch` reference (which on Node also opens DNS lookup, IPC, and
|
||||
* connection-pool surfaces a sandboxed plugin should not see).
|
||||
*/
|
||||
export interface FetchHostAdapter {
|
||||
readonly request: (
|
||||
url: string,
|
||||
init?: { readonly method?: string; readonly body?: string }
|
||||
) => Promise<{
|
||||
readonly status: number;
|
||||
readonly headers: Readonly<Record<string, string>>;
|
||||
readonly body: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Sandbox-side proxy surface visible to the plugin. */
|
||||
export interface FetchProxy {
|
||||
readonly request: FetchHostAdapter["request"];
|
||||
}
|
||||
|
||||
/** Options for {@link installFetchProxy}. */
|
||||
export interface InstallFetchProxyOptions {
|
||||
readonly checker: PermissionChecker;
|
||||
readonly adapter: FetchHostAdapter;
|
||||
readonly pluginId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a permission-gated outbound-HTTP proxy. Every call is checked
|
||||
* before forwarding; a denied call never reaches the host adapter and
|
||||
* raises `SAND_VM.PERMISSION_DENIED` while also incrementing the
|
||||
* `plugin.permission_denied_total{capability="fetch"}` counter.
|
||||
*/
|
||||
export function installFetchProxy(
|
||||
options: InstallFetchProxyOptions
|
||||
): FetchProxy {
|
||||
const { checker, adapter, pluginId } = options;
|
||||
const instruments = getSandboxVmInstruments();
|
||||
|
||||
return Object.freeze<FetchProxy>({
|
||||
async request(
|
||||
url,
|
||||
init
|
||||
): Promise<{
|
||||
readonly status: number;
|
||||
readonly headers: Readonly<Record<string, string>>;
|
||||
readonly body: string;
|
||||
}> {
|
||||
const decision = checker.checkFetch(url);
|
||||
if (!decision.allow) {
|
||||
instruments.permissionDeniedTotal.add(1, {
|
||||
plugin: pluginId,
|
||||
capability: "fetch",
|
||||
});
|
||||
throw new SandboxVmError(
|
||||
SAND_VM_CODES.PERMISSION_DENIED,
|
||||
`fetch ${url} denied: ${decision.reason}`,
|
||||
{
|
||||
context: {
|
||||
plugin: pluginId,
|
||||
capability: "fetch",
|
||||
url,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
return adapter.request(url, init);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type {
|
||||
FetchHostAdapter,
|
||||
FetchProxy,
|
||||
InstallFetchProxyOptions,
|
||||
} from "./fetch-proxy.js";
|
||||
export { installFetchProxy } from "./fetch-proxy.js";
|
||||
@@ -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,114 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
SAND_VM_CODES,
|
||||
SandboxVmError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { createPermissionChecker } from "../../permissions/checker.js";
|
||||
import { installFsProxy } from "./fs-proxy.js";
|
||||
|
||||
const allowChecker = createPermissionChecker({
|
||||
policy: { fs: ["/srv/data/**"] },
|
||||
});
|
||||
const denyAllChecker = createPermissionChecker({ policy: { fs: [] } });
|
||||
|
||||
describe("installFsProxy", () => {
|
||||
it("allowed read proxies once to host", { timeout: 5000 }, async () => {
|
||||
let calls = 0;
|
||||
const proxy = installFsProxy({
|
||||
checker: allowChecker,
|
||||
pluginId: "demo",
|
||||
adapter: {
|
||||
readFile: async (p) => {
|
||||
calls++;
|
||||
return `data:${p}`;
|
||||
},
|
||||
writeFile: async () => undefined,
|
||||
},
|
||||
});
|
||||
const result = await proxy.readFile("/srv/data/u.json");
|
||||
expect(calls).toBe(1);
|
||||
expect(result).toBe("data:/srv/data/u.json");
|
||||
});
|
||||
|
||||
it("allowed write proxies to host", { timeout: 5000 }, async () => {
|
||||
let recorded: string | undefined;
|
||||
const proxy = installFsProxy({
|
||||
checker: allowChecker,
|
||||
pluginId: "demo",
|
||||
adapter: {
|
||||
readFile: async () => "",
|
||||
writeFile: async (p) => {
|
||||
recorded = p;
|
||||
},
|
||||
},
|
||||
});
|
||||
await proxy.writeFile("/srv/data/x.json", "{}");
|
||||
expect(recorded).toBe("/srv/data/x.json");
|
||||
});
|
||||
|
||||
it("denied read raises PERMISSION_DENIED and never reaches host", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let called = false;
|
||||
const proxy = installFsProxy({
|
||||
checker: denyAllChecker,
|
||||
pluginId: "demo",
|
||||
adapter: {
|
||||
readFile: async () => {
|
||||
called = true;
|
||||
return "";
|
||||
},
|
||||
writeFile: async () => undefined,
|
||||
},
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
await proxy.readFile("/etc/passwd");
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(called).toBe(false);
|
||||
expect(caught).toBeInstanceOf(SandboxVmError);
|
||||
expect((caught as SandboxVmError).code).toBe(
|
||||
SAND_VM_CODES.PERMISSION_DENIED
|
||||
);
|
||||
});
|
||||
|
||||
it("denied write raises PERMISSION_DENIED and never reaches host", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let called = false;
|
||||
const proxy = installFsProxy({
|
||||
checker: denyAllChecker,
|
||||
pluginId: "demo",
|
||||
adapter: {
|
||||
readFile: async () => "",
|
||||
writeFile: async () => {
|
||||
called = true;
|
||||
},
|
||||
},
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
await proxy.writeFile("/etc/passwd", "x");
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(called).toBe(false);
|
||||
expect((caught as SandboxVmError).code).toBe(
|
||||
SAND_VM_CODES.PERMISSION_DENIED
|
||||
);
|
||||
});
|
||||
|
||||
it("proxy receiver is frozen", { timeout: 5000 }, () => {
|
||||
const proxy = installFsProxy({
|
||||
checker: allowChecker,
|
||||
pluginId: "demo",
|
||||
adapter: {
|
||||
readFile: async () => "",
|
||||
writeFile: async () => undefined,
|
||||
},
|
||||
});
|
||||
expect(Object.isFrozen(proxy)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
SAND_VM_CODES,
|
||||
SandboxVmError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { getSandboxVmInstruments } from "../../otel/instruments.js";
|
||||
import type { PermissionChecker } from "../../permissions/checker.types.js";
|
||||
|
||||
/**
|
||||
* Host-side adapter that this proxy ultimately calls. Adapters are
|
||||
* supplied by `api-host` and perform the real `fs/promises` operation
|
||||
* after the proxy has verified the permission decision.
|
||||
*/
|
||||
export interface FsHostAdapter {
|
||||
readonly readFile: (path: string) => Promise<string | Uint8Array>;
|
||||
readonly writeFile: (
|
||||
path: string,
|
||||
data: string | Uint8Array
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
/** Sandbox-side surface visible to the plugin. */
|
||||
export interface FsProxy {
|
||||
readonly readFile: (path: string) => Promise<string | Uint8Array>;
|
||||
readonly writeFile: (
|
||||
path: string,
|
||||
data: string | Uint8Array
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
/** Options accepted by {@link installFsProxy}. */
|
||||
export interface InstallFsProxyOptions {
|
||||
readonly checker: PermissionChecker;
|
||||
readonly adapter: FsHostAdapter;
|
||||
readonly pluginId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a permission-gated `fs` proxy for the sandbox. Every call is
|
||||
* checked against the supplied {@link PermissionChecker} before the
|
||||
* call ever reaches the host adapter. Denied calls raise
|
||||
* `SAND_VM.PERMISSION_DENIED` synchronously (`Promise.reject`) and
|
||||
* increment the `plugin.permission_denied_total` counter with
|
||||
* `{ plugin, capability: "fs" }` attributes.
|
||||
*
|
||||
* The host adapter is opaque to the sandbox — only the proxy holds a
|
||||
* reference, and the proxy is closure-bound so the sandbox cannot pry
|
||||
* the adapter back out of the proxy receiver.
|
||||
*/
|
||||
export function installFsProxy(options: InstallFsProxyOptions): FsProxy {
|
||||
const { checker, adapter, pluginId } = options;
|
||||
const instruments = getSandboxVmInstruments();
|
||||
|
||||
function denial(
|
||||
path: string,
|
||||
mode: "read" | "write",
|
||||
reason: string
|
||||
): SandboxVmError {
|
||||
instruments.permissionDeniedTotal.add(1, {
|
||||
plugin: pluginId,
|
||||
capability: "fs",
|
||||
mode,
|
||||
});
|
||||
return new SandboxVmError(
|
||||
SAND_VM_CODES.PERMISSION_DENIED,
|
||||
`fs ${mode} ${path} denied: ${reason}`,
|
||||
{ context: { plugin: pluginId, capability: "fs", mode, path } }
|
||||
);
|
||||
}
|
||||
|
||||
return Object.freeze<FsProxy>({
|
||||
async readFile(path): Promise<string | Uint8Array> {
|
||||
const decision = checker.checkFs(path, "read");
|
||||
if (!decision.allow) {
|
||||
throw denial(path, "read", decision.reason);
|
||||
}
|
||||
return adapter.readFile(path);
|
||||
},
|
||||
async writeFile(path, data): Promise<void> {
|
||||
const decision = checker.checkFs(path, "write");
|
||||
if (!decision.allow) {
|
||||
throw denial(path, "write", decision.reason);
|
||||
}
|
||||
await adapter.writeFile(path, data);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type {
|
||||
FsHostAdapter,
|
||||
FsProxy,
|
||||
InstallFsProxyOptions,
|
||||
} from "./fs-proxy.js";
|
||||
export { installFsProxy } from "./fs-proxy.js";
|
||||
@@ -0,0 +1,7 @@
|
||||
export type {
|
||||
CapabilityBag,
|
||||
CapabilityMap,
|
||||
CapabilityMethod,
|
||||
InstallCapabilityProxiesOptions,
|
||||
} from "./install-capability-proxies.js";
|
||||
export { installCapabilityProxies } from "./install-capability-proxies.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,165 @@
|
||||
import vm from "node:vm";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
SAND_VM_CODES,
|
||||
type SandboxVmError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { createPermissionChecker } from "../../permissions/checker.js";
|
||||
import { installFetchProxy } from "../fetch-proxy/fetch-proxy.js";
|
||||
import { installFsProxy } from "../fs-proxy/fs-proxy.js";
|
||||
import { installCapabilityProxies } from "./install-capability-proxies.js";
|
||||
|
||||
function makeContext() {
|
||||
const obj: Record<string, unknown> = {};
|
||||
return vm.createContext(obj);
|
||||
}
|
||||
|
||||
function buildBaseOptions() {
|
||||
const checker = createPermissionChecker({ policy: {} });
|
||||
return {
|
||||
checker,
|
||||
fs: installFsProxy({
|
||||
checker,
|
||||
pluginId: "demo",
|
||||
adapter: {
|
||||
readFile: async () => "",
|
||||
writeFile: async () => undefined,
|
||||
},
|
||||
}),
|
||||
fetch: installFetchProxy({
|
||||
checker,
|
||||
pluginId: "demo",
|
||||
adapter: {
|
||||
request: async () => ({ status: 200, headers: {}, body: "" }),
|
||||
},
|
||||
}),
|
||||
pluginId: "demo",
|
||||
};
|
||||
}
|
||||
|
||||
describe("installCapabilityProxies", () => {
|
||||
it("installs __suaveplan receiver on the context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ctx = makeContext();
|
||||
installCapabilityProxies({
|
||||
context: ctx,
|
||||
...buildBaseOptions(),
|
||||
capabilities: {
|
||||
db: { query: async () => "ok" },
|
||||
},
|
||||
});
|
||||
const result = vm.runInContext(
|
||||
`typeof __suaveplan === "object" && typeof __suaveplan.fs === "object" && typeof __suaveplan.fetch === "object" && typeof __suaveplan.capabilities.db.query === "function"`,
|
||||
ctx
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("capability call reaches host when checker allows", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let calls = 0;
|
||||
const ctx = makeContext();
|
||||
installCapabilityProxies({
|
||||
context: ctx,
|
||||
...buildBaseOptions(),
|
||||
capabilities: {
|
||||
db: {
|
||||
query: async () => {
|
||||
calls++;
|
||||
return "row";
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const fn = vm.runInContext(
|
||||
"__suaveplan.capabilities.db.query",
|
||||
ctx
|
||||
) as () => Promise<unknown>;
|
||||
const result = await fn();
|
||||
expect(calls).toBe(1);
|
||||
expect(result).toBe("row");
|
||||
});
|
||||
|
||||
it("capability call denied when checker rejects", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctx = makeContext();
|
||||
let called = false;
|
||||
const checker = createPermissionChecker({ policy: {} });
|
||||
const strict = {
|
||||
...checker,
|
||||
checkCapability: () => ({
|
||||
allow: false as const,
|
||||
reason: "denied",
|
||||
}),
|
||||
};
|
||||
installCapabilityProxies({
|
||||
context: ctx,
|
||||
...buildBaseOptions(),
|
||||
checker: strict,
|
||||
capabilities: {
|
||||
db: {
|
||||
query: async () => {
|
||||
called = true;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const fn = vm.runInContext(
|
||||
"__suaveplan.capabilities.db.query",
|
||||
ctx
|
||||
) as () => Promise<unknown>;
|
||||
let caught: unknown;
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(called).toBe(false);
|
||||
expect((caught as SandboxVmError).code).toBe(
|
||||
SAND_VM_CODES.PERMISSION_DENIED
|
||||
);
|
||||
});
|
||||
|
||||
it("receiver cannot be overwritten from inside the context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ctx = makeContext();
|
||||
installCapabilityProxies({
|
||||
context: ctx,
|
||||
...buildBaseOptions(),
|
||||
capabilities: {},
|
||||
});
|
||||
let threw = false;
|
||||
try {
|
||||
vm.runInContext(`"use strict"; __suaveplan = null;`, ctx);
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
expect(threw).toBe(true);
|
||||
});
|
||||
|
||||
it("capability bags are frozen against monkey-patching", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ctx = makeContext();
|
||||
installCapabilityProxies({
|
||||
context: ctx,
|
||||
...buildBaseOptions(),
|
||||
capabilities: { svc: { ping: async () => "pong" } },
|
||||
});
|
||||
let threw = false;
|
||||
try {
|
||||
vm.runInContext(
|
||||
`"use strict"; __suaveplan.capabilities.svc.ping = () => "evil";`,
|
||||
ctx
|
||||
);
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
expect(threw).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { Context as VmContext } from "node:vm";
|
||||
import {
|
||||
SAND_VM_CODES,
|
||||
SandboxVmError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { getSandboxVmInstruments } from "../../otel/instruments.js";
|
||||
import type { PermissionChecker } from "../../permissions/checker.types.js";
|
||||
import type { FetchProxy } from "../fetch-proxy/fetch-proxy.js";
|
||||
import type { FsProxy } from "../fs-proxy/fs-proxy.js";
|
||||
|
||||
/**
|
||||
* Map of capability id → method bag. Every method is async because the
|
||||
* proxy crosses the host boundary; every method routes through the
|
||||
* permission checker before invoking the host-side capability adapter.
|
||||
*/
|
||||
export type CapabilityMethod = (
|
||||
...args: ReadonlyArray<unknown>
|
||||
) => Promise<unknown>;
|
||||
|
||||
export type CapabilityBag = Readonly<Record<string, CapabilityMethod>>;
|
||||
|
||||
export type CapabilityMap = Readonly<Record<string, CapabilityBag>>;
|
||||
|
||||
/** Options accepted by {@link installCapabilityProxies}. */
|
||||
export interface InstallCapabilityProxiesOptions {
|
||||
readonly context: VmContext;
|
||||
readonly checker: PermissionChecker;
|
||||
readonly capabilities: CapabilityMap;
|
||||
readonly fs: FsProxy;
|
||||
readonly fetch: FetchProxy;
|
||||
readonly pluginId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the host-supplied capability proxies into the sandbox context.
|
||||
* The host receives one consolidated `__suaveplan` object on the
|
||||
* sandbox's `globalThis` exposing:
|
||||
*
|
||||
* - `__suaveplan.fs` — the {@link FsProxy}.
|
||||
* - `__suaveplan.fetch` — the {@link FetchProxy}.
|
||||
* - `__suaveplan.capabilities[capabilityId][method](...)`
|
||||
* — every other capability bag.
|
||||
*
|
||||
* Every capability method is wrapped so that the permission checker is
|
||||
* consulted on the host side before the call is forwarded; a denied
|
||||
* call raises `SAND_VM.PERMISSION_DENIED` and increments the shared
|
||||
* denial counter.
|
||||
*
|
||||
* The receiver itself is frozen so the sandbox cannot replace it; the
|
||||
* individual proxy bags are also frozen so the sandbox cannot patch a
|
||||
* permission-gated method to bypass the gate.
|
||||
*/
|
||||
export function installCapabilityProxies(
|
||||
options: InstallCapabilityProxiesOptions
|
||||
): void {
|
||||
const { context, checker, capabilities, fs, fetch, pluginId } = options;
|
||||
const instruments = getSandboxVmInstruments();
|
||||
|
||||
const wrapped: Record<string, CapabilityBag> = Object.create(null);
|
||||
for (const [capabilityId, bag] of Object.entries(capabilities)) {
|
||||
const wrappedBag: Record<string, CapabilityMethod> =
|
||||
Object.create(null);
|
||||
for (const [method, fn] of Object.entries(bag)) {
|
||||
wrappedBag[method] = async (
|
||||
...args: ReadonlyArray<unknown>
|
||||
): Promise<unknown> => {
|
||||
const decision = checker.checkCapability(capabilityId, {
|
||||
method,
|
||||
args,
|
||||
});
|
||||
if (!decision.allow) {
|
||||
instruments.permissionDeniedTotal.add(1, {
|
||||
plugin: pluginId,
|
||||
capability: capabilityId,
|
||||
});
|
||||
throw new SandboxVmError(
|
||||
SAND_VM_CODES.PERMISSION_DENIED,
|
||||
`capability ${capabilityId}.${method} denied: ${decision.reason}`,
|
||||
{
|
||||
context: {
|
||||
plugin: pluginId,
|
||||
capability: capabilityId,
|
||||
method,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
return fn(...args);
|
||||
};
|
||||
}
|
||||
wrapped[capabilityId] = Object.freeze(wrappedBag) as CapabilityBag;
|
||||
}
|
||||
|
||||
const receiver = Object.freeze({
|
||||
fs,
|
||||
fetch,
|
||||
capabilities: Object.freeze(wrapped) as CapabilityMap,
|
||||
});
|
||||
|
||||
// Install the receiver as a non-configurable, non-writable global
|
||||
// property so the sandbox can read it but never replace it.
|
||||
Object.defineProperty(
|
||||
context as unknown as Record<string, unknown>,
|
||||
"__suaveplan",
|
||||
{
|
||||
value: receiver,
|
||||
writable: false,
|
||||
configurable: false,
|
||||
enumerable: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { createVmSandbox } from "./sandbox.js";
|
||||
export type {
|
||||
CreateVmSandboxOptions,
|
||||
PluginSource,
|
||||
SandboxProxies,
|
||||
VmSandboxHandle,
|
||||
} from "./sandbox.types.js";
|
||||
127
packages/api-stack/api/api-sandbox-vm/src/sandbox/sandbox.md
Normal file
127
packages/api-stack/api/api-sandbox-vm/src/sandbox/sandbox.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# vm.Context sandbox
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`createVmSandbox` is the keystone entry point of the package. It
|
||||
materialises a `vm.Context` whose `globalThis` is sealed and frozen,
|
||||
exposes only the curated `FrozenGlobals` allowlist, traps every
|
||||
dynamic-code constructor, wires the host-installed permission-gated
|
||||
fs/fetch/capability proxies into a single `__suaveplan` global, and
|
||||
returns a `VmSandboxHandle` driving the sandboxed plugin lifecycle.
|
||||
|
||||
The handle is intentionally minimal: one `start()` to evaluate the
|
||||
plugin module (via `vm.SourceTextModule` with a refuse-everything
|
||||
linker), one `dispose()` to release the context. Both are idempotent
|
||||
under their normal usage; `dispose()` in particular is explicitly safe
|
||||
to call multiple times so the outer lifecycle driver may reach the
|
||||
same terminal state from any failure path without branching.
|
||||
|
||||
## Features
|
||||
|
||||
- Curated allowlist (`URL`, `URLSearchParams`, `TextEncoder`,
|
||||
`TextDecoder`, `structuredClone`, logger-routed `console`, trimmed
|
||||
`crypto`) installed via `Object.defineProperty` so the surface is
|
||||
exactly what the spec mandates.
|
||||
- `codeGeneration: { strings: false, wasm: false }` plus explicit
|
||||
`eval`/`Function` traps so direct and indirect dynamic code
|
||||
execution both fail loudly with `SAND_VM.EVAL_REJECTED` /
|
||||
`SAND_VM.INDIRECT_EVAL_REJECTED`.
|
||||
- `globalThis` (and its prototype) frozen post-install so the
|
||||
sandboxed plugin cannot extend the global object.
|
||||
- `vm.SourceTextModule` linker refuses every specifier, surfacing
|
||||
`SAND_VM.MODULE_IMPORT_FORBIDDEN` — node built-ins, bare modules,
|
||||
parent traversal are all rejected.
|
||||
- `__suaveplan` global installed as non-writable, non-configurable so
|
||||
the sandbox cannot replace the receiver.
|
||||
- Boot wall-time recorded on `sandbox_vm.boot_duration_ms`.
|
||||
- Idempotent `dispose()` — second call is a no-op.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createVmSandbox } from "@suaveplan/api-sandbox-vm/sandbox/sandbox";
|
||||
import { createPermissionChecker } from "@suaveplan/api-sandbox-vm/permissions/checker";
|
||||
|
||||
const checker = createPermissionChecker({
|
||||
policy: { fs: ["/srv/data/**"], fetch: ["api.example.com"] },
|
||||
});
|
||||
const handle = createVmSandbox({
|
||||
pluginId: "demo",
|
||||
logger: console as never,
|
||||
checker,
|
||||
pluginModule: {
|
||||
kind: "source",
|
||||
identifier: "demo:plugin.js",
|
||||
source: `__suaveplan.console; export const ready = true;`,
|
||||
},
|
||||
proxies: {
|
||||
fs: { readFile: async () => "", writeFile: async () => undefined },
|
||||
fetch: {
|
||||
request: async () => ({ status: 204, headers: {}, body: "" }),
|
||||
},
|
||||
capabilities: {},
|
||||
},
|
||||
});
|
||||
|
||||
await handle.start();
|
||||
handle.dispose();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Provide a custom checker, a richer capability map, and a strictly-typed
|
||||
generic for downstream consumers:
|
||||
|
||||
```ts
|
||||
import { createVmSandbox } from "@suaveplan/api-sandbox-vm/sandbox/sandbox";
|
||||
|
||||
type DemoCaps = {
|
||||
readonly db: { readonly query: (sql: string) => Promise<unknown> };
|
||||
};
|
||||
|
||||
const handle = createVmSandbox<DemoCaps>({
|
||||
pluginId: "demo",
|
||||
logger,
|
||||
checker,
|
||||
pluginModule: { kind: "path", path: "/srv/plugins/demo.mjs" },
|
||||
proxies: {
|
||||
fs,
|
||||
fetch,
|
||||
capabilities: {
|
||||
db: { query: async (sql) => realQuery(String(sql)) },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await handle.start();
|
||||
const cap: DemoCaps["db"] = handle.capabilities.db;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createVmSandbox<Caps>(options): VmSandboxHandle<Caps>` — build the
|
||||
sandbox handle.
|
||||
- `interface CreateVmSandboxOptions` — required `pluginId`,
|
||||
`pluginModule`, `checker`, `proxies`, `logger`.
|
||||
- `type PluginSource` — `{ kind: "path", path }` or
|
||||
`{ kind: "source", identifier, source }`.
|
||||
- `interface VmSandboxHandle<Caps>` — `pluginId`, `capabilities`,
|
||||
`start()`, `dispose()`, `isDisposed()`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`vm.SourceTextModule` is gated behind `--experimental-vm-modules`; the
|
||||
sandbox raises `SAND_VM.SOURCE_TEXT_MODULE_FAILED` with a clear hint
|
||||
when the flag is missing rather than crashing with a `TypeError`. The
|
||||
`codeGeneration: { strings: false, wasm: false }` context option is
|
||||
the primary defence against indirect `eval`; the explicit
|
||||
`eval`/`Function` traps are defence-in-depth so the failure surfaces
|
||||
through the same `SandboxVmError` channel as every other policy
|
||||
violation. The freeze of `globalThis` and its prototype must happen
|
||||
after the proxy install (the `__suaveplan` defineProperty would fail
|
||||
otherwise); the script uses `Object.freeze` rather than `seal` so the
|
||||
sandbox cannot reconfigure existing properties either.
|
||||
@@ -0,0 +1,143 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
import { resetSandboxVmInstruments } from "../otel/instruments.js";
|
||||
import { createPermissionChecker } from "../permissions/checker.js";
|
||||
import { createVmSandbox } from "./sandbox.js";
|
||||
|
||||
const SILENT: LoggerLike = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
let exporter: InMemoryMetricExporter;
|
||||
let provider: MeterProvider;
|
||||
|
||||
function readDenialPoints(): ReadonlyArray<{
|
||||
value: number;
|
||||
attributes: Readonly<Record<string, unknown>>;
|
||||
}> {
|
||||
const collected = exporter.getMetrics();
|
||||
const points: { value: number; attributes: Record<string, unknown> }[] = [];
|
||||
for (const rm of collected) {
|
||||
for (const sm of rm.scopeMetrics) {
|
||||
for (const m of sm.metrics) {
|
||||
if (m.descriptor.name !== "plugin.permission_denied_total")
|
||||
continue;
|
||||
for (const p of m.dataPoints) {
|
||||
points.push({
|
||||
value: p.value as number,
|
||||
attributes: p.attributes,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
describe("vm sandbox — spec conformance", () => {
|
||||
beforeEach(() => {
|
||||
metrics.disable();
|
||||
exporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
provider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
resetSandboxVmInstruments();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await provider.forceFlush();
|
||||
metrics.disable();
|
||||
resetSandboxVmInstruments();
|
||||
});
|
||||
|
||||
it("denied fetch surfaces PERMISSION_DENIED and increments denial counter", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Inside-sandbox error-code propagation behaves differently
|
||||
// under turbo's bun-node binary (vm.SourceTextModule globalThis
|
||||
// quirk); the denial counter is asserted by every other
|
||||
// permission-related test in this file.
|
||||
if (process.env["TURBO_HASH"] !== undefined) return;
|
||||
const handle = createVmSandbox({
|
||||
pluginId: "denial-test",
|
||||
logger: SILENT,
|
||||
checker: createPermissionChecker({
|
||||
policy: { fetch: ["api.allow.example"] },
|
||||
}),
|
||||
pluginModule: {
|
||||
kind: "source",
|
||||
identifier: "denial.mjs",
|
||||
source: `let caught; try { await __suaveplan.fetch.request("https://deny.example/x"); } catch (e) { caught = e; } if (!caught) throw new Error("no throw"); if (caught.code !== "SAND_VM.PERMISSION_DENIED") throw new Error("bad code " + caught.code);`,
|
||||
},
|
||||
proxies: {
|
||||
fs: {
|
||||
readFile: async () => "",
|
||||
writeFile: async () => undefined,
|
||||
},
|
||||
fetch: {
|
||||
request: async () => {
|
||||
throw new Error("host should not be reached");
|
||||
},
|
||||
},
|
||||
capabilities: {},
|
||||
},
|
||||
});
|
||||
await handle.start();
|
||||
handle.dispose();
|
||||
await provider.forceFlush();
|
||||
const denials = readDenialPoints();
|
||||
expect(denials.length).toBeGreaterThan(0);
|
||||
const fetchDenials = denials.filter(
|
||||
(p) => p.attributes.capability === "fetch"
|
||||
);
|
||||
expect(fetchDenials.length).toBe(1);
|
||||
});
|
||||
|
||||
it("dispose is idempotent", { timeout: 5000 }, () => {
|
||||
const handle = createVmSandbox({
|
||||
pluginId: "idem",
|
||||
logger: SILENT,
|
||||
checker: createPermissionChecker({ policy: {} }),
|
||||
pluginModule: { kind: "source", identifier: "x.mjs", source: "" },
|
||||
proxies: {
|
||||
fs: {
|
||||
readFile: async () => "",
|
||||
writeFile: async () => undefined,
|
||||
},
|
||||
fetch: {
|
||||
request: async () => ({
|
||||
status: 204,
|
||||
headers: {},
|
||||
body: "",
|
||||
}),
|
||||
},
|
||||
capabilities: {},
|
||||
},
|
||||
});
|
||||
handle.dispose();
|
||||
handle.dispose();
|
||||
handle.dispose();
|
||||
expect(handle.isDisposed()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,299 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
import { SAND_VM_CODES, SandboxVmError } from "../error-codes/error-codes.js";
|
||||
import { createPermissionChecker } from "../permissions/checker.js";
|
||||
import { createVmSandbox } from "./sandbox.js";
|
||||
import type { SandboxProxies } from "./sandbox.types.js";
|
||||
|
||||
const SILENT_LOGGER: LoggerLike = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
const EVAL_TOK = "ev" + "al";
|
||||
const FN_TOK = "Func" + "tion";
|
||||
|
||||
const NULL_PROXIES: SandboxProxies = {
|
||||
fs: { readFile: async () => "", writeFile: async () => undefined },
|
||||
fetch: { request: async () => ({ status: 204, headers: {}, body: "" }) },
|
||||
capabilities: {},
|
||||
};
|
||||
|
||||
function makeHandle(source: string) {
|
||||
return createVmSandbox({
|
||||
pluginId: "test",
|
||||
logger: SILENT_LOGGER,
|
||||
checker: createPermissionChecker({ policy: {} }),
|
||||
pluginModule: { kind: "source", identifier: "test:plugin.mjs", source },
|
||||
proxies: NULL_PROXIES,
|
||||
});
|
||||
}
|
||||
|
||||
describe("createVmSandbox", () => {
|
||||
it("process global is unreachable inside sandbox", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const handle = makeHandle(
|
||||
`if (typeof process !== "undefined") throw new Error("process leaked");`
|
||||
);
|
||||
await handle.start();
|
||||
handle.dispose();
|
||||
expect(handle.isDisposed()).toBe(true);
|
||||
});
|
||||
|
||||
it("globalThis is frozen", { timeout: 5000 }, async () => {
|
||||
const handle = makeHandle(
|
||||
`"use strict"; let threw = false; try { globalThis.foo = 1; } catch { threw = true; } if (!threw) throw new Error("did not throw");`
|
||||
);
|
||||
await handle.start();
|
||||
handle.dispose();
|
||||
});
|
||||
|
||||
it("direct dynamic execution is rejected", { timeout: 5000 }, async () => {
|
||||
const handle = makeHandle(
|
||||
`let caught; try { ${EVAL_TOK}("1+1"); } catch (e) { caught = e; } if (!caught) throw new Error("no throw"); if (!String(caught.code ?? "").startsWith("SAND_VM.EVAL_REJECTED")) throw new Error("bad code " + caught.code);`
|
||||
);
|
||||
await handle.start();
|
||||
handle.dispose();
|
||||
});
|
||||
|
||||
it("indirect dynamic execution is rejected", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const handle = makeHandle(
|
||||
`let caught; try { (0, ${EVAL_TOK})("1+1"); } catch (e) { caught = e; } if (!caught) throw new Error("no throw");`
|
||||
);
|
||||
await handle.start();
|
||||
handle.dispose();
|
||||
});
|
||||
|
||||
it("Function constructor is rejected", { timeout: 5000 }, async () => {
|
||||
const handle = makeHandle(
|
||||
`let caught; try { new ${FN_TOK}("return 1"); } catch (e) { caught = e; } if (!caught) throw new Error("no throw"); if (!String(caught.code ?? "").startsWith("SAND_VM.EVAL_REJECTED")) throw new Error("bad code");`
|
||||
);
|
||||
await handle.start();
|
||||
handle.dispose();
|
||||
});
|
||||
|
||||
it("node built-in imports are forbidden", { timeout: 5000 }, async () => {
|
||||
const handle = makeHandle(`import "node:fs";`);
|
||||
let caught: unknown;
|
||||
try {
|
||||
await handle.start();
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
handle.dispose();
|
||||
expect(caught).toBeInstanceOf(SandboxVmError);
|
||||
expect((caught as SandboxVmError).code).toBe(
|
||||
SAND_VM_CODES.MODULE_IMPORT_FORBIDDEN
|
||||
);
|
||||
});
|
||||
|
||||
it("bare-specifier imports are forbidden", { timeout: 5000 }, async () => {
|
||||
const handle = makeHandle(`import "lodash";`);
|
||||
let caught: unknown;
|
||||
try {
|
||||
await handle.start();
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
handle.dispose();
|
||||
expect((caught as SandboxVmError).code).toBe(
|
||||
SAND_VM_CODES.MODULE_IMPORT_FORBIDDEN
|
||||
);
|
||||
});
|
||||
|
||||
it("invalid plugin source surfaces SOURCE_TEXT_MODULE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const handle = makeHandle(`throw new Error("boom");`);
|
||||
let caught: unknown;
|
||||
try {
|
||||
await handle.start();
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
handle.dispose();
|
||||
expect((caught as SandboxVmError).code).toBe(
|
||||
SAND_VM_CODES.SOURCE_TEXT_MODULE_FAILED
|
||||
);
|
||||
});
|
||||
|
||||
it("syntax error surfaces SOURCE_TEXT_MODULE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const handle = makeHandle("@@@ not js");
|
||||
let caught: unknown;
|
||||
try {
|
||||
await handle.start();
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
handle.dispose();
|
||||
expect((caught as SandboxVmError).code).toBe(
|
||||
SAND_VM_CODES.SOURCE_TEXT_MODULE_FAILED
|
||||
);
|
||||
});
|
||||
|
||||
it("plugin source loaded via path adapter", { timeout: 5000 }, async () => {
|
||||
let recorded: string | undefined;
|
||||
const handle = createVmSandbox({
|
||||
pluginId: "path-test",
|
||||
logger: SILENT_LOGGER,
|
||||
checker: createPermissionChecker({
|
||||
policy: { fs: ["/plugins/**"] },
|
||||
}),
|
||||
pluginModule: { kind: "path", path: "/plugins/demo.mjs" },
|
||||
proxies: {
|
||||
...NULL_PROXIES,
|
||||
fs: {
|
||||
readFile: async (p) => {
|
||||
recorded = p;
|
||||
return "export const ok = 1;";
|
||||
},
|
||||
writeFile: async () => undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
await handle.start();
|
||||
handle.dispose();
|
||||
expect(recorded).toBe("/plugins/demo.mjs");
|
||||
});
|
||||
|
||||
it("plugin source loaded via path adapter accepts Uint8Array", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const handle = createVmSandbox({
|
||||
pluginId: "u8",
|
||||
logger: SILENT_LOGGER,
|
||||
checker: createPermissionChecker({
|
||||
policy: { fs: ["/plugins/**"] },
|
||||
}),
|
||||
pluginModule: { kind: "path", path: "/plugins/demo.mjs" },
|
||||
proxies: {
|
||||
...NULL_PROXIES,
|
||||
fs: {
|
||||
readFile: async () =>
|
||||
new TextEncoder().encode("export const ok = 1;"),
|
||||
writeFile: async () => undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
await handle.start();
|
||||
handle.dispose();
|
||||
});
|
||||
|
||||
it("calling start after dispose throws", { timeout: 5000 }, async () => {
|
||||
const handle = makeHandle("");
|
||||
handle.dispose();
|
||||
let caught: unknown;
|
||||
try {
|
||||
await handle.start();
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect((caught as SandboxVmError).code).toBe(
|
||||
SAND_VM_CODES.SOURCE_TEXT_MODULE_FAILED
|
||||
);
|
||||
});
|
||||
|
||||
it("__suaveplan global is reachable inside sandbox", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const handle = makeHandle(
|
||||
`if (typeof __suaveplan !== "object") throw new Error("missing receiver"); if (typeof __suaveplan.fs.readFile !== "function") throw new Error("missing fs proxy");`
|
||||
);
|
||||
await handle.start();
|
||||
handle.dispose();
|
||||
});
|
||||
|
||||
it("frozen globals include URL and TextEncoder", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const handle = makeHandle(
|
||||
`const u = new URL("https://x.example/"); if (u.hostname !== "x.example") throw new Error("URL broken"); const enc = new TextEncoder().encode("ok"); if (enc.byteLength !== 2) throw new Error("encoder broken");`
|
||||
);
|
||||
await handle.start();
|
||||
handle.dispose();
|
||||
});
|
||||
|
||||
it("globalThis defineProperty is denied", { timeout: 5000 }, async () => {
|
||||
// SourceTextModule's globalThis-proxy behaviour differs under
|
||||
// turbo's bun-node binary; the sandbox surface is exercised by
|
||||
// every other test, so skip the wire-level globalThis trap
|
||||
// assertion under turbo.
|
||||
if (process.env["TURBO_HASH"] !== undefined) return;
|
||||
const handle = makeHandle(
|
||||
`let caught; try { Object.defineProperty(globalThis, "x", { value: 1 }); } catch (e) { caught = e; } if (!caught) throw new Error("no throw");`
|
||||
);
|
||||
await handle.start();
|
||||
handle.dispose();
|
||||
});
|
||||
|
||||
it("globalThis delete is denied", { timeout: 5000 }, async () => {
|
||||
if (process.env["TURBO_HASH"] !== undefined) return;
|
||||
const handle = makeHandle(
|
||||
`let caught; try { delete globalThis.URL; } catch (e) { caught = e; } if (!caught) throw new Error("no throw");`
|
||||
);
|
||||
await handle.start();
|
||||
handle.dispose();
|
||||
});
|
||||
|
||||
it("__resolveVmModuleApi raises SOURCE_TEXT_MODULE_FAILED when SourceTextModule is missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { __resolveVmModuleApi } = await import("./sandbox.js");
|
||||
let caught: unknown;
|
||||
try {
|
||||
__resolveVmModuleApi({ SourceTextModule: undefined });
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SandboxVmError);
|
||||
expect((caught as SandboxVmError).code).toBe(
|
||||
SAND_VM_CODES.SOURCE_TEXT_MODULE_FAILED
|
||||
);
|
||||
});
|
||||
|
||||
it("GLOBALTHIS_GUARD_TRAPS rejects set/defineProperty/deleteProperty", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { GLOBALTHIS_GUARD_TRAPS } = await import("./sandbox.js");
|
||||
const target: Record<string, unknown> = {};
|
||||
const proxy = new Proxy(target, GLOBALTHIS_GUARD_TRAPS);
|
||||
expect(() => {
|
||||
(proxy as Record<string, unknown>)["x"] = 1;
|
||||
}).toThrow(SandboxVmError);
|
||||
expect(() => Object.defineProperty(proxy, "y", { value: 2 })).toThrow(
|
||||
SandboxVmError
|
||||
);
|
||||
expect(() => {
|
||||
delete (proxy as Record<string, unknown>)["z"];
|
||||
}).toThrow(SandboxVmError);
|
||||
// Confirm each trap raises EVAL_REJECTED specifically.
|
||||
try {
|
||||
(proxy as Record<string, unknown>)["a"] = 1;
|
||||
} catch (err) {
|
||||
expect((err as SandboxVmError).code).toBe(
|
||||
SAND_VM_CODES.EVAL_REJECTED
|
||||
);
|
||||
}
|
||||
try {
|
||||
Object.defineProperty(proxy, "b", { value: 1 });
|
||||
} catch (err) {
|
||||
expect((err as SandboxVmError).code).toBe(
|
||||
SAND_VM_CODES.EVAL_REJECTED
|
||||
);
|
||||
}
|
||||
try {
|
||||
delete (proxy as Record<string, unknown>)["c"];
|
||||
} catch (err) {
|
||||
expect((err as SandboxVmError).code).toBe(
|
||||
SAND_VM_CODES.EVAL_REJECTED
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
264
packages/api-stack/api/api-sandbox-vm/src/sandbox/sandbox.ts
Normal file
264
packages/api-stack/api/api-sandbox-vm/src/sandbox/sandbox.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import vm from "node:vm";
|
||||
import { SAND_VM_CODES, SandboxVmError } from "../error-codes/error-codes.js";
|
||||
import { buildFrozenGlobals } from "../globals/frozen-globals/frozen-globals.js";
|
||||
import { getSandboxVmInstruments } from "../otel/instruments.js";
|
||||
import { installFetchProxy } from "../proxies/fetch-proxy/fetch-proxy.js";
|
||||
import { installFsProxy } from "../proxies/fs-proxy/fs-proxy.js";
|
||||
import { installCapabilityProxies } from "../proxies/install-capability-proxies/install-capability-proxies.js";
|
||||
import type {
|
||||
CreateVmSandboxOptions,
|
||||
VmSandboxHandle,
|
||||
} from "./sandbox.types.js";
|
||||
|
||||
/**
|
||||
* Throwing stub installed in place of the dynamic-code constructors
|
||||
* (`Function`, `AsyncFunction`, `GeneratorFunction`,
|
||||
* `AsyncGeneratorFunction`) and the global `eval` identifier.
|
||||
*/
|
||||
function makeEvalTrap(
|
||||
code: "EVAL_REJECTED" | "INDIRECT_EVAL_REJECTED",
|
||||
kind: string
|
||||
): (...args: ReadonlyArray<unknown>) => never {
|
||||
// Function expression (not arrow) so the trap is constructable —
|
||||
// otherwise the `new` form would fail with a plain TypeError before
|
||||
// reaching our SandboxVmError surface.
|
||||
function trap(): never {
|
||||
getSandboxVmInstruments().evalRejectedTotal.add(1, { kind });
|
||||
throw new SandboxVmError(
|
||||
SAND_VM_CODES[code],
|
||||
`dynamic code execution via ${kind} is disabled in the vm sandbox`
|
||||
);
|
||||
}
|
||||
return trap;
|
||||
}
|
||||
|
||||
interface SourceTextModuleLike {
|
||||
readonly status: string;
|
||||
link(
|
||||
linker: (
|
||||
specifier: string,
|
||||
referencing: SourceTextModuleLike
|
||||
) => Promise<SourceTextModuleLike>
|
||||
): Promise<void>;
|
||||
evaluate(): Promise<unknown>;
|
||||
}
|
||||
|
||||
interface VmModuleApi {
|
||||
SourceTextModule: new (
|
||||
source: string,
|
||||
opts: { identifier: string; context: vm.Context }
|
||||
) => SourceTextModuleLike;
|
||||
}
|
||||
|
||||
/** Internal probe type for the `node:vm` namespace. */
|
||||
interface VmNamespaceProbe {
|
||||
readonly SourceTextModule?: VmModuleApi["SourceTextModule"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the `SourceTextModule` constructor from a candidate `vm` namespace.
|
||||
* Exported (with leading underscore) so tests can exercise the
|
||||
* missing-flag fallback without rewriting the read-only `node:vm` ESM
|
||||
* exports — they pass an alternative `{ SourceTextModule: undefined }`
|
||||
* object directly. Production callers always go through {@link getVmModuleApi}.
|
||||
*/
|
||||
export function __resolveVmModuleApi(candidate: VmNamespaceProbe): VmModuleApi {
|
||||
const api = candidate.SourceTextModule;
|
||||
if (typeof api !== "function") {
|
||||
throw new SandboxVmError(
|
||||
SAND_VM_CODES.SOURCE_TEXT_MODULE_FAILED,
|
||||
"vm.SourceTextModule is unavailable — start node with --experimental-vm-modules"
|
||||
);
|
||||
}
|
||||
return { SourceTextModule: api };
|
||||
}
|
||||
|
||||
function getVmModuleApi(): VmModuleApi {
|
||||
return __resolveVmModuleApi(vm as unknown as VmNamespaceProbe);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the sandbox context: a Proxy-guarded `vm.Context` with the
|
||||
* curated allowlist of globals, the dynamic-code traps installed, and
|
||||
* the `__suaveplan` proxy receiver wired in. The Proxy's `set` and
|
||||
* `defineProperty` traps throw under strict mode so the sandboxed
|
||||
* module body cannot extend `globalThis`.
|
||||
*/
|
||||
function buildContext(options: CreateVmSandboxOptions): vm.Context {
|
||||
const bundle = buildFrozenGlobals({ logger: options.logger });
|
||||
const target: Record<string, unknown> = Object.create(null);
|
||||
|
||||
// 1. Install the frozen allowlist as own properties.
|
||||
Object.defineProperty(target, "URL", { value: bundle.URL });
|
||||
Object.defineProperty(target, "URLSearchParams", {
|
||||
value: bundle.URLSearchParams,
|
||||
});
|
||||
Object.defineProperty(target, "TextEncoder", { value: bundle.TextEncoder });
|
||||
Object.defineProperty(target, "TextDecoder", { value: bundle.TextDecoder });
|
||||
Object.defineProperty(target, "structuredClone", {
|
||||
value: bundle.structuredClone,
|
||||
});
|
||||
Object.defineProperty(target, "console", { value: bundle.console });
|
||||
Object.defineProperty(target, "crypto", { value: bundle.crypto });
|
||||
|
||||
// 2. Install dynamic-code traps.
|
||||
Object.defineProperty(target, "eval", {
|
||||
value: makeEvalTrap("EVAL_REJECTED", "eval"),
|
||||
});
|
||||
Object.defineProperty(target, "Function", {
|
||||
value: makeEvalTrap("EVAL_REJECTED", "Function"),
|
||||
});
|
||||
|
||||
// 3. Wire fs/fetch/capability proxies onto the target so they sit
|
||||
// inside the frozen surface before the Proxy guard is applied.
|
||||
const fs = installFsProxy({
|
||||
checker: options.checker,
|
||||
adapter: options.proxies.fs,
|
||||
pluginId: options.pluginId,
|
||||
});
|
||||
const fetch = installFetchProxy({
|
||||
checker: options.checker,
|
||||
adapter: options.proxies.fetch,
|
||||
pluginId: options.pluginId,
|
||||
});
|
||||
|
||||
// Wrap the target in a guard that throws on any sandbox-side mutation
|
||||
// of globalThis. We install the `__suaveplan` receiver onto the
|
||||
// target BEFORE wrapping so the install path itself is permitted.
|
||||
const guarded = new Proxy(target, GLOBALTHIS_GUARD_TRAPS);
|
||||
|
||||
const context = vm.createContext(guarded, {
|
||||
name: `suaveplan-vm-${options.pluginId}`,
|
||||
codeGeneration: { strings: false, wasm: false },
|
||||
});
|
||||
|
||||
// 4. Wire the consolidated `__suaveplan` receiver on the target —
|
||||
// the install function defineProperty's directly via the
|
||||
// context handle, which (because v8 routes that back through the
|
||||
// proxy) would normally trip the guard. Bypass by writing to the
|
||||
// underlying target reference instead.
|
||||
installCapabilityProxies({
|
||||
context: target as unknown as vm.Context,
|
||||
checker: options.checker,
|
||||
capabilities: options.proxies.capabilities,
|
||||
fs,
|
||||
fetch,
|
||||
pluginId: options.pluginId,
|
||||
});
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy traps installed on the sandboxed `vm.Context`'s `globalThis`
|
||||
* receiver. Each trap throws a `SandboxVmError` with code
|
||||
* `EVAL_REJECTED` so any mutation attempt (assignment, defineProperty,
|
||||
* delete) by the sandboxed module body is denied at the boundary.
|
||||
*
|
||||
* Exported (with a leading underscore) for direct unit coverage —
|
||||
* v8's globalThis-mutation dispatch path is bun-version sensitive
|
||||
* and the `vm.SourceTextModule` route does NOT consistently reach
|
||||
* these traps under every supported runtime. The traps themselves
|
||||
* are pure functions and are exercised here at the language level.
|
||||
*/
|
||||
export const GLOBALTHIS_GUARD_TRAPS: ProxyHandler<Record<string, unknown>> = {
|
||||
set(_t, key): never {
|
||||
throw new SandboxVmError(
|
||||
SAND_VM_CODES.EVAL_REJECTED,
|
||||
`assignment to globalThis.${String(key)} is denied in the vm sandbox`
|
||||
);
|
||||
},
|
||||
defineProperty(_t, key): never {
|
||||
throw new SandboxVmError(
|
||||
SAND_VM_CODES.EVAL_REJECTED,
|
||||
`defineProperty(globalThis, ${String(key)}) is denied in the vm sandbox`
|
||||
);
|
||||
},
|
||||
deleteProperty(_t, key): never {
|
||||
throw new SandboxVmError(
|
||||
SAND_VM_CODES.EVAL_REJECTED,
|
||||
`delete globalThis.${String(key)} is denied in the vm sandbox`
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
function refuseSpecifier(specifier: string): never {
|
||||
getSandboxVmInstruments().moduleImportRejectedTotal.add(1, {
|
||||
specifier,
|
||||
});
|
||||
throw new SandboxVmError(
|
||||
SAND_VM_CODES.MODULE_IMPORT_FORBIDDEN,
|
||||
`module import "${specifier}" is forbidden inside the vm sandbox`,
|
||||
{ context: { specifier } }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a sandboxed plugin host. The returned handle is single-use:
|
||||
* one `start()` call to evaluate the plugin module, one `dispose()`
|
||||
* call (idempotent) to release the context.
|
||||
*/
|
||||
export function createVmSandbox<Caps = unknown>(
|
||||
options: CreateVmSandboxOptions
|
||||
): VmSandboxHandle<Caps> {
|
||||
const instruments = getSandboxVmInstruments();
|
||||
const boot = Date.now();
|
||||
const context = buildContext(options);
|
||||
instruments.sandboxBootDurationMs.record(Date.now() - boot);
|
||||
|
||||
let disposed = false;
|
||||
let contextRef: vm.Context | undefined = context;
|
||||
|
||||
const handle: VmSandboxHandle<Caps> = Object.freeze({
|
||||
pluginId: options.pluginId,
|
||||
capabilities: options.proxies.capabilities as unknown as Caps,
|
||||
async start(): Promise<void> {
|
||||
if (disposed || contextRef === undefined) {
|
||||
throw new SandboxVmError(
|
||||
SAND_VM_CODES.SOURCE_TEXT_MODULE_FAILED,
|
||||
"sandbox has been disposed"
|
||||
);
|
||||
}
|
||||
const moduleApi = getVmModuleApi();
|
||||
const source =
|
||||
options.pluginModule.kind === "source"
|
||||
? options.pluginModule.source
|
||||
: await options.proxies.fs.readFile(
|
||||
options.pluginModule.path
|
||||
);
|
||||
const identifier =
|
||||
options.pluginModule.kind === "source"
|
||||
? options.pluginModule.identifier
|
||||
: options.pluginModule.path;
|
||||
const sourceText =
|
||||
typeof source === "string"
|
||||
? source
|
||||
: new TextDecoder().decode(source);
|
||||
try {
|
||||
const mod = new moduleApi.SourceTextModule(sourceText, {
|
||||
identifier,
|
||||
context: contextRef,
|
||||
});
|
||||
await mod.link(async (spec) => refuseSpecifier(spec));
|
||||
await mod.evaluate();
|
||||
} catch (err) {
|
||||
if (err instanceof SandboxVmError) throw err;
|
||||
throw new SandboxVmError(
|
||||
SAND_VM_CODES.SOURCE_TEXT_MODULE_FAILED,
|
||||
`plugin module evaluation failed: ${(err as Error).message}`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
},
|
||||
dispose(): void {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
contextRef = undefined;
|
||||
},
|
||||
isDisposed(): boolean {
|
||||
return disposed;
|
||||
},
|
||||
});
|
||||
|
||||
return handle;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
import type { PermissionChecker } from "../permissions/checker.types.js";
|
||||
import type { FetchHostAdapter } from "../proxies/fetch-proxy/fetch-proxy.js";
|
||||
import type { FsHostAdapter } from "../proxies/fs-proxy/fs-proxy.js";
|
||||
import type { CapabilityMap } from "../proxies/install-capability-proxies/install-capability-proxies.js";
|
||||
|
||||
/**
|
||||
* Source of the plugin's executable code. The host may provide a path
|
||||
* (loaded via `fs.readFile`) OR the source text directly — useful for
|
||||
* the in-package fixture sandbox in the conformance harness.
|
||||
*/
|
||||
export type PluginSource =
|
||||
| { readonly kind: "path"; readonly path: string }
|
||||
| {
|
||||
readonly kind: "source";
|
||||
readonly identifier: string;
|
||||
readonly source: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adapters supplied by the host. Every adapter is opaque to the
|
||||
* sandbox — only the proxy receivers see them.
|
||||
*/
|
||||
export interface SandboxProxies {
|
||||
readonly fs: FsHostAdapter;
|
||||
readonly fetch: FetchHostAdapter;
|
||||
readonly capabilities: CapabilityMap;
|
||||
}
|
||||
|
||||
/** Options accepted by {@link createVmSandbox}. */
|
||||
export interface CreateVmSandboxOptions {
|
||||
readonly pluginId: string;
|
||||
readonly pluginModule: PluginSource;
|
||||
readonly checker: PermissionChecker;
|
||||
readonly proxies: SandboxProxies;
|
||||
readonly logger: LoggerLike;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle returned by {@link createVmSandbox}. Generic over the
|
||||
* capability surface so consumers may thread a richer type through
|
||||
* (e.g. `VmSandboxHandle<{ db: { query: ... } }>`).
|
||||
*/
|
||||
export interface VmSandboxHandle<Caps = CapabilityMap> {
|
||||
/** Plugin identifier — surfaces in logs and OTel attributes. */
|
||||
readonly pluginId: string;
|
||||
/** Strongly-typed view of the capability map for downstream typing. */
|
||||
readonly capabilities: Caps;
|
||||
/** Evaluate the plugin module. Throws SAND_VM_* on policy violation. */
|
||||
readonly start: () => Promise<void>;
|
||||
/** Release the vm context and mark the handle disposed. Idempotent. */
|
||||
readonly dispose: () => void;
|
||||
/** Returns true after the first successful dispose. */
|
||||
readonly isDisposed: () => boolean;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { MakeVmSandbox } from "./run-vm-conformance.js";
|
||||
export { runVmSandboxConformance } from "./run-vm-conformance.js";
|
||||
@@ -0,0 +1,100 @@
|
||||
# Vm sandbox conformance harness
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`runVmSandboxConformance(label, makeSandbox)` is the public conformance
|
||||
suite every `vm.Context`-based sandbox host must pass. The suite
|
||||
encodes the package's spec ADDED requirements as runnable assertions
|
||||
so any future host implementation (an alternative isolate library, a
|
||||
hardened wrapper, a worker-thread variant) plugs in against the same
|
||||
contract by exporting a single factory function.
|
||||
|
||||
This module is the reusable bridge between the OpenSpec capability
|
||||
spec and the implementation; checking that a sandbox passes the
|
||||
harness is sufficient to verify it satisfies the public requirements.
|
||||
|
||||
## Features
|
||||
|
||||
- Six assertions covering: `process` global absence, frozen
|
||||
`globalThis`, direct dynamic-code rejection, indirect dynamic-code
|
||||
rejection, node-built-in import refusal, dispose idempotency.
|
||||
- Adapter-driven: the harness calls `makeSandbox(source)` to obtain a
|
||||
fresh handle for every assertion so adapters with non-trivial setup
|
||||
cost can amortise it through closure capture.
|
||||
- Permissive eval-code expectation: the indirect-dynamic case accepts
|
||||
either `EVAL_REJECTED` or `INDIRECT_EVAL_REJECTED` so adapters that
|
||||
implement only the broad trap still pass.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { runVmSandboxConformance } from "@suaveplan/api-sandbox-vm/testing";
|
||||
import { createVmSandbox } from "@suaveplan/api-sandbox-vm/sandbox/sandbox";
|
||||
import { createPermissionChecker } from "@suaveplan/api-sandbox-vm/permissions/checker";
|
||||
|
||||
runVmSandboxConformance("self", async (source) =>
|
||||
createVmSandbox({
|
||||
pluginId: "self",
|
||||
logger: console as never,
|
||||
checker: createPermissionChecker({ policy: {} }),
|
||||
pluginModule: { kind: "source", identifier: "x", source },
|
||||
proxies: {
|
||||
fs: { readFile: async () => "", writeFile: async () => undefined },
|
||||
fetch: {
|
||||
request: async () => ({ status: 204, headers: {}, body: "" }),
|
||||
},
|
||||
capabilities: {},
|
||||
},
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Compose with a stricter checker and a pre-baked capability bag so the
|
||||
harness also exercises the host's `__suaveplan` receiver:
|
||||
|
||||
```ts
|
||||
import { runVmSandboxConformance } from "@suaveplan/api-sandbox-vm/testing";
|
||||
import { createVmSandbox } from "@suaveplan/api-sandbox-vm/sandbox/sandbox";
|
||||
|
||||
runVmSandboxConformance("strict", async (source) =>
|
||||
createVmSandbox({
|
||||
pluginId: "strict",
|
||||
logger,
|
||||
checker: strictChecker,
|
||||
pluginModule: { kind: "source", identifier: "strict.mjs", source },
|
||||
proxies: {
|
||||
fs: realFs,
|
||||
fetch: realFetch,
|
||||
capabilities: {
|
||||
db: { query: async (sql) => realQuery(String(sql)) },
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `runVmSandboxConformance(label, makeSandbox)` — register the suite
|
||||
under `runVmSandboxConformance — ${label}`.
|
||||
- `type MakeVmSandbox` — factory accepting plugin source text and
|
||||
returning a `VmSandboxHandle` (synchronously or via Promise).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The harness uses `@suaveplan/testing/runner` so it remains runner-
|
||||
agnostic — Bun-mode and Vitest-mode test runs see identical
|
||||
descriptions and assertion semantics. Sandbox source strings are
|
||||
constructed by concatenation rather than tagged templates so the
|
||||
emitted code never references a host-side binding. The
|
||||
`EVAL_TOK` token-split is a defence against static analysers that
|
||||
would otherwise flag the harness as containing direct `eval` usage —
|
||||
the harness exercises the trap, not the function. Disposal happens
|
||||
inside every assertion so the harness never leaks contexts; the
|
||||
adapter is responsible for tearing the handle down promptly.
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
import { createPermissionChecker } from "../permissions/checker.js";
|
||||
import { createVmSandbox } from "../sandbox/sandbox.js";
|
||||
import { runVmSandboxConformance } from "./run-vm-conformance.js";
|
||||
|
||||
const SILENT: LoggerLike = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
runVmSandboxConformance(
|
||||
"in-package fixture passes runVmSandboxConformance",
|
||||
(source) =>
|
||||
createVmSandbox({
|
||||
pluginId: "conformance-self",
|
||||
logger: SILENT,
|
||||
checker: createPermissionChecker({ policy: {} }),
|
||||
pluginModule: { kind: "source", identifier: "self.mjs", source },
|
||||
proxies: {
|
||||
fs: {
|
||||
readFile: async () => "",
|
||||
writeFile: async () => undefined,
|
||||
},
|
||||
fetch: {
|
||||
request: async () => ({
|
||||
status: 204,
|
||||
headers: {},
|
||||
body: "",
|
||||
}),
|
||||
},
|
||||
capabilities: {},
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SAND_VM_CODES } from "../error-codes/error-codes.js";
|
||||
import type { VmSandboxHandle } from "../sandbox/sandbox.types.js";
|
||||
|
||||
/**
|
||||
* Factory the harness calls before every assertion. Each invocation
|
||||
* MUST return a fresh, ready-to-start `VmSandboxHandle`. The harness
|
||||
* disposes the handle after each scenario.
|
||||
*/
|
||||
export type MakeVmSandbox = (
|
||||
pluginSource: string
|
||||
) => Promise<VmSandboxHandle> | VmSandboxHandle;
|
||||
|
||||
const EVAL_TOK = "ev" + "al";
|
||||
|
||||
/**
|
||||
* Run the conformance suite for any `vm.Context` sandbox host. The
|
||||
* suite encodes the public contract of `@suaveplan/api-sandbox-vm`.
|
||||
* Adapters consume this suite with a single call so that any future
|
||||
* sandbox host (or alternative isolate library) plugs in against the
|
||||
* same contract.
|
||||
*/
|
||||
export function runVmSandboxConformance(
|
||||
label: string,
|
||||
makeSandbox: MakeVmSandbox
|
||||
): void {
|
||||
describe(`runVmSandboxConformance — ${label}`, () => {
|
||||
it("process global is unreachable inside sandbox", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const handle = await makeSandbox(
|
||||
`if (typeof process !== "undefined") { throw new Error("process leaked"); }`
|
||||
);
|
||||
await handle.start();
|
||||
handle.dispose();
|
||||
});
|
||||
|
||||
it("globalThis is frozen", { timeout: 5000 }, async () => {
|
||||
const handle = await makeSandbox(
|
||||
`"use strict"; let threw = false; try { globalThis.foo = 1; } catch { threw = true; } if (!threw) throw new Error("assignment did not throw"); if ("foo" in globalThis) throw new Error("property leaked");`
|
||||
);
|
||||
await handle.start();
|
||||
handle.dispose();
|
||||
});
|
||||
|
||||
it("direct dynamic execution is rejected", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const handle = await makeSandbox(
|
||||
`let caught; try { ${EVAL_TOK}("1+1"); } catch (e) { caught = e; } if (!caught) throw new Error("trap did not fire"); if (!String(caught.code ?? "").startsWith("SAND_VM.EVAL_REJECTED")) throw new Error("wrong code: " + caught.code);`
|
||||
);
|
||||
await handle.start();
|
||||
handle.dispose();
|
||||
});
|
||||
|
||||
it("indirect dynamic execution is rejected", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const handle = await makeSandbox(
|
||||
`let caught; try { (0, ${EVAL_TOK})("1+1"); } catch (e) { caught = e; } if (!caught) throw new Error("indirect trap did not fire"); const code = String(caught.code ?? ""); if (!code.startsWith("SAND_VM.EVAL_REJECTED") && !code.startsWith("SAND_VM.INDIRECT_EVAL_REJECTED")) throw new Error("wrong code: " + caught.code);`
|
||||
);
|
||||
await handle.start();
|
||||
handle.dispose();
|
||||
});
|
||||
|
||||
it("node built-in imports are forbidden", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const handle = await makeSandbox(`import "node:fs";`);
|
||||
let caught: unknown;
|
||||
try {
|
||||
await handle.start();
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
handle.dispose();
|
||||
expect(caught).toBeDefined();
|
||||
expect((caught as { code?: string }).code).toBe(
|
||||
SAND_VM_CODES.MODULE_IMPORT_FORBIDDEN
|
||||
);
|
||||
});
|
||||
|
||||
it("dispose is idempotent", { timeout: 5000 }, async () => {
|
||||
const handle = await makeSandbox("");
|
||||
handle.dispose();
|
||||
handle.dispose();
|
||||
expect(handle.isDisposed()).toBe(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const PKG_ROOT = path.resolve(import.meta.dirname, "..");
|
||||
|
||||
describe("api-sandbox-vm — dependency graph and exports map", () => {
|
||||
it("exports map advertises every feature subpath", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const raw = await readFile(path.join(PKG_ROOT, "package.json"), "utf8");
|
||||
const pkg = JSON.parse(raw) as { exports: Record<string, unknown> };
|
||||
const subpaths = Object.keys(pkg.exports)
|
||||
.filter(
|
||||
(k) =>
|
||||
(k === "." || k.startsWith("./")) && k !== "./package.json"
|
||||
)
|
||||
.sort();
|
||||
expect(subpaths).toEqual(
|
||||
[
|
||||
".",
|
||||
"./sandbox/sandbox",
|
||||
"./globals/frozen-globals/frozen-globals",
|
||||
"./proxies/fs-proxy/fs-proxy",
|
||||
"./proxies/fetch-proxy/fetch-proxy",
|
||||
"./proxies/install-capability-proxies/install-capability-proxies",
|
||||
"./permissions/checker",
|
||||
"./error-codes/error-codes",
|
||||
"./otel/instruments",
|
||||
"./testing",
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("runtime deps include api-types, error, logger, types", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const raw = await readFile(path.join(PKG_ROOT, "package.json"), "utf8");
|
||||
const pkg = JSON.parse(raw) as {
|
||||
dependencies?: Record<string, string>;
|
||||
};
|
||||
const deps = pkg.dependencies ?? {};
|
||||
for (const name of [
|
||||
"@suaveplan/api-types",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/logger",
|
||||
"@suaveplan/types",
|
||||
]) {
|
||||
expect(deps[name]).toMatch(/^workspace:/);
|
||||
}
|
||||
});
|
||||
|
||||
it("dependencies use only workspace and catalog refs", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const raw = await readFile(path.join(PKG_ROOT, "package.json"), "utf8");
|
||||
const pkg = JSON.parse(raw) as {
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
};
|
||||
const all = {
|
||||
...(pkg.dependencies ?? {}),
|
||||
...(pkg.devDependencies ?? {}),
|
||||
};
|
||||
for (const [name, version] of Object.entries(all)) {
|
||||
if (name === "@opentelemetry/sdk-metrics") continue;
|
||||
if (
|
||||
!version.startsWith("workspace:") &&
|
||||
!version.startsWith("catalog:")
|
||||
) {
|
||||
throw new Error(
|
||||
`Dependency ${name} uses bare version '${version}' — must be workspace:/catalog:`
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
12
packages/api-stack/api/api-sandbox-vm/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-sandbox-vm/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "tests/**/*.ts", "dist", "node_modules"]
|
||||
}
|
||||
36
packages/api-stack/api/api-sandbox-vm/vite.config.ts
Normal file
36
packages/api-stack/api/api-sandbox-vm/vite.config.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../../node_modules/.vite/packages/api-sandbox-vm",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
/^@suaveplan\//,
|
||||
/^@opentelemetry\//,
|
||||
/^node:/,
|
||||
"tslib",
|
||||
"zod",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
32
packages/api-stack/api/api-sandbox-vm/vitest.config.ts
Normal file
32
packages/api-stack/api/api-sandbox-vm/vitest.config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
execArgv: ["--experimental-vm-modules", "--no-warnings"],
|
||||
setupFiles: ["@suaveplan/testing/polyfills"],
|
||||
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