@arnilo/prism 0.1.7 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,246 @@
1
+ /**
2
+ * DNS-pinned outbound fetch primitive (0.2.1 task 4).
3
+ *
4
+ * One resolve per request, bounded to 1..32 addresses, family-verified, and
5
+ * every candidate checked against `assertSsrfAllowedUrl` BEFORE the connect —
6
+ * the connect itself uses a node lookup hook that returns exactly the pinned
7
+ * address, so the socket cannot re-resolve (DNS-rebinding defense). Redirects
8
+ * are rejected outright (3xx), and the response stream is byte-bounded.
9
+ *
10
+ * Throws `MediaContentError` (`ssrf_denied` for address violations, `redirect`
11
+ * for 3xx). Error messages are parameterized by `errorPrefix` so each caller
12
+ * (MCP, OIDC, OPA, content) keeps its own taxonomy and message text.
13
+ *
14
+ * NOTE: imports from ./content.js and is imported by it (content's default
15
+ * media fetch routes through here) — a deliberate ESM cycle; both modules only
16
+ * reference the other's exports inside function bodies, never at module scope.
17
+ */
18
+ import { lookup as dnsLookup } from "node:dns/promises";
19
+ import { request as httpRequest } from "node:http";
20
+ import { request as httpsRequest } from "node:https";
21
+ import { isIP } from "node:net";
22
+ import { assertSsrfAllowedUrl, MediaContentError } from "./content.js";
23
+ /** One DNS-pinned, redirect-free, byte-bounded fetch. See module comment. */
24
+ export async function pinnedFetch(url, init, options) {
25
+ const errorPrefix = options?.errorPrefix ?? "Request";
26
+ const hostnameErrorPrefix = options?.hostnameErrorPrefix ?? errorPrefix;
27
+ if (url.username || url.password)
28
+ throw new MediaContentError("ssrf_denied", `${errorPrefix} URL must not embed credentials`);
29
+ if (url.hash)
30
+ throw new MediaContentError("ssrf_denied", `${errorPrefix} URL must not contain a fragment`);
31
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
32
+ throw new MediaContentError("ssrf_denied", `${errorPrefix} URL must use https: (got ${url.protocol})`);
33
+ }
34
+ if (url.protocol === "http:" && !(options?.allowLoopback === true && isLoopbackHostname(url.hostname))) {
35
+ throw new MediaContentError("ssrf_denied", `Plaintext ${errorPrefix} is allowed only for an explicitly enabled loopback endpoint`);
36
+ }
37
+ if (!(options?.allowLoopback === true && isLoopbackHostname(url.hostname))) {
38
+ try {
39
+ assertSsrfAllowedUrl(url.href, options?.ssrf);
40
+ }
41
+ catch (error) {
42
+ if (error instanceof MediaContentError && error.code === "unsupported_url_scheme")
43
+ throw error;
44
+ throw new MediaContentError("ssrf_denied", `${errorPrefix} URL is not public`, { cause: error });
45
+ }
46
+ }
47
+ const signal = init?.signal;
48
+ const address = await resolvePinnedAddress(url, options?.resolver ?? defaultResolver, signal, options?.allowLoopback === true, options?.ssrf);
49
+ const response = await requestPinned(url, address, init, errorPrefix);
50
+ if (response.status >= 300 && response.status < 400) {
51
+ await response.body?.cancel();
52
+ throw new MediaContentError("redirect", `${errorPrefix} redirects are not allowed (status ${response.status})`);
53
+ }
54
+ return options?.maxResponseBytes === undefined ? response : boundResponse(response, options.maxResponseBytes, errorPrefix);
55
+ }
56
+ export async function resolvePinnedAddress(url, resolver, signal, allowLoopback, ssrf, hostnameErrorPrefix = "Request") {
57
+ signal?.throwIfAborted();
58
+ const hostname = normalizeHostname(url.hostname);
59
+ const family = isIP(hostname);
60
+ const addresses = family
61
+ ? [{ address: hostname, family: family }]
62
+ : await raceAbort(resolver(hostname, signal ?? new AbortController().signal), signal);
63
+ if (addresses.length < 1 || addresses.length > 32)
64
+ throw new MediaContentError("fetch_failed", `${hostnameErrorPrefix} hostname returned an invalid address count`);
65
+ for (const candidate of addresses) {
66
+ const normalized = normalizeHostname(candidate.address);
67
+ if (isIP(normalized) !== candidate.family)
68
+ throw new MediaContentError("fetch_failed", `${hostnameErrorPrefix} hostname resolver returned an invalid address`);
69
+ if (allowLoopback && isLoopbackHostname(hostname)) {
70
+ if (!isLoopbackAddress(normalized))
71
+ throw new MediaContentError("ssrf_denied", `${hostnameErrorPrefix} loopback hostname resolved outside loopback`);
72
+ continue;
73
+ }
74
+ const literal = candidate.family === 6 ? `[${normalized}]` : normalized;
75
+ // Fail closed on resolved candidates: an explicit hostname allow-list is honored
76
+ // for the URL itself, but every resolved address is still private-checked.
77
+ const candidatePolicy = ssrf?.allowedHostnames?.length ? { denyPrivateHosts: ssrf.denyPrivateHosts } : ssrf;
78
+ try {
79
+ assertSsrfAllowedUrl(`${url.protocol}//${literal}`, candidatePolicy);
80
+ }
81
+ catch (error) {
82
+ throw new MediaContentError("ssrf_denied", `${hostnameErrorPrefix} hostname resolved to a private or non-public address`, {
83
+ cause: error,
84
+ });
85
+ }
86
+ }
87
+ // ponytail: pin first validated address; add bounded public-address retry only if availability data requires it.
88
+ const selected = addresses[0];
89
+ return { address: normalizeHostname(selected.address), family: selected.family };
90
+ }
91
+ export async function defaultResolver(hostname) {
92
+ return dnsLookup(hostname, { all: true, verbatim: true });
93
+ }
94
+ export async function requestPinned(url, address, init, errorPrefix = "Request") {
95
+ const body = await requestBody(init?.body, errorPrefix);
96
+ const headers = new Headers(init?.headers);
97
+ const method = init?.method ?? "GET";
98
+ const request = url.protocol === "https:" ? httpsRequest : httpRequest;
99
+ return new Promise((resolve, reject) => {
100
+ const nodeRequest = request(url, {
101
+ method,
102
+ headers: Object.fromEntries(headers.entries()),
103
+ signal: init?.signal ?? undefined,
104
+ lookup: ((_hostname, options, callback) => {
105
+ if (options.all)
106
+ callback(null, [{ address: address.address, family: address.family }]);
107
+ else
108
+ callback(null, address.address, address.family);
109
+ }),
110
+ }, (incoming) => {
111
+ const responseHeaders = new Headers();
112
+ for (const [name, value] of Object.entries(incoming.headers)) {
113
+ if (Array.isArray(value))
114
+ for (const item of value)
115
+ responseHeaders.append(name, item);
116
+ else if (value !== undefined)
117
+ responseHeaders.set(name, value);
118
+ }
119
+ const noBody = method === "HEAD" || incoming.statusCode === 204 || incoming.statusCode === 304;
120
+ const iterator = incoming[Symbol.asyncIterator]();
121
+ const stream = noBody
122
+ ? null
123
+ : new ReadableStream({
124
+ async pull(controller) {
125
+ try {
126
+ const next = await iterator.next();
127
+ if (next.done)
128
+ controller.close();
129
+ else
130
+ controller.enqueue(new Uint8Array(next.value));
131
+ }
132
+ catch (error) {
133
+ controller.error(error);
134
+ }
135
+ },
136
+ cancel(reason) {
137
+ incoming.destroy(reason instanceof Error ? reason : undefined);
138
+ },
139
+ });
140
+ resolve(new Response(stream, {
141
+ status: incoming.statusCode ?? 500,
142
+ statusText: incoming.statusMessage,
143
+ headers: responseHeaders,
144
+ }));
145
+ });
146
+ nodeRequest.on("error", reject);
147
+ if (body)
148
+ nodeRequest.end(body);
149
+ else
150
+ nodeRequest.end();
151
+ });
152
+ }
153
+ async function requestBody(body, errorPrefix) {
154
+ if (body === undefined || body === null)
155
+ return undefined;
156
+ if (typeof body === "string")
157
+ return new TextEncoder().encode(body);
158
+ if (body instanceof URLSearchParams)
159
+ return new TextEncoder().encode(body.toString());
160
+ if (body instanceof ArrayBuffer)
161
+ return new Uint8Array(body);
162
+ if (ArrayBuffer.isView(body))
163
+ return new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
164
+ if (body instanceof Blob)
165
+ return new Uint8Array(await body.arrayBuffer());
166
+ throw new MediaContentError("ssrf_denied", `${errorPrefix} request body type is not supported by the pinned transport`);
167
+ }
168
+ export function boundResponse(response, maxBytes, errorPrefix = "Request") {
169
+ const declared = Number(response.headers.get("content-length"));
170
+ if (Number.isFinite(declared) && declared > maxBytes) {
171
+ void response.body?.cancel();
172
+ throw new MediaContentError("ssrf_denied", `${errorPrefix} response exceeds ${maxBytes} bytes`);
173
+ }
174
+ if (!response.body)
175
+ return response;
176
+ const reader = response.body.getReader();
177
+ let bytes = 0;
178
+ const body = new ReadableStream({
179
+ async pull(controller) {
180
+ try {
181
+ const next = await reader.read();
182
+ if (next.done) {
183
+ reader.releaseLock();
184
+ controller.close();
185
+ return;
186
+ }
187
+ bytes += next.value.byteLength;
188
+ if (bytes > maxBytes) {
189
+ await reader.cancel();
190
+ reader.releaseLock();
191
+ controller.error(new MediaContentError("ssrf_denied", `${errorPrefix} response exceeds ${maxBytes} bytes`));
192
+ return;
193
+ }
194
+ controller.enqueue(next.value);
195
+ }
196
+ catch (error) {
197
+ try {
198
+ reader.releaseLock();
199
+ }
200
+ catch {
201
+ /* Already released after EOF/overflow. */
202
+ }
203
+ controller.error(error);
204
+ }
205
+ },
206
+ async cancel(reason) {
207
+ await reader.cancel(reason);
208
+ try {
209
+ reader.releaseLock();
210
+ }
211
+ catch {
212
+ /* Already released after EOF/overflow. */
213
+ }
214
+ },
215
+ });
216
+ return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
217
+ }
218
+ export async function raceAbort(promise, signal) {
219
+ if (!signal)
220
+ return promise;
221
+ signal.throwIfAborted();
222
+ return new Promise((resolve, reject) => {
223
+ const abort = () => reject(signal.reason);
224
+ signal.addEventListener("abort", abort, { once: true });
225
+ promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
226
+ });
227
+ }
228
+ export function normalizeHostname(value) {
229
+ return value
230
+ .toLowerCase()
231
+ .replace(/^\[|\]$/g, "")
232
+ .replace(/\.$/, "");
233
+ }
234
+ export function isLoopbackHostname(value) {
235
+ const hostname = normalizeHostname(value);
236
+ return hostname === "localhost" || hostname.endsWith(".localhost") || isLoopbackAddress(hostname);
237
+ }
238
+ export function isLoopbackAddress(value) {
239
+ const address = normalizeHostname(value);
240
+ if (address === "::1")
241
+ return true;
242
+ if (isIP(address) !== 4)
243
+ return false;
244
+ return Number(address.split(".", 1)[0]) === 127;
245
+ }
246
+ //# sourceMappingURL=pinned-fetch.js.map
@@ -21,7 +21,7 @@ export interface OpenAICompatibleProviderOptions {
21
21
  readonly extraHeaders?: (request: ProviderRequest) => Record<string, string>;
22
22
  /** Final body transform applied last (token limits, compat stripping). Wins over everything. */
23
23
  readonly transformBody?: (body: JsonObject, request: ProviderRequest) => JsonObject;
24
- /** Require `[DONE]` and a `finish_reason` before emitting `done`; truncated streams yield an error. `done` then carries the final usage. */
24
+ /** Require `[DONE]` and a `finish_reason` before emitting `done`; truncated streams yield an error. `done` then carries the final usage. Default `true` (fail-closed); set `false` explicitly to accept streams that end without completion evidence. */
25
25
  readonly strictCompletion?: boolean;
26
26
  /** Emit the final stream usage on the `done` event (without strict completion checks). */
27
27
  readonly doneUsage?: boolean;
@@ -34,7 +34,7 @@ export interface OpenAICompatibleProviderOptions {
34
34
  }
35
35
  export interface OpenAIChatEventsOptions {
36
36
  readonly signal?: AbortSignal;
37
- /** Require `[DONE]` and a `finish_reason`; `done` then carries the final usage. */
37
+ /** Require `[DONE]` and a `finish_reason`; `done` then carries the final usage. Default `true` (fail-closed); set `false` explicitly to accept streams that end without completion evidence. */
38
38
  readonly strictCompletion?: boolean;
39
39
  /** Emit the final stream usage on the `done` event. */
40
40
  readonly doneUsage?: boolean;
@@ -72,7 +72,7 @@ export async function* openAIChatEvents(body, options = {}) {
72
72
  yield providerError(new ProviderTransportError("incomplete_delta", `Incomplete tool call delta at index ${incomplete[0]}`));
73
73
  return;
74
74
  }
75
- if (options.strictCompletion && (!sawDoneMarker || !sawFinishReason)) {
75
+ if ((options.strictCompletion ?? true) && (!sawDoneMarker || !sawFinishReason)) {
76
76
  // Truncated streams must fail loudly — emitting done would mark partial output as succeeded.
77
77
  yield providerError(new Error(`Chat stream ended without completion evidence ` +
78
78
  `([DONE]: ${sawDoneMarker ? "received" : "missing"}, ` +
@@ -82,7 +82,7 @@ export async function* openAIChatEvents(body, options = {}) {
82
82
  for (const call of tools.values()) {
83
83
  yield providerToolCall(toolCallFromArgumentsText(call.id, call.name, call.argumentsText));
84
84
  }
85
- yield providerDone(options.strictCompletion || options.doneUsage ? usage : undefined);
85
+ yield providerDone((options.strictCompletion ?? true) || options.doneUsage ? usage : undefined);
86
86
  }
87
87
  export function createOpenAICompatibleProvider(options) {
88
88
  const providerId = options.id ?? "openai-compatible";
@@ -14,7 +14,7 @@ export interface SseEvent {
14
14
  readonly data: string;
15
15
  readonly comments?: readonly string[];
16
16
  }
17
- export type ProviderTransportErrorCode = "sse_buffer_overflow" | "sse_event_overflow" | "response_body_overflow" | "aborted" | "invalid_json_arguments" | "incomplete_delta";
17
+ export type ProviderTransportErrorCode = "sse_buffer_overflow" | "sse_event_overflow" | "response_body_overflow" | "aborted" | "invalid_json_arguments" | "incomplete_delta" | "response_body_shape";
18
18
  export declare class ProviderTransportError extends Error {
19
19
  readonly code: ProviderTransportErrorCode;
20
20
  readonly limitBytes?: number;
@@ -32,6 +32,15 @@ export interface ReadSseEventsOptions extends BoundedStreamLimits {
32
32
  export interface ReadBoundedResponseTextOptions extends BoundedStreamLimits {
33
33
  readonly secrets?: readonly (string | undefined)[];
34
34
  }
35
+ export interface ReadBoundedResponseJsonOptions extends ReadBoundedResponseTextOptions {
36
+ /** Max JSON nesting depth. Default: 32. */
37
+ readonly maxDepth?: number;
38
+ /** Max object properties / array elements per container. Default: 4_096. */
39
+ readonly maxProperties?: number;
40
+ /** Caller-supplied shape gate; failure throws `response_body_shape`. */
41
+ readonly shape?: (value: unknown) => boolean;
42
+ readonly signal?: AbortSignal;
43
+ }
35
44
  export interface ParseJsonObjectArgumentsOptions {
36
45
  readonly toolName?: string;
37
46
  readonly maxBytes?: number;
@@ -42,6 +51,10 @@ export declare function readSseEvents(body: ReadableStream<Uint8Array>, options?
42
51
  export declare function readSseData(body: ReadableStream<Uint8Array>, options?: ReadSseEventsOptions): AsyncGenerator<string>;
43
52
  /** Read a response body with a hard byte ceiling; redacts optional secrets. */
44
53
  export declare function readBoundedResponseText(response: Response, options?: ReadBoundedResponseTextOptions): Promise<string>;
54
+ /** Read a success body with the same byte ceiling as {@link readBoundedResponseText}, then parse it as JSON
55
+ * with depth/property caps and an optional caller-supplied shape gate. Malformed JSON, over-limit nesting,
56
+ * or shape failure throw `ProviderTransportError` with code `response_body_shape`. */
57
+ export declare function readBoundedResponseJson<T>(response: Response, options?: ReadBoundedResponseJsonOptions): Promise<T>;
45
58
  export type ParseJsonObjectArgumentsResult = {
46
59
  readonly ok: true;
47
60
  readonly value: JsonObject;
@@ -218,6 +218,49 @@ export async function readBoundedResponseText(response, options) {
218
218
  }
219
219
  }
220
220
  }
221
+ function assertJsonBounds(value, maxDepth, maxProperties, depth = 0) {
222
+ if (depth > maxDepth) {
223
+ throw new ProviderTransportError("response_body_shape", `JSON nesting exceeded ${maxDepth} levels`);
224
+ }
225
+ if (value === null || typeof value !== "object")
226
+ return;
227
+ if (Array.isArray(value)) {
228
+ if (value.length > maxProperties) {
229
+ throw new ProviderTransportError("response_body_shape", `JSON array exceeded ${maxProperties} elements`);
230
+ }
231
+ for (const entry of value)
232
+ assertJsonBounds(entry, maxDepth, maxProperties, depth + 1);
233
+ return;
234
+ }
235
+ const keys = Object.keys(value);
236
+ if (keys.length > maxProperties) {
237
+ throw new ProviderTransportError("response_body_shape", `JSON object exceeded ${maxProperties} properties`);
238
+ }
239
+ for (const key of keys)
240
+ assertJsonBounds(value[key], maxDepth, maxProperties, depth + 1);
241
+ }
242
+ /** Read a success body with the same byte ceiling as {@link readBoundedResponseText}, then parse it as JSON
243
+ * with depth/property caps and an optional caller-supplied shape gate. Malformed JSON, over-limit nesting,
244
+ * or shape failure throw `ProviderTransportError` with code `response_body_shape`. */
245
+ export async function readBoundedResponseJson(response, options) {
246
+ throwIfAborted(options?.signal);
247
+ const text = await readBoundedResponseText(response, options);
248
+ throwIfAborted(options?.signal);
249
+ const maxDepth = options?.maxDepth ?? 32;
250
+ const maxProperties = options?.maxProperties ?? 4_096;
251
+ let parsed;
252
+ try {
253
+ parsed = JSON.parse(text);
254
+ }
255
+ catch {
256
+ throw new ProviderTransportError("response_body_shape", "Response body is not valid JSON");
257
+ }
258
+ assertJsonBounds(parsed, maxDepth, maxProperties);
259
+ if (options?.shape && !options.shape(parsed)) {
260
+ throw new ProviderTransportError("response_body_shape", "Response body does not match the expected shape");
261
+ }
262
+ return parsed;
263
+ }
221
264
  /** Parse streamed tool arguments without throwing; prefer this for recoverable tool-call recovery. */
222
265
  export function tryParseJsonObjectArguments(text, options) {
223
266
  const maxBytes = options?.maxBytes ?? DEFAULT_MAX_ARGUMENT_BYTES;
@@ -187,6 +187,8 @@ Set `runState` with a host-owned `CheckpointStore`, stable `definitionRevision`,
187
187
 
188
188
  `*_for_run` outcomes append a `StickyDecision` to the durable run state: later calls in the same run matching the scope exactly (all recorded fields) proceed or are blocked without a new suspension, policy still enforced at dispatch. Sticky decisions expire when the run reaches any terminal status. Caps: 32 pending decisions per run (hard 128), 64 sticky decisions (hard 256), 2 KB decision reasons, 16 KB elicitation payloads.
189
189
 
190
+ **Runtime input validation (0.2.0, plan 020 Task 2).** Every public resume entrypoint (`resumeAgentRun`, `resumeAgentRunStream`, `AgentRunLifecycle.resume()`/`resumeStream()`) validates the complete resume input in core before any checkpoint read/write, agent resolution, subscription, or tool execution: a non-null object, positive safe-integer `expectedVersion`, exactly one of `decision`/`decisions`, legacy `decision` exactly `approve`/`deny`, and a non-empty batch ≤ 128 entries whose entries are objects with a bounded non-empty `approvalId`, a whitelisted outcome, an optional string `reason` within the 2 KB limit, and JSON-object `modifiedArguments`/`elicitation` within the 16 KB limit. Unknown legacy decisions (e.g. `"sideways"`) and malformed untyped batches fail closed with `AgentDecisionError` (`ERR_PRISM_DECISION_INVALID`/`..._LIMIT`/`..._DUPLICATE`) under a **no-side-effect guarantee**: zero checkpoint writes/CAS changes, zero tool calls, zero resumed events. This holds for plain-JavaScript and `as any` callers; the server's transport parser is defense in depth, not the security boundary. State-dependent checks (foreign/stale approval ids, scope, schema, policy) still run in the atomic batch resolver.
191
+
190
192
  ```ts
191
193
  const result = await session.run("Publish draft", {
192
194
  runState: { checkpoints, definitionRevision: "2026-07-20.1", interruptBeforeTool: true },
@@ -13,7 +13,7 @@
13
13
  | `createSandboxCodingTools` / `createSandboxReadOnlyTools` | Thin wrappers that return `tools` only (compat); still require `workspaceMode`. |
14
14
  | `createSandboxFilesystemOperations` / `createSandboxRepositoryOperations` | Optional execFile-backed FS/list/search backends for a disposable sandbox tree. |
15
15
  | `createDockerSandbox(options)` | Creates one disposable non-root Docker container with read-only root/source, bounded tmpfs workspace, typed `execFile`, import/export, and stop/kill/cleanup. |
16
- | `createNativeSandbox(options)` | Linux-only network-free backend: every command runs in a fresh network namespace (`unshare`), POSIX `ulimit` hard caps, cwd-in-root containment; fails closed at creation on platforms/privileges that cannot deny egress. Docker remains the stronger, documented reference backend. |
16
+ | `createNativeSandbox(options)` | Linux-only network-free backend: every command runs in a fresh network namespace (`unshare`), POSIX `ulimit` hard caps, cwd-in-root containment; fails closed at creation on platforms/privileges that cannot deny egress. Reports truthful capability metadata (`networkIsolated`/`egressRestricted` true, `filesystemIsolated`/`processIsolated`/`privilegeIsolated` false). Docker remains the stronger, documented reference backend. |
17
17
  | `SandboxProcessHandle` | Optional long-running process handle (`write`/`signal`/`kill`/`release`/`wait`) returned by `DisposableSandbox.startProcess?`. |
18
18
  | `createEgressPolicy(options)` | Deny-all allow-list policy: exact host/port/protocol rules plus frozen `npm-registry` / `github` presets; SHA-256 fingerprint. |
19
19
  | `createAllowListEgressProxy(options)` | HTTP forward proxy + CONNECT tunnel enforcing the policy: pinned DNS (rebinding defense), private/metadata IP denial, redirect re-validation + hop cap, byte/time caps, per-decision audit, attestation for sandbox composition. |
@@ -34,7 +34,7 @@ Use this package when coding tools need path scoping, human approval, command ru
34
34
 
35
35
  Use `createDockerSandbox()` when the host wants a production-reference containment boundary. Prism does **not** claim OS-level isolation unless the host constructs this adapter (or supplies an equivalent custom `DisposableSandbox`). Default policy denies shell/write/edit/delete/move without an `approve` callback and rejects paths outside configured roots. Coding shell definitions are marked `exclusive: true`, matching the approval policy's shell decision, so a single-shot turn containing shell work runs sequentially even when `toolConcurrency > 1`. Non-shell turns retain configured parallelism.
36
36
 
37
- Use `createNativeSandbox()` when the host has no container runtime and needs network-free containment (0.1.6, plan 018 closeout `native-sandbox`). Linux only; creation fails closed with a documented error on other platforms or when the OS cannot create a network namespace (no root/CAP_SYS_ADMIN and no unprivileged user namespaces). Every command runs in a fresh netns — **loopback is down**, so even localhost connections fail; hosts that need loopback keep the Docker backend. Containment is egress denial + `ulimit` hard caps (address space from `memoryBytes`, CPU-time wall backstop, fd count from `maxFds`) + cwd-inside-root (`assertPathInsideRoots`, symlink-aware). The native backend does **not** isolate the filesystem: commands run as the invoking OS user with full host-tree access, so pair it with `createSandboxCodingComposition`/`createSandboxFilesystemOperations` (per-op `assertSandboxPath`) and the approval policy, exactly as with any custom `DisposableSandbox`. Host env is never inherited; `env` is an exact allow-list (PATH only by default). No `startProcess` (ProcessSessions fails closed with `ERR_PRISM_PROCESS_UNSUPPORTED`), no CPU-rate/pids/fs-size caps (cgroup-only). Secrets passed as `secrets` are redacted from surfaced errors. See `docs/_evidence/phase18-primitive-review.md` for the full threat model.
37
+ Use `createNativeSandbox()` when the host has no container runtime and needs network-free containment (0.1.6, plan 018 closeout `native-sandbox`). Linux only; creation fails closed with a documented error on other platforms or when the OS cannot create a network namespace (no root/CAP_SYS_ADMIN and no unprivileged user namespaces). Every command runs in a fresh netns — **loopback is down**, so even localhost connections fail; hosts that need loopback keep the Docker backend. Containment is egress denial + `ulimit` hard caps (address space from `memoryBytes`, CPU-time wall backstop, fd count from `maxFds`) + cwd-inside-root (`assertPathInsideRoots`, symlink-aware). The native backend does **not** isolate the filesystem: commands run as the invoking OS user with full host-tree access, so pair it with `createSandboxCodingComposition`/`createSandboxFilesystemOperations` (per-op `assertSandboxPath`) and the approval policy, exactly as with any custom `DisposableSandbox`. Its `capabilities` report `networkIsolated: true` and `egressRestricted: true` but `filesystemIsolated`/`processIsolated`/`privilegeIsolated: false` — the native backend is never a containment boundary for untrusted code (see [Sandbox capabilities](#sandbox-capabilities-020-plan-020-task-4)). Host env is never inherited; `env` is an exact allow-list (PATH only by default). No `startProcess` (ProcessSessions fails closed with `ERR_PRISM_PROCESS_UNSUPPORTED`), no CPU-rate/pids/fs-size caps (cgroup-only). Secrets passed as `secrets` are redacted from surfaced errors. See `docs/_evidence/phase18-primitive-review.md` for the full threat model.
38
38
 
39
39
  Use `createEgressPolicy()` + `createAllowListEgressProxy()` when a coding agent needs outbound network access under an explicit allow list: package installs, forge API calls, or source fetches — never unrestricted egress. The proxy is inert until `start()`; nothing binds or resolves on import or construction.
40
40
 
@@ -104,7 +104,27 @@ const sandbox = await createDockerSandbox({ docker, image, sourceRoot, user, net
104
104
 
105
105
  `createCodingApprovalPolicy()` returns an `ExecutionPolicy`. Allowed checks return `ExecutionDecision { allowed: true }`; denied checks include a stable reason; shell decisions set `exclusive: true`. Sandbox adapters return coding-agent-compatible `BashOperations`, receive `onData(Buffer)` for ordered stdout/stderr forwarding through the shell tool's existing bounded accumulator, and never grant policy approval themselves.
106
106
 
107
- `createSandboxCodingComposition()` returns `{ tools, composition }` where `SandboxCodingComposition` carries `workspaceMode`, `containmentClaim`, `mixedWiringAllowed`, `warnings`, `workspaceRoot`, and optional `treeIdentity` (from `importIdentity` / `lastExportIdentity`). `containmentClaim` is `true` only for sandbox mode with tree backends bound and mixed wiring denied. Host mode and escape-hatch mixed wiring always set `containmentClaim: false` — never treat host mode as contained execution.
107
+ `createSandboxCodingComposition()` returns `{ tools, composition }` where `SandboxCodingComposition` carries `workspaceMode`, `capabilities`, `containmentClaim` (deprecated), `mixedWiringAllowed`, `warnings`, `workspaceRoot`, and optional `treeIdentity` (from `importIdentity` / `lastExportIdentity`). `capabilities` is a complete, frozen `SandboxCapabilities` object: `workspaceCoherent` derives from actual shell/filesystem/repository wiring; the isolation fields derive only from validated adapter capability metadata, never from `execFile`/`close` duck typing or custom operations being present. Host mode and escape-hatch mixed wiring always report no isolation capability — never treat host mode as contained execution.
108
+
109
+ ### Sandbox capabilities (0.2.0, plan 020 Task 4)
110
+
111
+ Every sandbox backend and every composition reports a frozen, complete capability object:
112
+
113
+ | Capability | Meaning | Docker | Native | Custom / omitted |
114
+ | --- | --- | --- | --- | --- |
115
+ | `workspaceCoherent` | Shell, filesystem, and repository tools observe one workspace tree. | true | true | true only when tree backends are bound and mixed wiring denied |
116
+ | `filesystemIsolated` | Sandbox processes cannot touch the host filesystem. | true | **false** (full host-tree access by design) | false unless host attests |
117
+ | `networkIsolated` | No reachable network. | true only for `network: { mode: "none" }` | true (fresh netns per command, egress denied, loopback down) | false unless host attests |
118
+ | `processIsolated` | Sandbox processes run in a separate process namespace. | true | **false** | false unless host attests |
119
+ | `privilegeIsolated` | Sandbox processes cannot obtain host privileges. | **false** (root-in-container without user namespaces is not reliable) | **false** | false unless host attests |
120
+ | `egressRestricted` | Any egress is forced through a controlled proxy/firewall. | true for mode `none`, or a custom network carrying a validated `EgressAttestation` | true (no egress at all) | false unless host attests |
121
+
122
+ Rules:
123
+
124
+ - **Omission is false.** A `SandboxAdapter` without `capabilities` metadata — or with malformed metadata (non-object, missing fields, non-boolean values, unknown keys) — resolves every isolation field false. `resolveSandboxCapabilities()` validates, copies, and freezes explicit metadata; a backend can never gain a capability by omission, interface shape, or mixed wiring.
125
+ - **Explicit metadata is host attestation.** Prism validates shape and freezes the object; the host is responsible for the underlying controls being real.
126
+ - **`containmentClaim` is deprecated (0.2.0).** Retained for 0.1.7 compatibility as the conservative projection `workspaceCoherent && filesystemIsolated && networkIsolated && processIsolated` (privilege isolation excluded). It can only be `true` when every required capability is true — never authorize a security-sensitive action from this boolean alone; use `composition.capabilities`.
127
+ - **Capability construction is O(1)** — one small frozen object per sandbox/composition; no command, filesystem, Docker, DNS, or network operation.
108
128
 
109
129
  `createDockerSandbox()` returns a `DisposableSandbox`: typed `execFile(file, args)`, shell-compatible `exec`, `status`, cooperative `stop`, forced `kill`, and idempotent `close`. Import may surface `importIdentity`; successful export updates `lastExportIdentity`. `close({ export })` can stream a bounded workspace tar plus SHA-256/entry/byte metadata through a host callback; checkpoints should retain only host artifact references/hashes, never whole workspaces. Optional `startProcess?(SandboxExecFileRequest)` returns a `SandboxProcessHandle` for long-running work consumed by coding-agent `createProcessSessions({ sandbox })`; absence means one-shot-only — ProcessSessions fails closed with `ERR_PRISM_PROCESS_UNSUPPORTED` (no native fallback). The Docker reference adapter does not implement `startProcess` yet; capability is detected, never assumed. See [Process sessions](process-sessions.md).
110
130
 
@@ -151,7 +171,8 @@ const { tools, composition } = createSandboxCodingComposition("/srv/jobs/task-1/
151
171
  executionPolicy: policy,
152
172
  repository: { exclude: [".git", "node_modules", "dist"] },
153
173
  });
154
- // composition.containmentClaim === true when backends are bound
174
+ // composition.capabilities.filesystemIsolated === true for the Docker adapter with network: none
175
+ // composition.containmentClaim is deprecated compatibility metadata only
155
176
 
156
177
  // Same-tree Git/check (opt-in; not folded into coding tools):
157
178
  const gitTools = createGitTools(composition.workspaceRoot, {
@@ -161,7 +182,7 @@ const gitTools = createGitTools(composition.workspaceRoot, {
161
182
 
162
183
  // Host mode (explicit non-contained): omit sandbox; never claim containment.
163
184
  const host = createSandboxCodingComposition(hostCwd, { workspaceMode: "host", executionPolicy: policy });
164
- // host.composition.containmentClaim === false
185
+ // host.composition.capabilities: workspaceCoherent only; every isolation field false; containmentClaim false
165
186
 
166
187
  await sandbox.execFile({ file: "npm", args: ["test"], cwd: "/workspace" });
167
188
  await sandbox.close({
@@ -173,7 +194,7 @@ await sandbox.close({
173
194
 
174
195
  Policies are ordinary host values: attach one globally through `createCodingTools()`/`createReadOnlyTools()`/`createSandboxCodingComposition()` or per tool. A per-tool policy overrides the shared policy. `SandboxAdapter` / `DisposableSandbox` are replaceable and host-owned; approval policy and sandboxing are separate layers. Custom remote sandboxes can implement `DisposableSandbox` without using Docker.
175
196
 
176
- `createSandboxCodingComposition()` requires `workspaceMode`. Sandbox mode auto-wires FS/list/search through `DisposableSandbox.execFile` (or host-supplied custom operations) so mutations stay on the disposable tree until export. Host mode runs every coding tool against the host cwd and never sets `containmentClaim`. Sandbox shell + host FS throws unless `allowMixedWorkspaceWiring: true` (warnings + `containmentClaim: false`). Opt-in structured Git tools (`createGitTools(composition.workspaceRoot, { execFile: sandbox.execFile, commitIdentity })`) share the same tree/cwd; Prism still never pushes or opens PRs. Optional `@arnilo/prism-browser` can share the same disposable boundary: use `assertBrowserSandboxNetwork()` before browse-ready custom networks, and `createSharedSandboxBrowserOptions({ workspaceRoot, downloadsRoot, containedProxyAttestation })` so uploads/downloads align with `/workspace` and `/downloads`. Close the browser context before disposing the sandbox.
197
+ `createSandboxCodingComposition()` requires `workspaceMode`. Sandbox mode auto-wires FS/list/search through `DisposableSandbox.execFile` (or host-supplied custom operations) so mutations stay on the disposable tree until export. Host mode runs every coding tool against the host cwd and reports no isolation capability (`containmentClaim` deprecated false). Sandbox shell + host FS throws unless `allowMixedWorkspaceWiring: true` (warnings + all capabilities false). Opt-in structured Git tools (`createGitTools(composition.workspaceRoot, { execFile: sandbox.execFile, commitIdentity })`) share the same tree/cwd; Prism still never pushes or opens PRs. Optional `@arnilo/prism-browser` can share the same disposable boundary: use `assertBrowserSandboxNetwork()` before browse-ready custom networks, and `createSharedSandboxBrowserOptions({ workspaceRoot, downloadsRoot, containedProxyAttestation })` so uploads/downloads align with `/workspace` and `/downloads`. Close the browser context before disposing the sandbox.
177
198
 
178
199
  The Docker reference adapter starts by recorded container ID/label, uses argument arrays only, mounts source read-only, populates a size-bounded tmpfs `/workspace`, drops all capabilities, enables `no-new-privileges`, runs with `--init`, and never exposes the Docker socket, privileged mode, or host PID/IPC namespaces. Image pull/build/update stays outside Prism. Protected real-Docker checks are opt-in via `PRISM_TEST_DOCKER_SANDBOX=1` with host-supplied `PRISM_TEST_DOCKER_BIN` and digest-pinned `PRISM_TEST_DOCKER_IMAGE`.
179
200
 
@@ -248,6 +248,7 @@ const providers = createOpenAIProviderPackage({ apiKey });
248
248
  - Never log passphrases, derived keys, or decrypted credential payloads.
249
249
  - Optional host KMS: `encryptWithHostKms` / `decryptWithHostKms` wrap a random AES-256-GCM DEK via host `HostKms.wrapKey`/`unwrapKey` (timeout ≤ 60 s). Envelope sizes reuse vault/file caps. Keys are never logged. `createMemoryHostKms` is for tests only.
250
250
  - Storage is not OAuth eligibility. A durable store may persist credentials for a provider only after the host selects a provider-authorized flow; it must not be used to piggyback on a vendor CLI or consumer subscription.
251
+ - OIDC JWKS fetches (0.2.1) are DNS-pinned through the core `pinnedFetch` primitive: one resolve per request, every resolved address SSRF-checked before the connect (rebinding defense), redirects rejected outright, and the JWKS body read under a hard byte ceiling; oversized documents fail closed as a parse error, never as a rotatable transport failure.
251
252
  - Live keychain tests are opt-in (`PRISM_TEST_KEYCHAIN=1`); default `npm test` stays offline.
252
253
 
253
254
  ## MCP authentication boundary
@@ -124,6 +124,7 @@ A future provider-local OAuth adapter needs published permission for third-party
124
124
  - `resolveCredentialValue()` and `createExplicitCredentialResolver()` do not cache values. Add host-side caching only if a real credential source needs it.
125
125
  - `refreshOAuthCredential()` only calls the supplied OAuth provider and optional store; it has no built-in persistence or retry loop.
126
126
  - OpenAI Codex device-code OAuth polls inside `createOpenAICodexOAuthProvider().login()` with bounded delays and abort support via `OAuthLoginCallbacks.signal`. Token-endpoint failures redact authorization codes, PKCE verifiers, device/user codes, and access/refresh tokens when those values are known.
127
+ - The shared bounded device/token flow lives in core `pollDeviceCodeToken` (0.2.1) and is used by the OpenAI Codex provider and the credentials-node OAuth 2.0 provider (Microsoft 365 / Google Workspace). It owns the RFC 8628 device-code request and poll loop (`authorization_pending` continue, `slow_down` +5s backoff, expiry deadline, abort), reads every response body under the shared byte ceiling, parses success bodies with a fail-closed shape gate (an `access_token` string is required), and redacts device/user codes, authorization codes, PKCE verifiers, and tokens from every thrown error. Adapter-specific fields (message prefix, extra token params, account binding) are plain options, never subclasses.
127
128
 
128
129
  ## Related APIs
129
130
 
@@ -147,7 +147,7 @@ Wire those values where they matter: provider adapters receive the resolved cred
147
147
  - AG-UI MCP Apps requires negotiated `mcpApps`, exact proxy origin/auth, owned-run context, approval, one bridge, separate-origin sandbox (`allow-scripts allow-same-origin`), and no-wider CSP. Never execute HTML in host origin or retry a UI mutation; Task 4 adds recovery.
148
148
  - AG-UI A2A requires exact-origin verified client, host-owned task selection/correlation, explicit data/tool/A2UI projection, and reauthorized follow/cancel.
149
149
  - `@arnilo/prism-server` exposes no agent/workflow by default and requires `authorize()` for every matched operation. Derive complete tenant/account/user ownership from validated host identity, never request JSON. Workflow active identity and cancellation compare exact ownership; a tenant-only scope intentionally cannot cancel a checkpoint/run carrying account or user identity. The artifact review service (`createArtifactService`) requires authenticated identity + thread ownership on every attach/revise/compare/approve/reject/download, resolves concurrent reviewers via checkpoint CAS (no lost approvals), rejects local filesystem paths in `uri`/citations, redacts records before persist and on response, and serves downloads only through signed expiring links that are reauthorized against the token's ownership per request. When a blob store is wired (`bodies: ArtifactBodyStore`, 0.0.28), delivery links additionally resolve through `bodies.presign`; the reference `createS3ArtifactBodyStore` verifies ownership on every operation, verifies size/SHA-256/MIME on put and get (fail closed), refuses delete under legal hold (host `isHeld` callback), keeps credentials host-resolved, and never discloses bucket/path/key in errors, telemetry, or artifact records. Pass the current explicitly revised workflow definition so recursive hash mismatch fails before abort or durable mutation. Configure exact host/origin allow-lists where needed, wire redaction before execution, retain tool/workflow policy checks, and adapt the Web handler behind host TLS/rate limits. Disconnect abort is default; persistent reconnect/status belongs to durable workflow checkpoints, not an invented in-memory agent result cache.
150
- - Coding tools from `@arnilo/prism-coding-agent` accept an optional `ExecutionPolicy` checked inside each tool before side effects; shared policy propagation includes `createReadOnlyTools()`. They enforce finite text-scan/image/edit/write/shell limits, repository list/search depth/entry/match/scan/time caps, structured Git path/ref/message/output/patch/worktree caps, named-check concurrency/output caps, a 600-second default shell wall time, and a 64 MiB default total-output ceiling. Opt-in `createGitTools()` uses argument arrays with hooks/credential prompts/external diff disabled, requires host `commitIdentity` for commits, and never pushes or opens PRs. Successful truncated shell output leaves a host-owned exclusive `0600` temp file; delete `metadata.fullOutputPath` after use. Error/abort/timeout/overflow removes unpublished spills. Custom read/edit/shell/repository backends must honor supplied caps/signals. Use `@arnilo/prism-coding-security` for path roots, command rules, identity-scoped approval caching, required `workspaceMode` on `createSandboxCodingComposition()` / `createSandboxCodingTools()`, and the optional `createDockerSandbox()` reference adapter. **Host mode is never contained execution** (`containmentClaim: false`). Sandbox mode claims containment only when FS backends target the disposable tree; mixed wiring requires `allowMixedWorkspaceWiring` and still does not claim containment. Limits alone are not containment: construct the Docker adapter (absolute CLI, digest-pinned image, network none by default) or an equivalent host sandbox before treating coding execution as production-safe. Docker daemon/image trust, egress firewall/proxy, and artifact retention remain host-owned.
150
+ - Coding tools from `@arnilo/prism-coding-agent` accept an optional `ExecutionPolicy` checked inside each tool before side effects; shared policy propagation includes `createReadOnlyTools()`. They enforce finite text-scan/image/edit/write/shell limits, repository list/search depth/entry/match/scan/time caps, structured Git path/ref/message/output/patch/worktree caps, named-check concurrency/output caps, a 600-second default shell wall time, and a 64 MiB default total-output ceiling. Opt-in `createGitTools()` uses argument arrays with hooks/credential prompts/external diff disabled, requires host `commitIdentity` for commits, and never pushes or opens PRs. Successful truncated shell output leaves a host-owned exclusive `0600` temp file; delete `metadata.fullOutputPath` after use. Error/abort/timeout/overflow removes unpublished spills. Custom read/edit/shell/repository backends must honor supplied caps/signals. Use `@arnilo/prism-coding-security` for path roots, command rules, identity-scoped approval caching, required `workspaceMode` on `createSandboxCodingComposition()` / `createSandboxCodingTools()`, and the optional `createDockerSandbox()` reference adapter. **Host mode is never contained execution** (every isolation capability false). Sandbox mode reports isolation only from validated adapter capability metadata: `composition.capabilities` carries the frozen `SandboxCapabilities` object (`workspaceCoherent`, `filesystemIsolated`, `networkIsolated`, `processIsolated`, `privilegeIsolated`, `egressRestricted`); the deprecated `containmentClaim` is a conservative projection and must never be used alone. Authorize security-sensitive actions from the individual capabilities the policy actually needs — e.g. require `filesystemIsolated` before hosting untrusted coding tasks, and `egressRestricted` before any network-capable run. Mixed wiring requires `allowMixedWorkspaceWiring` and still reports no isolation. Limits alone are not containment: construct the Docker adapter (absolute CLI, digest-pinned image, network none by default) or an equivalent host sandbox before treating coding execution as production-safe. Docker daemon/image trust, egress firewall/proxy, and artifact retention remain host-owned.
151
151
  - Allow-list egress (0.0.26, `@arnilo/prism-coding-security`): `createEgressPolicy()` is deny-all with exact host/port/protocol rules and frozen `npm-registry`/`github` presets; `createAllowListEgressProxy()` is an HTTP forward proxy + CONNECT tunnel that pins DNS answers and verifies the connected address (rebinding defense), denies private/link-local/metadata ranges unless a rule opts in, re-validates every redirect hop against policy, and cuts oversized/slow transfers at frozen byte/time caps. TLS passes through without interception. Every allow/deny writes an audit record with no secrets. The proxy is inert until `start()`; `reloadPolicy()` is the only rule change path. `composeEgressSandboxNetwork(proxy.attestation(), name)` records validated attestation as `prism.egress.*` container labels — evidence, not enforcement: the host must restrict the Docker network so the proxy is the only reachable path, and `denyDirectEgress: true` is a claim the host makes true by topology. The proxy is not a firewall and cannot stop a container whose network reaches the internet directly.
152
152
  - Optional `@arnilo/prism-browser` requires a host-supplied Playwright Browser (`playwright-core@1.61.0` peer). Import is inert. One non-persistent context belongs to one run; actions serialize; refs are snapshot-scoped; CSS/evaluate/CDP/persistent profiles are denied. Context routing + `serviceWorkers: "block"` deny file/data/blob/devtools/private/loopback by default and require contained-proxy attestation for external egress (Playwright routing is defense in depth, not DNS containment). Uploads are realpath-rooted; downloads quarantine with hash/MIME until host `approveRelease`; screenshots return bounded `ImageContent`. Observation vs mutation/high-impact actions map to `ExecutionPolicy`. Treat snapshot/page text as untrusted external content. Close contexts with `browser_close` or `manager.closeRun(runId)` on abort/terminal. Browser control endpoint, binary/image pin, and real egress firewall/proxy remain host-owned. Shared sandbox: `createSharedSandboxBrowserOptions()` + `assertBrowserSandboxNetwork()`.
153
153
  - Browser verified-state checkpoints (0.0.14, `createBrowserCheckpointLedger()`) store URL + domain-state hash + host data refs only — never serialized browser internals (cookies/storage/contexts). After any resume/interruption the ledger fails closed (`assertVerifiedBeforeSideEffect`) until the host reloads + verifies, so side effects never replay on stale state.