@arnilo/prism 0.2.0 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.1] - 2026-08-13
4
+
5
+ ### Changed
6
+ - **Release 0.2.1 (plan 021)** is the provider-completion and outbound-trust-boundaries cut on the 0.2.x review-remediation line. API surface **additive-only** vs 0.2.0 (plain reviewed compat gate at 0.2.1: deltas are the version literal plus `@arnilo/prism-mcp` transport helpers re-exported from the lifted core primitives — same names/signatures, no removal; no `--allow-break`), five documented security-motivated behavior tightenings in `docs/migration.md` `0.2.0 → 0.2.1`: (1) **strict stream completion is the shared default** (`strict-completion`, core `createOpenAICompatibleProvider`) — `strictCompletion` defaults to `true` (explicit `false` stays the documented opt-out): a stream ending without `[DONE]` plus a choice-level `finish_reason` now fails with `ProviderTransportError` `incomplete_delta` instead of a successful `providerDone`, and done events carry usage only with completion evidence; applies to Azure, Bedrock, Vertex, OpenRouter, ZAI, NeuralWatt (Alibaba/Kimi/Ollama/OpenCode-go had already opted in). (2) **bounded success bodies** (`bounded-bodies`, core `@arnilo/prism/providers/transport`) — additive `readBoundedResponseJson` (65,536-byte UTF-8 ceiling, max JSON depth 32, max properties 4096, caller shape gate, abort, redacted errors, new `ProviderTransportError` code `response_body_shape`) replaces unbounded `response.json()` at all ten model-discovery sites plus NeuralWatt quota, Alibaba embeddings, OpenAI uploads, and both OAuth success paths. (3) **DNS-pinned OIDC/OPA/content fetch, redirects rejected** (`dns-pinning`, core `src/pinned-fetch.ts` + `@arnilo/prism-credentials-node`, `@arnilo/prism-policy`, `@arnilo/prism-mcp`) — `pinnedFetch`/`resolvePinnedAddress`/`requestPinned` lift the MCP pinning algorithm (one resolve, 1–32 bound, family check, loopback confinement, per-candidate `assertSsrfAllowedUrl`, pinned-lookup socket) into core; default JWKS, OPA decision, and content/media fetches route through it and **reject 3xx redirects outright** (`MediaContentError` code `redirect`); private/metadata/loopback answers fail closed `ssrf_denied`; MCP re-exports the lifted helpers with byte-identical behavior; egress `dns-pin.ts` deliberately NOT converged (0.3.x candidate). (4) **shared bounded OAuth device/token polling** (`oauth-consolidation`, core `src/oauth-device-code.ts`) — `pollDeviceCodeToken` serves both `@arnilo/prism-provider-openai` and `@arnilo/prism-credentials-node` device flows (RFC 8628 poll, `slow_down` +5 s, expiry deadline, cancellation, bounded reads, fail-closed token shape, `[REDACTED]` redaction); adapter fields stay plain options; behavior equivalent. (5) **edge fixes** (`edge-fixes`) — Azure/Vertex resolve rotating credentials **once per request** (inner provider re-created with the resolved token; never consumed twice); Bedrock SigV4 merges duplicate-case headers last-wins and sorts query params by encoded key then value (malformed duplicate-case signatures eliminated, single-case byte-identical); OpenAI upload cleanup retains file ids until their `DELETE` succeeds (no remote-file leak on failed cleanup); cache-telemetry `__overflow__` never carries cost (requests + token totals only). New regression surface: `scripts/phase21-security.test.mjs` (10 conformance tests over built public entrypoints covering all five items, wired into `security:threat-suites`) plus a packed plain-JS `security21.mjs` consumer in install-smoke; phase-21 freeze manifest `scripts/phase21-freeze-manifest.json` machine-checks each task's diff (preserved surface: egress dns-pin primitives, OAuth connector consumers, strictCompletion opt-in adapters, native streaming adapters). Release graph stays **50** publishable manifests (root + 49 workspace packages) at exact **0.2.1**; zero new runtime dependency names (core remains dependency-free). Store compatibility with 0.2.0: **compatible, no migration** (no persisted-shape change). Exit gate green (core + script gates incl. phase21-freeze done-phase, `sdk:ready`, audit 0 moderate, pack dry-run 50/50 twice byte-identical, plain reviewed compat gate at 0.2.1, live OIDC JWKS protected evidence + live OPA fail-closed evidence, evidence in `scripts/phase21-baseline.json`). **Publication remains the operator handoff** (`docs/release-and-install.md` `0.2.1 publish handoff` — signed `v0.2.1` tag + npm OIDC).
7
+
3
8
  ## [0.2.0] - 2026-08-13
4
9
 
5
10
  ### Changed
@@ -72,7 +72,10 @@ export function createCacheTelemetry(options = {}) {
72
72
  cacheReadTokens: sample.cacheReadTokens,
73
73
  inputTokens: sample.inputTokens,
74
74
  });
75
- if (model?.cost) {
75
+ // The __overflow__ bucket aggregates mixed provider/model tokens, so it
76
+ // never carries cost: one model's cost metadata must not be applied to
77
+ // other models' tokens. It reports requests and token totals only.
78
+ if (model?.cost && sample !== overflowSample) {
76
79
  // cacheSavings depends only on read tokens + cost metadata, so the
77
80
  // aggregate equals the sum of per-call savings; feed it the totals
78
81
  // to reuse the exact cache-helpers math rather than reimplementing it.
package/dist/content.d.ts CHANGED
@@ -104,8 +104,8 @@ export declare class UnsupportedModalityError extends Error {
104
104
  constructor(modality: ModelInputCapability, model: ModelConfig);
105
105
  }
106
106
  export declare class MediaContentError extends Error {
107
- readonly code: "ambiguous_source" | "missing_source" | "item_too_large" | "request_too_large" | "too_many_items" | "audio_too_long" | "invalid_base64" | "ssrf_denied" | "fetch_failed" | "fetch_timeout" | "resource_required" | "mime_mismatch" | "unsupported_url_scheme";
108
- constructor(code: MediaContentError["code"], message: string);
107
+ readonly code: "ambiguous_source" | "missing_source" | "item_too_large" | "request_too_large" | "too_many_items" | "audio_too_long" | "invalid_base64" | "ssrf_denied" | "redirect" | "fetch_failed" | "fetch_timeout" | "resource_required" | "mime_mismatch" | "unsupported_url_scheme";
108
+ constructor(code: MediaContentError["code"], message: string, options?: ErrorOptions);
109
109
  }
110
110
  export declare function contentBlockInputModality(block: ContentBlock): ModelInputCapability | undefined;
111
111
  export declare function collectMessageContentBlocks(messages: readonly Message[]): ContentBlock[];
package/dist/content.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import { lookup as dnsLookup } from "node:dns/promises";
2
- import { request as httpRequest } from "node:http";
3
- import { request as httpsRequest } from "node:https";
4
2
  import { isIP } from "node:net";
3
+ import { pinnedFetch } from "./pinned-fetch.js";
5
4
  import { assertPermission } from "./security.js";
6
5
  /** Known model input capability tags for `ModelCapabilities.input`. */
7
6
  export const MODEL_INPUT_CAPABILITIES = ["text", "image", "audio", "file", "document"];
@@ -29,8 +28,8 @@ export class UnsupportedModalityError extends Error {
29
28
  }
30
29
  export class MediaContentError extends Error {
31
30
  code;
32
- constructor(code, message) {
33
- super(message);
31
+ constructor(code, message, options) {
32
+ super(message, options);
34
33
  this.name = "MediaContentError";
35
34
  this.code = code;
36
35
  }
@@ -310,22 +309,34 @@ async function fetchBoundedMediaUrl(url, options) {
310
309
  try {
311
310
  if (options.fetch)
312
311
  return await readFetchResponse(await options.fetch(url, { signal, redirect: "error" }), options.maxBytes);
313
- const parsed = new URL(url);
314
- const hostname = normalizeHostname(parsed.hostname);
315
- const family = isIP(hostname);
316
- const address = family
317
- ? { address: hostname, family: family }
318
- : await resolvePublicAddress(hostname, options.resolveHostname ?? defaultMediaHostnameResolver, signal, options.ssrf);
319
- const bytes = await (options.requestUrl ?? requestPinnedMediaUrl)({
320
- url: parsed,
321
- address,
322
- maxBytes: options.maxBytes,
323
- signal,
324
- });
325
- if (bytes.byteLength > options.maxBytes) {
326
- throw new MediaContentError("item_too_large", `Fetched media exceeded ${options.maxBytes} bytes`);
312
+ if (options.requestUrl) {
313
+ // Test/custom seam: explicit resolve + caller-provided requester. The default
314
+ // path below routes through the shared pinnedFetch primitive instead.
315
+ const parsed = new URL(url);
316
+ const hostname = normalizeHostname(parsed.hostname);
317
+ const family = isIP(hostname);
318
+ const address = family
319
+ ? { address: hostname, family: family }
320
+ : await resolvePublicAddress(hostname, options.resolveHostname ?? defaultMediaHostnameResolver, signal, options.ssrf);
321
+ const bytes = await options.requestUrl({
322
+ url: parsed,
323
+ address,
324
+ maxBytes: options.maxBytes,
325
+ signal,
326
+ });
327
+ if (bytes.byteLength > options.maxBytes) {
328
+ throw new MediaContentError("item_too_large", `Fetched media exceeded ${options.maxBytes} bytes`);
329
+ }
330
+ return bytes;
327
331
  }
328
- return bytes;
332
+ // Default: one DNS-pinned, redirect-free, byte-bounded fetch (task 4).
333
+ const response = await pinnedFetch(new URL(url), { signal, redirect: "manual" }, {
334
+ errorPrefix: "Media",
335
+ hostnameErrorPrefix: "Media",
336
+ resolver: options.resolveHostname ?? defaultMediaHostnameResolver,
337
+ ssrf: options.ssrf,
338
+ });
339
+ return await readFetchResponse(response, options.maxBytes);
329
340
  }
330
341
  catch (error) {
331
342
  if (error instanceof MediaContentError)
@@ -411,37 +422,6 @@ async function readFetchResponse(response, maxBytes) {
411
422
  }
412
423
  return joinChunks(chunks, total);
413
424
  }
414
- function requestPinnedMediaUrl({ url, address, maxBytes, signal }) {
415
- return new Promise((resolve, reject) => {
416
- const request = (url.protocol === "https:" ? httpsRequest : httpRequest)(url, {
417
- agent: false,
418
- family: address.family,
419
- signal,
420
- lookup: (_hostname, _options, callback) => callback(null, address.address, address.family),
421
- }, (response) => readIncomingMessage(response, maxBytes).then(resolve, reject));
422
- request.on("error", reject);
423
- request.end();
424
- });
425
- }
426
- async function readIncomingMessage(response, maxBytes) {
427
- const status = response.statusCode ?? 0;
428
- if (status < 200 || status >= 300) {
429
- response.resume();
430
- throw new MediaContentError("fetch_failed", `Media fetch failed with status ${status}`);
431
- }
432
- const chunks = [];
433
- let total = 0;
434
- for await (const chunk of response) {
435
- const bytes = typeof chunk === "string" ? new TextEncoder().encode(chunk) : new Uint8Array(chunk);
436
- total += bytes.byteLength;
437
- if (total > maxBytes) {
438
- response.destroy();
439
- throw new MediaContentError("item_too_large", `Fetched media exceeded ${maxBytes} bytes`);
440
- }
441
- chunks.push(bytes);
442
- }
443
- return joinChunks(chunks, total);
444
- }
445
425
  function joinChunks(chunks, total) {
446
426
  const bytes = new Uint8Array(total);
447
427
  let offset = 0;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export { resolveAgentDefinition } from "./agent-definitions.js";
2
+ export { abortableSleep, pollDeviceCodeToken, redactOAuthError, throwIfAborted, type OAuthTokenSuccessPayload, type PollDeviceCodeTokenOptions, } from "./oauth-device-code.js";
3
+ export { boundResponse, defaultResolver, isLoopbackAddress, isLoopbackHostname, normalizeHostname, pinnedFetch, raceAbort, requestPinned, resolvePinnedAddress, type PinnedFetchOptions, } from "./pinned-fetch.js";
2
4
  export type { AgentEventSourceErrorCode } from "./agent-event-source.js";
3
5
  export { AgentEventSourceError, createMemoryAgentEventSource } from "./agent-event-source.js";
4
6
  export { dispatchToolCallsInOrder, generateValidateReviseLoop, isAgentLoopOptions, resolveLoop, resolveToolConcurrency, singleShotLoop, } from "./agent-loops.js";
@@ -107,5 +109,5 @@ export { createToolParameterValidator, createToolRegistry, dispatchToolCall, fil
107
109
  export type { ResolvedUseCaseModel, ResolveUseCaseModelInput, UseCaseModelBinding, } from "./use-case-model.js";
108
110
  export { resolveUseCaseModel, resolveUseCaseModelBinding, useCaseCredentialProviderId, } from "./use-case-model.js";
109
111
  export declare const name = "prism";
110
- export declare const version = "0.2.0";
112
+ export declare const version = "0.2.1";
111
113
  export declare const description = "Agent harness for AI providers, agents, sessions, and tools.";
package/dist/index.js CHANGED
@@ -1,4 +1,6 @@
1
1
  export { resolveAgentDefinition } from "./agent-definitions.js";
2
+ export { abortableSleep, pollDeviceCodeToken, redactOAuthError, throwIfAborted, } from "./oauth-device-code.js";
3
+ export { boundResponse, defaultResolver, isLoopbackAddress, isLoopbackHostname, normalizeHostname, pinnedFetch, raceAbort, requestPinned, resolvePinnedAddress, } from "./pinned-fetch.js";
2
4
  export { AgentEventSourceError, createMemoryAgentEventSource } from "./agent-event-source.js";
3
5
  export { dispatchToolCallsInOrder, generateValidateReviseLoop, isAgentLoopOptions, resolveLoop, resolveToolConcurrency, singleShotLoop, } from "./agent-loops.js";
4
6
  export { createAgentRunLifecycle } from "./agent-run-lifecycle.js";
@@ -58,6 +60,6 @@ export { DEFAULT_TOOL_RESULT_FOLD_MAX_SUMMARY_BYTES, DEFAULT_TOOL_RESULT_FOLD_MI
58
60
  export { createToolParameterValidator, createToolRegistry, dispatchToolCall, filterTools } from "./tools.js";
59
61
  export { resolveUseCaseModel, resolveUseCaseModelBinding, useCaseCredentialProviderId, } from "./use-case-model.js";
60
62
  export const name = "prism";
61
- export const version = "0.2.0";
63
+ export const version = "0.2.1";
62
64
  export const description = "Agent harness for AI providers, agents, sessions, and tools.";
63
65
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,58 @@
1
+ import type { OAuthCredentials, OAuthLoginCallbacks } from "./contracts-core.js";
2
+ /**
3
+ * Shared RFC 8628 device-code flow used by both the OpenAI Codex OAuth provider
4
+ * (provider-openai) and the generic OAuth 2.0 provider (credentials-node).
5
+ * Owns the device-code request, the poll loop (authorization_pending continue,
6
+ * slow_down backoff, expiry deadline), cancellation, bounded success/error
7
+ * body reads, secret redaction, and token-shape parsing. Adapter-specific
8
+ * fields are parameters/callbacks, not subclasses.
9
+ */
10
+ export declare const REDACTED = "[REDACTED]";
11
+ export declare const DEFAULT_DEVICE_POLL_INTERVAL_MS = 5000;
12
+ export declare const SLOW_DOWN_INCREMENT_MS = 5000;
13
+ export interface OAuthDeviceCodePayload {
14
+ readonly device_code: string;
15
+ readonly user_code: string;
16
+ readonly verification_uri: string;
17
+ readonly expires_in?: number;
18
+ readonly interval?: number;
19
+ }
20
+ export interface OAuthTokenSuccessPayload {
21
+ readonly access_token?: string;
22
+ readonly refresh_token?: string;
23
+ readonly expires_in?: number;
24
+ readonly account_id?: string;
25
+ }
26
+ export interface OAuthTokenErrorPayload {
27
+ readonly error?: string;
28
+ readonly error_description?: string;
29
+ }
30
+ export interface PollDeviceCodeTokenOptions {
31
+ readonly fetchImpl: typeof fetch;
32
+ readonly deviceCodeUrl: string;
33
+ readonly tokenUrl: string;
34
+ readonly clientId: string;
35
+ readonly scope?: string;
36
+ /** Extra token-request params merged into every poll body (e.g. client_secret, audience). Never logged. */
37
+ readonly extraTokenParams?: Readonly<Record<string, string>>;
38
+ readonly callbacks?: Pick<OAuthLoginCallbacks, "onDeviceCode" | "signal">;
39
+ /** Message prefix, e.g. "OpenAI" or the adapter id. */
40
+ readonly errorPrefix: string;
41
+ /** Test seam: override wall clock for device-code expiry. */
42
+ readonly now?: () => number;
43
+ /** Test seam: override poll delay between device-code token requests. */
44
+ readonly sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
45
+ /** Token-shape parsing; adapter-specific fields (e.g. accountId fallback) applied here. */
46
+ readonly parseTokenCredentials: (json: OAuthTokenSuccessPayload) => OAuthCredentials;
47
+ }
48
+ export declare function throwIfAborted(signal?: AbortSignal): void;
49
+ export declare function abortableSleep(ms: number, signal?: AbortSignal): Promise<void>;
50
+ export declare function redactOAuthError(error: Error, secrets: readonly (string | undefined)[]): Error;
51
+ /**
52
+ * Request a device code, surface it through `onDeviceCode`, then poll the token
53
+ * endpoint until success, expiry, a terminal OAuth error, or abort. All response
54
+ * bodies are read under the shared byte ceiling; success bodies must be bounded
55
+ * JSON with an `access_token` string (fail closed otherwise); error bodies are
56
+ * bounded text parsed as JSON with a redacted-text fallback.
57
+ */
58
+ export declare function pollDeviceCodeToken(options: PollDeviceCodeTokenOptions): Promise<OAuthCredentials>;
@@ -0,0 +1,119 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+ import { readBoundedResponseJson, readBoundedResponseText } from "./providers/transport.js";
3
+ /**
4
+ * Shared RFC 8628 device-code flow used by both the OpenAI Codex OAuth provider
5
+ * (provider-openai) and the generic OAuth 2.0 provider (credentials-node).
6
+ * Owns the device-code request, the poll loop (authorization_pending continue,
7
+ * slow_down backoff, expiry deadline), cancellation, bounded success/error
8
+ * body reads, secret redaction, and token-shape parsing. Adapter-specific
9
+ * fields are parameters/callbacks, not subclasses.
10
+ */
11
+ export const REDACTED = "[REDACTED]";
12
+ export const DEFAULT_DEVICE_POLL_INTERVAL_MS = 5_000;
13
+ export const SLOW_DOWN_INCREMENT_MS = 5_000;
14
+ export function throwIfAborted(signal) {
15
+ if (signal?.aborted)
16
+ throw signal.reason ?? new Error("OAuth login aborted");
17
+ }
18
+ export async function abortableSleep(ms, signal) {
19
+ if (ms <= 0)
20
+ return;
21
+ throwIfAborted(signal);
22
+ await delay(ms, undefined, { signal });
23
+ }
24
+ export function redactOAuthError(error, secrets) {
25
+ let message = error.message;
26
+ for (const secret of secrets) {
27
+ if (secret)
28
+ message = message.split(secret).join(REDACTED);
29
+ }
30
+ return new Error(message);
31
+ }
32
+ const isDeviceCodePayload = (value) => {
33
+ if (typeof value !== "object" || value === null)
34
+ return false;
35
+ const candidate = value;
36
+ return (typeof candidate.device_code === "string" &&
37
+ candidate.device_code.length > 0 &&
38
+ typeof candidate.user_code === "string" &&
39
+ candidate.user_code.length > 0 &&
40
+ typeof candidate.verification_uri === "string" &&
41
+ candidate.verification_uri.length > 0);
42
+ };
43
+ const isTokenSuccessPayload = (value) => {
44
+ if (typeof value !== "object" || value === null)
45
+ return false;
46
+ return typeof value.access_token === "string";
47
+ };
48
+ /**
49
+ * Request a device code, surface it through `onDeviceCode`, then poll the token
50
+ * endpoint until success, expiry, a terminal OAuth error, or abort. All response
51
+ * bodies are read under the shared byte ceiling; success bodies must be bounded
52
+ * JSON with an `access_token` string (fail closed otherwise); error bodies are
53
+ * bounded text parsed as JSON with a redacted-text fallback.
54
+ */
55
+ export async function pollDeviceCodeToken(options) {
56
+ const { fetchImpl, deviceCodeUrl, tokenUrl, clientId, scope, extraTokenParams, callbacks, errorPrefix, parseTokenCredentials } = options;
57
+ const now = options.now ?? Date.now;
58
+ const sleep = options.sleep ?? abortableSleep;
59
+ const requestBody = { client_id: clientId };
60
+ if (scope)
61
+ requestBody.scope = scope;
62
+ const response = await fetchImpl(deviceCodeUrl, {
63
+ method: "POST",
64
+ headers: { "content-type": "application/json" },
65
+ body: JSON.stringify(requestBody),
66
+ signal: callbacks?.signal,
67
+ });
68
+ if (!response.ok) {
69
+ const detail = await readBoundedResponseText(response);
70
+ throw redactOAuthError(new Error(`${errorPrefix} device code failed: ${response.status}${detail ? ` ${detail}` : ""}`), []);
71
+ }
72
+ const json = await readBoundedResponseJson(response, { shape: isDeviceCodePayload });
73
+ const secrets = [json.device_code, json.user_code];
74
+ const expiresAtMs = now() + (json.expires_in ?? 0) * 1_000;
75
+ await callbacks?.onDeviceCode?.({
76
+ userCode: json.user_code,
77
+ verificationUri: json.verification_uri,
78
+ expiresAt: json.expires_in ? new Date(expiresAtMs).toISOString() : undefined,
79
+ });
80
+ let intervalMs = Math.max(1, (json.interval ?? DEFAULT_DEVICE_POLL_INTERVAL_MS / 1_000) * 1_000);
81
+ while (now() < expiresAtMs) {
82
+ throwIfAborted(callbacks?.signal);
83
+ await sleep(intervalMs, callbacks?.signal);
84
+ throwIfAborted(callbacks?.signal);
85
+ const tokenResponse = await fetchImpl(tokenUrl, {
86
+ method: "POST",
87
+ headers: { "content-type": "application/json" },
88
+ body: JSON.stringify({
89
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
90
+ client_id: clientId,
91
+ device_code: json.device_code,
92
+ ...extraTokenParams,
93
+ }),
94
+ signal: callbacks?.signal,
95
+ });
96
+ if (tokenResponse.ok) {
97
+ const payload = await readBoundedResponseJson(tokenResponse, { shape: isTokenSuccessPayload });
98
+ return parseTokenCredentials(payload);
99
+ }
100
+ const errorText = await readBoundedResponseText(tokenResponse, { secrets });
101
+ let errorPayload;
102
+ try {
103
+ errorPayload = JSON.parse(errorText);
104
+ }
105
+ catch {
106
+ errorPayload = { error: "invalid_token_response", error_description: errorText };
107
+ }
108
+ const code = errorPayload.error ?? "unknown_error";
109
+ if (code === "authorization_pending")
110
+ continue;
111
+ if (code === "slow_down") {
112
+ intervalMs += SLOW_DOWN_INCREMENT_MS;
113
+ continue;
114
+ }
115
+ throw redactOAuthError(new Error(`${errorPrefix} device code login failed: ${code}${errorPayload.error_description ? ` ${errorPayload.error_description}` : ""}`), secrets);
116
+ }
117
+ throw redactOAuthError(new Error(`${errorPrefix} device code login expired before authorization completed`), secrets);
118
+ }
119
+ //# sourceMappingURL=oauth-device-code.js.map
@@ -0,0 +1,25 @@
1
+ import { type MediaHostAddress, type MediaHostnameResolver, type SsrfPolicy } from "./content.js";
2
+ export interface PinnedFetchOptions {
3
+ /** Prefix for request-level error messages ("redirects are not allowed", "response exceeds", ...). Default "Request". */
4
+ readonly errorPrefix?: string;
5
+ /** Prefix for hostname-resolution error messages. Default: `errorPrefix`. */
6
+ readonly hostnameErrorPrefix?: string;
7
+ /** Custom resolver; defaults to `node:dns/promises` lookup (all answers, verbatim). */
8
+ readonly resolver?: MediaHostnameResolver;
9
+ /** Allow loopback destinations only when the requested hostname is itself loopback AND this is true. */
10
+ readonly allowLoopback?: boolean;
11
+ /** SSRF policy applied to the URL precheck and to every resolved candidate. */
12
+ readonly ssrf?: SsrfPolicy;
13
+ /** Byte ceiling for the response stream (content-length precheck + streaming bound). */
14
+ readonly maxResponseBytes?: number;
15
+ }
16
+ /** One DNS-pinned, redirect-free, byte-bounded fetch. See module comment. */
17
+ export declare function pinnedFetch(url: URL, init: RequestInit | undefined, options?: PinnedFetchOptions): Promise<Response>;
18
+ export declare function resolvePinnedAddress(url: URL, resolver: MediaHostnameResolver, signal: AbortSignal | null | undefined, allowLoopback: boolean, ssrf: SsrfPolicy | undefined, hostnameErrorPrefix?: string): Promise<MediaHostAddress>;
19
+ export declare function defaultResolver(hostname: string): Promise<readonly MediaHostAddress[]>;
20
+ export declare function requestPinned(url: URL, address: MediaHostAddress, init: RequestInit | undefined, errorPrefix?: string): Promise<Response>;
21
+ export declare function boundResponse(response: Response, maxBytes: number, errorPrefix?: string): Response;
22
+ export declare function raceAbort<T>(promise: Promise<T>, signal: AbortSignal | null | undefined): Promise<T>;
23
+ export declare function normalizeHostname(value: string): string;
24
+ export declare function isLoopbackHostname(value: string): boolean;
25
+ export declare function isLoopbackAddress(value: string): boolean;
@@ -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;
@@ -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
 
package/docs/index.md CHANGED
@@ -7,7 +7,7 @@ Prism is a TypeScript/Node.js agent harness. Host apps and extension packages ow
7
7
 
8
8
  ## Identity and governance
9
9
  - [Agent identity](agent-identity.md): host-verified `Principal` / `AgentIdentity`, delegation narrowing, ownership projection, and redacted telemetry refs for enterprise runs/tools/MCP/A2A/workflows; optional OIDC/JWKS verifier adapter (`@arnilo/prism-credentials-node/oidc` — pinned issuer/audience/JWKS, bounded claims, fail closed).
10
- - [Policy and audit](policy-and-audit.md): optional `@arnilo/prism-policy` decision ledger (allow/deny/modify/approval), evidence refs only, cursor-paginated WORM export, and durable PostgreSQL composition; 0.0.28 adds the OPA REST evaluator (`@arnilo/prism-policy/opa` — pinned SSRF-checked endpoint, redacted input, fail-closed deny, optional bundle-revision pin).
10
+ - [Policy and audit](policy-and-audit.md): optional `@arnilo/prism-policy` decision ledger (allow/deny/modify/approval), evidence refs only, cursor-paginated WORM export, and durable PostgreSQL composition; 0.0.28 adds the OPA REST evaluator (`@arnilo/prism-policy/opa` — pinned SSRF-checked endpoint, redacted input, fail-closed deny, optional bundle-revision pin); 0.2.1 makes the OPA decision fetch DNS-pinned (core `pinnedFetch`).
11
11
  - [Model routing](model-routing.md): optional `@arnilo/prism-model-router` allow-list/residency/budget/rate/circuit/fallback governance with redacted diagnostics; durable state requires awaited identity-scoped calls; host-configurable selection policies (reference cost/latency policy ranks by `ModelCost` then in-memory latency EMA fed from `recordOutcome`).
12
12
 
13
13
  ## Agent/session runtime
@@ -40,23 +40,23 @@ Prism is a TypeScript/Node.js agent harness. Host apps and extension packages ow
40
40
  - [Persistence, credentials, and multimodality primitives](persistence-credentials-multimodality-primitives.md): Plan 056 inventory — session/run-ledger/persistence contracts, credential/OAuth seams, content/resource/model capabilities, package dependency matrix, conformance matrix, and threat model for production adapters.
41
41
 
42
42
  ## Provider and model connection
43
- - [Provider primitives](provider-primitives.md): shared bounded transport and OpenAI serialization helpers — migrated across first-party providers; native structured-output and observability contracts.
43
+ - [Provider primitives](provider-primitives.md): shared bounded transport and OpenAI serialization helpers — migrated across first-party providers; bounded success-body reader for all non-stream JSON endpoints; native structured-output and observability contracts.
44
44
  - [Provider layer](provider-layer.md): register and resolve host-owned providers/models, choose replace-or-error duplicate policy, create provider events, stream/reconstruct tool-call deltas, use generic provider request options, and test with the mock provider; deprecated provider-level timeout/retry hints point to runtime abort/retry.
45
45
  - [Model registry](model-registry.md): register and resolve `ModelConfig` records with capabilities, limits, cost, cache support metadata, compat data, and duplicate policy.
46
- - [Provider caching](provider-caching.md): use `PromptCacheHints`, `PromptCacheBreakpoint`, `ModelCacheCapabilities`, cache-aware stable-prefix guidance, and shared cache diagnostics helpers; includes the complete per-provider explicit/implicit cache matrix plus no-Prism-cache entries (including Anthropic, Google, Alibaba, Ollama, cloud adapters, and host-owned AI SDK); cache hints are best-effort and cache keys are never secrets; `createCacheTelemetry()` aggregates per-provider/model hit rate and cache-token totals from the `usage` event stream for tuning the `cache_aware` layout.
46
+ - [Provider caching](provider-caching.md): use `PromptCacheHints`, `PromptCacheBreakpoint`, `ModelCacheCapabilities`, cache-aware stable-prefix guidance, and shared cache diagnostics helpers; includes the complete per-provider explicit/implicit cache matrix plus no-Prism-cache entries (including Anthropic, Google, Alibaba, Ollama, cloud adapters, and host-owned AI SDK); cache hints are best-effort and cache keys are never secrets; `createCacheTelemetry()` aggregates per-provider/model hit rate and cache-token totals from the `usage` event stream for tuning the `cache_aware` layout — the `__overflow__` bucket reports requests/token totals only, never mixed-model cost.
47
47
  - [Thinking and reasoning](thinking-and-reasoning.md): portable `ThinkingLevel` helpers (`applyThinkingLevel` / `thinkingCompatFor`) map per-turn effort into provider `compat` fields; model defaults stay on `ModelConfig.compat`; no second options tree.
48
48
  - [Use-case model selection](use-case-model-selection.md): bind `{ model?, provider?, thinkingLevel? }` for observational memory, LLM compaction, and other non-session LLM jobs with explicit session-model fallback via `resolveUseCaseModel`.
49
49
  - [Provider request policies](provider-request-policies.md): chain `ProviderRequestPolicy` hooks, use `createSessionCachePolicy`, and merge legacy/structured cache options safely.
50
50
  - [Provider packages](provider-packages.md): define explicit provider packages, model metadata, auth descriptors, request/cache policies, provider-owned header precedence, the provider-authorized OAuth matrix, and the Phase 10 first-party compatibility matrix without package discovery or provider-specific core behavior; includes a cache behavior summary and **caller-gated on-demand model discovery** (`list*Models`, setup zero-fetch).
51
51
  - Phase 12 package workspaces: [`@arnilo/prism-provider-openai`](providers/openai.md) (Responses hosted-tool attribution, bounded continuation, Realtime session seam), [`@arnilo/prism-provider-anthropic`](providers/anthropic.md) (native Messages, `cache_control`, thinking, caller-gated `listAnthropicModels`), [`@arnilo/prism-provider-google`](providers/google.md) (native Gemini `generateContent` SSE, caller-gated `listGoogleModels`), [`@arnilo/prism-provider-opencode-go`](providers/opencode-go.md) (official Go open models, dual-route Anthropic/OpenAI, caller-gated `listOpenCodeGoModels`, `reasoning_content`/thinking preserve), [`@arnilo/prism-provider-openrouter`](providers/openrouter.md) (app-controlled catalog, caller-gated `listOpenRouterModels`, `reasoning` merge/preserve, `cache_control` + sticky `session_id`), [`@arnilo/prism-provider-zai`](providers/zai.md) (official `thinking`/`reasoning_effort`/`tool_stream`, implicit cache, caller-gated `listZaiModels`), [`@arnilo/prism-provider-kimi`](providers/kimi.md), [`@arnilo/prism-provider-alibaba`](providers/alibaba.md) (Alibaba Cloud Model Studio / DashScope + Coding Plan, OpenAI-compatible, caller-gated `listAlibabaModels`, implicit + explicit `cache_control` caching, Qwen `enable_thinking`), [`@arnilo/prism-provider-ollama`](providers/ollama.md) (Ollama Cloud + local, OpenAI-compatible, caller-gated `listOllamaModels`, implicit-only caching, `reasoning_effort`), and [`@arnilo/prism-provider-neuralwatt`](providers/neuralwatt.md) with implicit vLLM prefix caching, reasoning controls (`reasoning_effort`/`thinking_token_budget`/`enable_thinking`/`preserve_thinking`/`clear_thinking`), reasoning preservation, OpenAI-style tool-call loop, quota, telemetry, and retry classification helpers.
52
- - Phase 8 enterprise cloud (workload identity; separate from consumer Anthropic/Google): [`@arnilo/prism-provider-azure`](providers/azure.md) (Entra / Foundry), [`@arnilo/prism-provider-bedrock`](providers/bedrock.md) (IAM/IRSA + region/PrivateLink), [`@arnilo/prism-provider-vertex`](providers/vertex.md) (ADC / Vertex OpenAPI).
52
+ - Phase 8 enterprise cloud (workload identity; separate from consumer Anthropic/Google): [`@arnilo/prism-provider-azure`](providers/azure.md) (Entra / Foundry, credential once per request), [`@arnilo/prism-provider-bedrock`](providers/bedrock.md) (IAM/IRSA + region/PrivateLink, duplicate-case-safe SigV4 signing), [`@arnilo/prism-provider-vertex`](providers/vertex.md) (ADC / Vertex OpenAPI, credential once per request).
53
53
  - Optional AI SDK adapter: [`@arnilo/prism-provider-ai-sdk`](providers/ai-sdk.md) maps host-owned pinned `LanguageModelV4` models onto Prism `AIProvider` streams (offline-tested `@ai-sdk/provider` version matrix; no Prism catalog; maps metadata/tool authority/`finish.usage` cache tokens; reasoning is host-model-owned).
54
- - [OpenAI-compatible provider](providers/openai-compatible.md): optional provider subpath using native or injected `fetch` for Chat Completions streaming (`chatCompletionsUrl` / `authStyle` overrides for enterprise adapters; `buildBodyExtra` / `mapMessages` / `mapUsage` / `extraHeaders` hooks for vendor variants).
54
+ - [OpenAI-compatible provider](providers/openai-compatible.md): optional provider subpath using native or injected `fetch` for Chat Completions streaming (`chatCompletionsUrl` / `authStyle` overrides for enterprise adapters; `buildBodyExtra` / `mapMessages` / `mapUsage` / `extraHeaders` hooks for vendor variants; **strict completion is the shared default** — streams ending without `[DONE]` + `finish_reason` fail closed instead of emitting a successful `done`, with explicit `strictCompletion: false` as the documented opt-out).
55
55
 
56
56
  ## Input, prompt, and context assembly
57
57
  - [SDK customization guide](customization.md): map provider resolution, middleware, context, builders, injectors, loops, compaction, retry, stores, and skills to explicit host-wired APIs.
58
58
  - [Input and prompt assembly](input-and-prompt-assembly.md): render tiny prompt templates and turn common host input, history, attachments, explicit resources, summaries, and tool results into messages with replaceable builders, provider-input assembly, cache-aware default order, opt-in legacy ordering, and optional `contextBudget` eviction + omission reports. Audio/file/document `ContentBlock` types and capability checks are documented there.
59
- - [Multimodal content](multimodal-content.md): complete-request media resolution and aggregate bounds, DNS-classified/address-pinned URLs, SSRF/MIME policy, `ModelCapabilities.input` tags, and first-party content-type mapping.
59
+ - [Multimodal content](multimodal-content.md): complete-request media resolution and aggregate bounds, DNS-classified/address-pinned URLs, SSRF/MIME policy, `ModelCapabilities.input` tags, and first-party content-type mapping; 0.2.1 routes media URL fetches through the core DNS-pinned fetch primitive.
60
60
  - [System prompts](system-prompts.md): compose explicit user/package/app/run system prompt layers, auto-load the standard `AGENTS.md` (workspace) / `SYSTEM.md` prompt files via the Node `loadSystemPromptFiles` loader (trust-gated for `AGENTS.md`), and append `SYSTEM.md` → per-agent `AGENT.md` body → repo `AGENTS.md` layers from a discovered agent bundle via `resolveAgentBundle`.
61
61
  - [Instruction injection](instruction-injection.md): register package injectors that layer redacted instructions/context blocks without granting tools, permissions, or resource escapes.
62
62
  - [Context and skills](context-and-skills.md): resolve ordered context providers; progressive skill catalog (`skillsDisclosure`, default catalog-only), `load_skill` on-demand bodies, fail-closed registry activation (`activateAllSkills` migration opt-in), `toolNames` fail closed before provider turns, priority-aware budget demotion, and optional `toolResultFold`.
@@ -68,7 +68,7 @@ Prism is a TypeScript/Node.js agent harness. Host apps and extension packages ow
68
68
  - [OpenAPI tools adapter](openapi-tools.md): optional `@arnilo/prism-openapi-tools` `createOpenApiTools` — compile host-selected OpenAPI 3.1 operationIds into bounded `ToolDefinition`s (allow-list only, pinned origin, resolved/bounded schemas, approval + effect-store idempotency on mutations, bounded body/response/retries/pagination, host credential resolver, untrusted output).
69
69
  - [Tool execution primitives](tool-execution-primitives.md): finite JSON Schema LRU validation, exclusive-aware bounded parallel dispatch, MCP bridge mapping, coding execution policy, and image-read bounds.
70
70
  - [Tool validator JSON Schema package](../packages/tool-validator-json-schema/README.md): optional `@arnilo/prism-tool-validator-json-schema` adapter for `tool.parameters`.
71
- - [MCP client bridge and server exposure](mcp-tools.md): SDK-1.30.0 bounded tools/resources/prompts, host-owned roots/sampling/elicitation, exact-origin DNS-pinned client transport, and principal-bound opt-in Streamable HTTP sessions. 0.0.28 adds MCP OAuth: `createMcpOAuthTransport`/`createMcpOAuthFetch`/`createMcpClientAuth` (RFC 9728/8414 discovery, PKCE, RFC 8707 audience binding, RFC 7009 revocation, host-owned bounded state) and server `protectedResource` metadata + `WWW-Authenticate` challenges.
71
+ - [MCP client bridge and server exposure](mcp-tools.md): SDK-1.30.0 bounded tools/resources/prompts, host-owned roots/sampling/elicitation, exact-origin DNS-pinned client transport, and principal-bound opt-in Streamable HTTP sessions. 0.0.28 adds MCP OAuth: `createMcpOAuthTransport`/`createMcpOAuthFetch`/`createMcpClientAuth` (RFC 9728/8414 discovery, PKCE, RFC 8707 audience binding, RFC 7009 revocation, host-owned bounded state) and server `protectedResource` metadata + `WWW-Authenticate` challenges; 0.2.1 re-routes the client transport through the shared core DNS-pinned fetch primitive.
72
72
  - [Web search, fetch, and extraction](web-tools.md): optional host-selected Brave/Exa discovery and Firecrawl Markdown/schema tools with native fetch, stable citations, late credentials, finite limits, and explicit untrusted-content boundaries.
73
73
  - [Work tools](work-tools.md): optional `@arnilo/prism-work-tools` identity-scoped M365 + GWS connectors (hard-coded CLI argv, draft-then-approve, state-machine idempotency, shared result shapes); 0.0.14 adds a late-bound per-identity `tokenProvider` (env-only, fail-closed); 0.2.0 plan 020 Task 3 provides an isolated subprocess environment (fixed allow-listed base + explicit env + late-bound token env, forced `HOME`/telemetry controls, 64-name/64-KiB caps) and requires host-pinned **absolute** binary/configDir paths.
74
74
  - [Work connectors](work-connectors.md): connector principles, capability gates, scoped OAuth establishment (0.0.14), and out-of-scope boundaries (Slack/Teams channels not shipped) for Microsoft 365 / Google Workspace.
@@ -111,8 +111,8 @@ Prism is a TypeScript/Node.js agent harness. Host apps and extension packages ow
111
111
  ## Security and credentials
112
112
  - [Host security guide](host-security.md): fail-closed checklist for supply-chain/attestation/canary isolation, bounded credentials, AG-UI/ACP/A2A/web remote boundaries, untrusted external content, settings, redaction, trust roots, workflow ownership, coding I/O, permissions, PostgreSQL TLS/roles/cleanup, persistence, extensions, and tool validation; **0.1.0 security evidence** (plan 012 Task 6): moderate audit policy, named threat-suites leg (`npm run security:threat-suites`), supply-chain negative fixtures, blocked-gate canary semantics.
113
113
  - [Security/auth/trust](settings-auth-trust-security.md): settings providers, credential helpers, trust/permission policies, redaction controls, host-owned settings/credentials wiring outside `AgentConfig`, and security-boundary hardening summary.
114
- - [Credentials and redaction](credentials-and-redaction.md): compose explicit credential resolver order, use caller-supplied env objects/OAuth refresh + revoke helpers, resolve credentials only at the provider edge, redact known secret values, and follow the provider-authorized subscription OAuth matrix.
115
- - [Credential storage](credential-storage.md): optional `@arnilo/prism-credentials-node` adapter with strict bounded AES-GCM envelopes, async finite scrypt, restrictive Unix files, abort-aware bounded system-keychain calls, optional host-KMS wrap (`encryptWithHostKms`), and 0.0.14 Microsoft 365 / Google Workspace OAuth providers (PKCE/device-code, least-privilege scope bundles, per-identity work-token bridge); `./oidc` subpath adds the OIDC/JWKS identity verifier.
114
+ - [Credentials and redaction](credentials-and-redaction.md): compose explicit credential resolver order, use caller-supplied env objects/OAuth refresh + revoke helpers, resolve credentials only at the provider edge, redact known secret values, and follow the provider-authorized subscription OAuth matrix; 0.2.1 adds the shared bounded device/token flow (core `pollDeviceCodeToken`, fail-closed token shape) behind both the OpenAI Codex and OAuth 2.0 providers.
115
+ - [Credential storage](credential-storage.md): optional `@arnilo/prism-credentials-node` adapter with strict bounded AES-GCM envelopes, async finite scrypt, restrictive Unix files, abort-aware bounded system-keychain calls, optional host-KMS wrap (`encryptWithHostKms`), and 0.0.14 Microsoft 365 / Google Workspace OAuth providers (PKCE/device-code, least-privilege scope bundles, per-identity work-token bridge); `./oidc` subpath adds the OIDC/JWKS identity verifier; 0.2.1 makes the JWKS fetch DNS-pinned (core `pinnedFetch`, redirect-free, byte-bounded).
116
116
 
117
117
  ## Testing and examples
118
118
  - Provider test doubles: `createMockProvider()` and provider event helpers are documented on the canonical Provider layer page above.
@@ -129,7 +129,7 @@ Prism is a TypeScript/Node.js agent harness. Host apps and extension packages ow
129
129
  - [Ponytail behavior integration](ponytail.md): optional `@arnilo/prism-ponytail` — upstream Ponytail skills/commands, `ponytail-mode` injector, session `ponytail-mode` persistence; resolves peer `@dietrichgebert/ponytail` or `upstreamPath`; opt-in (not in code/sdk profiles).
130
130
 
131
131
  ## Release and install
132
- - [Release and install](release-and-install.md): current **0.2.0** 50-package graph (root + 49 workspace packages) — plan 020 the fail-closed runtime-and-sandbox-security cut on the 0.2.x review-remediation line: durable-resume decision validation in core (`assertValidAgentRunResume` — unknown decisions/malformed batches fail closed with `ERR_PRISM_DECISION_*` before any state claim, checkpoint write, or tool execution; server parser remains defense in depth), isolated work-tool subprocess environments (`@arnilo/prism-work-tools` — fixed base allow-list + explicit env + forced HOME/telemetry + late-bound per-identity tokens, 64-name/64-KiB caps, absolute binary/configDir, linear output capture), and explicit sandbox capabilities (`@arnilo/prism-coding-security` — `SandboxAdapter.capabilities` with omission-is-false fail-closed resolution, `SandboxCodingComposition.capabilities` from verified wiring, `containmentClaim` deprecated as the conservative projection; Docker reports only verified controls, native reports filesystem/process/privilege `false`); public-entrypoint security conformance (`scripts/phase20-security.test.mjs`, wired into `security:threat-suites`), packed plain-JS consumer regressions, and the sandbox-browser workflow's fail-loud Docker/native capability evidence gate — 0.2.0 never ships while a blocker is skipped; migration and rollback notes in `docs/migration.md` `0.1.7 → 0.2.0`, store-compatible with 0.1.7 in both directions; 0.1.7 was the performance-and-DX patch — dependency-free `createCacheTelemetry()` per-provider/model cache hit/miss aggregator (bounded cardinality with `__overflow__`, token counters/rates only, host-activated), host-configurable `ModelRouterSelectionPolicy` on `createModelRouter` with the reference `createCostLatencySelection` (ModelCost rank then in-memory latency EMA, default ordered behavior byte-identical), `prism providers add <name>` OpenAI-compatible provider scaffold (manifest/provider/models/cache/conformance test/docs stub, npm-name + traversal + symlink-escape validation, placeholders only), and the async `AgUiProjection` verification closeout (plan 009 Task 15 evidence recorded, no new code); plan 017 the documented breaking cut — deprecated-option removal with `docs/migration.md` `0.1.4 → 0.1.5` section and reviewed compat-baseline regeneration via `--allow-break` then `--update-baseline`: the inert provider request knobs, `RunOptions.maxToolRounds`, observational-memory flat settings keys + top-level worker aliases, `ReadToolOptions.autoResizeImages`, `INIT_PROVIDERS`; all removals fail closed naming their replacement; plan 016 internal god-module split — `agents.ts`/`contracts.ts` reorganized behind barrel re-exports with a byte-identical public entry surface, measured tree-shaking improvement in `scripts/phase16-baseline.json`, and additive `@arnilo/prism-browser` Chrome DevTools Protocol capabilities — `browser_evaluate`/`browser_observe` and `block_urls`/`unblock_urls`/`throttle`/`emulate` act actions; plan 015 dead-code and deprecation hygiene on the frozen 0.1.x line — parameterized benchmark runner `scripts/benchmark.mjs` absorbing the per-version runners, archived review-coverage evidence in `docs/_evidence/`, non-blocking unused-code sweep `npm run sweep:unused`, opt-in checkpoint persistence for loaded-skill names and read-path sets; plan 014 Alibaba provider enrichment — embeddings, video input, verified compatible-mode surface decision table; plan 013 post-release hardening — build single-flight, MCP SSE relay test, combined coverage summary, canonical manifest-count narrative, ACP modes/config persistence guidance; Phase 12 release-candidate hardening; plan 012 — freeze manifest, compatibility matrix, upgrade matrix, packed-install e2e journeys, restart-recovery evidence, capacity envelopes, security policy), exact-peer/install/tarball rules, deterministic resumable publication and publish dry-run, frozen 0.1.x compatibility and support matrix (Node/PostgreSQL/platform/provider/protocol pins and unsupported combinations, machine-checked against `scripts/phase12-freeze-manifest.json`), protected PostgreSQL gate, pinned supply-chain gates, offline tests, the 0.0.15 provider/AI-SDK/RAG/memory protected live-canary matrix, and sandbox-browser Docker/Playwright gates.
132
+ - [Release and install](release-and-install.md): current **0.2.1** 50-package graph (root + 49 workspace packages) — plan 021 the provider-completion-and-outbound-trust-boundaries cut: strict stream completion is the shared OpenAI-compatible default (truncated streams fail `incomplete_delta`, explicit `strictCompletion: false` opt-out), bounded success bodies via `readBoundedResponseJson` on all discovery/quota/embeddings/upload/OAuth JSON endpoints (65,536-byte ceiling, depth/property/shape caps), DNS-pinned OIDC JWKS/OPA/content fetches through the core `pinnedFetch` primitive with 3xx redirects rejected outright (private/metadata answers fail closed `ssrf_denied`), shared bounded OAuth device/token polling (`pollDeviceCodeToken`) across provider-openai and credentials-node, and the four edge fixes (Azure/Vertex credential-once, Bedrock duplicate-case/repeated-query SigV4 canonicalization, OpenAI upload failed-DELETE retention, cache `__overflow__` tokens-only); public-entrypoint threat-suite `scripts/phase21-security.test.mjs` + packed plain-JS consumer; additive-only compat (MCP transport helpers re-exported from core, no removals); migration `0.2.0 → 0.2.1`; then plan 020 the fail-closed runtime-and-sandbox-security cut on the 0.2.x review-remediation line: durable-resume decision validation in core (`assertValidAgentRunResume` — unknown decisions/malformed batches fail closed with `ERR_PRISM_DECISION_*` before any state claim, checkpoint write, or tool execution; server parser remains defense in depth), isolated work-tool subprocess environments (`@arnilo/prism-work-tools` — fixed base allow-list + explicit env + forced HOME/telemetry + late-bound per-identity tokens, 64-name/64-KiB caps, absolute binary/configDir, linear output capture), and explicit sandbox capabilities (`@arnilo/prism-coding-security` — `SandboxAdapter.capabilities` with omission-is-false fail-closed resolution, `SandboxCodingComposition.capabilities` from verified wiring, `containmentClaim` deprecated as the conservative projection; Docker reports only verified controls, native reports filesystem/process/privilege `false`); public-entrypoint security conformance (`scripts/phase20-security.test.mjs`, wired into `security:threat-suites`), packed plain-JS consumer regressions, and the sandbox-browser workflow's fail-loud Docker/native capability evidence gate — 0.2.0 never ships while a blocker is skipped; migration and rollback notes in `docs/migration.md` `0.1.7 → 0.2.0`, store-compatible with 0.1.7 in both directions; 0.1.7 was the performance-and-DX patch — dependency-free `createCacheTelemetry()` per-provider/model cache hit/miss aggregator (bounded cardinality with `__overflow__`, token counters/rates only, host-activated), host-configurable `ModelRouterSelectionPolicy` on `createModelRouter` with the reference `createCostLatencySelection` (ModelCost rank then in-memory latency EMA, default ordered behavior byte-identical), `prism providers add <name>` OpenAI-compatible provider scaffold (manifest/provider/models/cache/conformance test/docs stub, npm-name + traversal + symlink-escape validation, placeholders only), and the async `AgUiProjection` verification closeout (plan 009 Task 15 evidence recorded, no new code); plan 017 the documented breaking cut — deprecated-option removal with `docs/migration.md` `0.1.4 → 0.1.5` section and reviewed compat-baseline regeneration via `--allow-break` then `--update-baseline`: the inert provider request knobs, `RunOptions.maxToolRounds`, observational-memory flat settings keys + top-level worker aliases, `ReadToolOptions.autoResizeImages`, `INIT_PROVIDERS`; all removals fail closed naming their replacement; plan 016 internal god-module split — `agents.ts`/`contracts.ts` reorganized behind barrel re-exports with a byte-identical public entry surface, measured tree-shaking improvement in `scripts/phase16-baseline.json`, and additive `@arnilo/prism-browser` Chrome DevTools Protocol capabilities — `browser_evaluate`/`browser_observe` and `block_urls`/`unblock_urls`/`throttle`/`emulate` act actions; plan 015 dead-code and deprecation hygiene on the frozen 0.1.x line — parameterized benchmark runner `scripts/benchmark.mjs` absorbing the per-version runners, archived review-coverage evidence in `docs/_evidence/`, non-blocking unused-code sweep `npm run sweep:unused`, opt-in checkpoint persistence for loaded-skill names and read-path sets; plan 014 Alibaba provider enrichment — embeddings, video input, verified compatible-mode surface decision table; plan 013 post-release hardening — build single-flight, MCP SSE relay test, combined coverage summary, canonical manifest-count narrative, ACP modes/config persistence guidance; Phase 12 release-candidate hardening; plan 012 — freeze manifest, compatibility matrix, upgrade matrix, packed-install e2e journeys, restart-recovery evidence, capacity envelopes, security policy), exact-peer/install/tarball rules, deterministic resumable publication and publish dry-run, frozen 0.1.x compatibility and support matrix (Node/PostgreSQL/platform/provider/protocol pins and unsupported combinations, machine-checked against `scripts/phase12-freeze-manifest.json`), protected PostgreSQL gate, pinned supply-chain gates, offline tests, the 0.0.15 provider/AI-SDK/RAG/memory protected live-canary matrix, and sandbox-browser Docker/Playwright gates.
133
133
  - [0.1.0 / 1.0 readiness gates](0.1.0-readiness.md): command-per-gate 1.0 readiness table — frozen API surface + compat gate, migration/docs tripwires, budget table, live-suite matrix, security matrix, current-line status (**0.0.23** published target), signed-publication/live-canary prerequisites for 1.0, and Phase 12 demand-evidence entry criteria.
134
134
  - [Review coverage archive](_evidence/): per-phase evidence freezes (plans 067–079, releases 0.0.4–0.0.16) — traceability matrices, provider validation, capability/primitive/limit matrices, benchmark budgets, and artifact-diet findings; tarball-excluded, kept in-repo for audit.
135
135
 
package/docs/mcp-tools.md CHANGED
@@ -208,7 +208,7 @@ Remote MCP tools default to `external_mutation`/`unsupported` unless the host `e
208
208
  | Risk | Mitigation |
209
209
  | --- | --- |
210
210
  | Untrusted subprocess (stdio) | Explicit `command` / `args` / `env` / `cwd`; review before deploy |
211
- | SSRF / DNS rebinding / redirects (HTTP) | Exact HTTPS origins; credentials/fragments/redirects denied; every DNS answer public; one address pinned per request; explicit loopback-only HTTP escape hatch |
211
+ | SSRF / DNS rebinding / redirects (HTTP) | Exact HTTPS origins; credentials/fragments/redirects denied; every DNS answer public; one address pinned per request; explicit loopback-only HTTP escape hatch. Since 0.2.1 the client transport re-routes through the shared core `pinnedFetch` primitive (DNS-pinned fetch) with byte-identical `McpBridgeError`/`McpOAuthError` wrapping |
212
212
  | Hostile discovery / schema compilation | Raw SDK `tools/list` requests avoid SDK Ajv output-schema compilation; finite pages/tools/cursors/metadata/schema totals; failed refresh leaves previous tools unchanged |
213
213
  | Tool-name shadowing | Prefixed names + `createToolRegistry({ duplicate: "error" })` |
214
214
  | MCP Apps metadata/HTML | Explicit extension acknowledgement; bounded nested metadata; app-only tools absent from model list; only linked `ui://` HTML/MIME resource body reaches the host renderer |
package/docs/migration.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Migration guide
2
2
 
3
+ ## 0.2.0 → 0.2.1 provider completion and outbound trust boundaries (plan 021)
4
+
5
+ Release **0.2.1** (plan 021) tightens the streaming-completion, outbound-fetch, and credential/signing/upload boundaries. The API surface is **additive-only** (plain reviewed compat gate at 0.2.1: the only deltas are the version literal and `@arnilo/prism-mcp` transport helpers `boundResponse`/`defaultResolver`/`isLoopbackAddress`/`isLoopbackHostname`/`normalizeHostname`/`raceAbort`/`requestPinned`/`resolvePinnedAddress` becoming re-exports of the lifted core primitives — same names, same signatures, no removal; no `--allow-break`), with five documented security-motivated behavior tightenings. Untyped/legacy callers may now fail where 0.2.0 silently proceeded:
6
+
7
+ 1. **Strict stream completion is the shared default (all OpenAI-compatible adapters).** `createOpenAICompatibleProvider` now defaults `strictCompletion: true` — a stream that ends without a `[DONE]` marker AND a choice-level `finish_reason` (EOF, network cut, provider truncation) emits a `ProviderTransportError` (`incomplete_delta`) instead of a successful `providerDone`, and a successful done never fabricates usage. This applies to every inheriting adapter: Azure, Bedrock, Vertex, OpenRouter, ZAI, NeuralWatt (Alibaba/Kimi/Ollama/OpenCode-go had already opted in).
8
+
9
+ ```js
10
+ // 0.2.1: truncated stream fails closed
11
+ for await (const event of provider.generate(request)) {
12
+ if (event.type === "error") {
13
+ event.error.code; // "incomplete_delta"
14
+ }
15
+ }
16
+ // explicit opt-out stays available where hosts own truncation detection:
17
+ createOpenAICompatibleProvider({ ..., strictCompletion: false });
18
+ ```
19
+
20
+ 2. **Bounded success bodies on non-stream JSON endpoints.** `readBoundedResponseJson` (exported from `@arnilo/prism/providers/transport`) replaces unbounded `response.json()` on all model-discovery `/models` calls, NeuralWatt quota, Alibaba embeddings, OpenAI uploads, and the OAuth success paths. Defaults: 65,536-byte UTF-8 ceiling, max JSON depth 32, max properties 4096, caller-supplied shape gate, abort support, secret-redacted errors. Oversized or malformed bodies abort with `ProviderTransportError` `response_body_overflow`/`response_body_shape` instead of buffering unbounded input.
21
+
22
+ 3. **DNS-pinned OIDC JWKS, OPA, and content fetches; redirects rejected.** The default fetch paths of `credentials-node` JWKS (`@arnilo/prism-credentials-node/oidc`), `policy` OPA decisions, and core content/media fetches now resolve the hostname once (1–32 addresses), validate every candidate against the SSRF policy, and connect only to a pinned address via a lookup-hook socket (no re-resolution). **3xx redirects are rejected outright** (`MediaContentError` code `redirect`) — a redirected fetch is never re-validated or followed. Private/metadata/loopback addresses fail closed (`MediaContentError` `ssrf_denied`). The MCP transport helpers were lifted to the shared core primitive (`pinnedFetch`, `resolvePinnedAddress`, `requestPinned` from `@arnilo/prism`) with byte-identical behavior and are re-exported from `@arnilo/prism-mcp`.
23
+
24
+ 4. **Shared bounded OAuth device/token polling.** Core OpenAI OAuth (`@arnilo/prism-provider-openai`) and `@arnilo/prism-credentials-node` now share `pollDeviceCodeToken` (RFC 8628 poll loop with `authorization_pending` continue, `slow_down` +5 s backoff, expiry deadline, cancellation, bounded success/error reads, fail-closed token-shape gates, `[REDACTED]` secret redaction). No public change — the device/token flows keep their messages and cadence; provider-specific fields stay adapter options.
25
+
26
+ 5. **Credential, signing, upload, and cache edge fixes.** (a) Azure and Vertex resolve a rotating/single-use credential **exactly once per request** — the inner provider signs with the same token the wrapper validated (a `CredentialValueSource` is never consumed twice). (b) Bedrock SigV4 canonicalization lowercases and merges duplicate-case request headers last-wins and sorts query parameters by encoded key then value — duplicate-case or reordered input can no longer produce a malformed signature. (c) OpenAI upload cleanup retains a file id until its `DELETE` succeeds — a failed/skipped cleanup leaves the id registered for a retried cleanup instead of leaking the remote file. (d) The cache-telemetry `__overflow__` bucket never carries cost — it reports requests and token totals only, so one model's cost metadata cannot mix into mixed-model overflow tokens.
27
+
28
+ **Store compatibility:** 0.2.1 is store-compatible with 0.2.0 in both directions — no persisted-shape change, no migration step. Checkpoint, session-store, approval, and registry payloads are byte-identical; only fetch/stream/credential behavior changed.
29
+
30
+ **Rollout:** upgrade core first (strict completion and bounded readers apply to all hosts immediately; truncated-stream callers must add `strictCompletion: false` only if they intentionally accept incomplete streams), then `@arnilo/prism-credentials-node` + `@arnilo/prism-policy` (DNS-pinned fetches; ensure JWKS/OPA hosts resolve to public addresses and never redirect), then the provider adapters (Azure/Vertex credential handling, Bedrock signing), then `@arnilo/prism-mcp` (re-export-only change).
31
+
32
+ **Rollback risk:** restoring 0.2.0 restores all five boundary gaps — rollback is **not** a mitigation. Hosts that must roll back should disable truncated-stream acceptance, unbounded-body endpoints, redirect-following fetches, rotating-credential reuse, and upload cleanup at their own boundary until they can return to 0.2.1.
33
+
3
34
  ## 0.1.7 → 0.2.0 fail-closed runtime and sandbox security (plan 020)
4
35
 
5
36
  Release **0.2.0** (plan 020) is the first cut of the 0.2.x review-remediation line: it closes the three security blockers found in the 2026-08-12 comprehensive review. The API surface is **additive-only** (plain compat gate at 0.2.0 shows zero removed/changed declarations; no `--allow-break`), but three behaviors are deliberately tightened for security, so untyped/legacy callers may now fail where 0.1.7 silently proceeded:
@@ -145,6 +145,7 @@ try {
145
145
  - SSRF deny-by-default blocks IPv4/IPv6 loopback, private/unique-local, link-local, unspecified, multicast, IPv4-mapped private, and cloud metadata targets. DNS answers are all classified before one public address is pinned; mixed public/private answers fail closed.
146
146
  - `allowedHostnames` is an explicit trust override and may permit a private destination. `denyPrivateHosts: false` is broader and should be reserved for hosts that intentionally own private-network access.
147
147
  - DNS lookup, connection, and body streaming share `fetchTimeoutMs` and caller abort; more than 32 resolved addresses, redirects, and oversized response bodies are rejected.
148
+ - Media URL fetches (0.2.1) route through the core `pinnedFetch` primitive — DNS-pinned resolution with per-answer SSRF checks (rebinding defense) and outright 3xx rejection — while keeping the `fetch`/`resolveHostname`/`requestUrl` host seams and the existing byte budgets.
148
149
  - MIME validation rejects common magic-byte spoofing; extensions alone are never trusted.
149
150
  - Byte budgets use base64 size estimates before decode and re-check decoded bytes after every read/fetch. Complete-request resolution keeps at most the configured request budget plus one bounded item in memory and performs no provider upload/request until validation succeeds.
150
151
  - Media errors omit raw bytes/base64 payloads from messages.
@@ -115,6 +115,7 @@ Policy is optional. Hosts wire `record*` helpers or `evaluateAndAppend` at permi
115
115
  - Policy version pin fails closed on mismatch.
116
116
  - Unrestricted payload field names (`prompt`, `body`, `toolArguments`, …) are rejected before append.
117
117
  - Evaluate/append are O(fields) and network-free in-package; remote WORM I/O stays in the host sink/adapter.
118
+ - The OPA decision fetch (0.2.1) is DNS-pinned through the core `pinnedFetch` primitive: one resolve per request, every resolved address SSRF-checked before the connect (rebinding defense), redirects rejected outright, timeouts/retries unchanged, and private-answer denials surface `MediaContentError` (`ssrf_denied`) rather than a transport error.
118
119
  - Export never full-scans: page size is capped; raise hard caps only with Phase 8 freeze + tests + docs updates.
119
120
 
120
121
  ## OPA external policy adapter (`@arnilo/prism-policy/opa`, 0.0.28)
@@ -279,7 +279,10 @@ for (const sample of report.samples) {
279
279
  - Cardinality is bounded: beyond `maxKeys` distinct provider/model keys, excess
280
280
  keys accumulate in a single `__overflow__` bucket; memory cannot grow with
281
281
  hostile model names (`ponytail:` ceiling — upgrade to host-configurable caps
282
- or LRU eviction only if a real deployment exceeds it).
282
+ or LRU eviction only if a real deployment exceeds it). The `__overflow__`
283
+ bucket aggregates mixed provider/model tokens, so it never carries cost
284
+ metadata: `estimatedSavings`/`currency` are unset there and it reports
285
+ requests and token totals only.
283
286
  - `record()` is O(1) per usage event; `report()` is O(keys). No secrets or
284
287
  cache keys are accepted or stored.
285
288
 
@@ -137,10 +137,27 @@ export function tryParseJsonObjectArguments(
137
137
  text: string,
138
138
  options?: { toolName?: string; maxBytes?: number },
139
139
  ): { ok: true; value: JsonObject } | { ok: false; error: ProviderTransportError };
140
+
141
+ /** Read a success body with the same byte ceiling as `readBoundedResponseText`, then parse it as JSON
142
+ * with depth/property caps and an optional caller-supplied shape gate. Malformed JSON, over-limit
143
+ * nesting/width, or shape failure throw `ProviderTransportError` (code `response_body_shape`). */
144
+ export async function readBoundedResponseJson<T>(
145
+ response: Response,
146
+ options?: {
147
+ secrets?: readonly (string | undefined)[];
148
+ maxResponseBodyBytes?: number;
149
+ maxDepth?: number; // default 32
150
+ maxProperties?: number; // default 4_096 per object/array
151
+ shape?: (value: unknown) => boolean; // caller-supplied shape gate, fails closed
152
+ signal?: AbortSignal;
153
+ },
154
+ ): Promise<T>;
140
155
  ```
141
156
 
142
157
  **Performance:** Single pass over chunks; retained memory is `O(min(buffer, maxBufferBytes))`, not `O(stream)`. No full-stream accumulation.
143
158
 
159
+ **Security:** the bounded success-body reader (added in 0.2.1) caps every non-stream JSON response: a hard UTF-8 byte ceiling (`maxResponseBodyBytes`, default 65_536) that cancels the body before full buffering, a nesting depth cap (default 32), a per-container property/element cap (default 4_096), and a caller-supplied shape gate. Malformed JSON, over-limit nesting or width, and shape mismatches all fail closed with `ProviderTransportError` code `response_body_shape`; errors are static text and never embed body content or secrets. It replaces the former unbounded `response.json()` in all ten model-discovery implementations, NeuralWatt quota, Alibaba embeddings, OpenAI uploads (0.2.1 task 6), and the shared OAuth device/token flows (0.2.1 task 5); per-adapter post-parse validation stays as defense in depth.
160
+
144
161
  ### `@arnilo/prism/providers/openai` — **shipped**
145
162
 
146
163
  Import:
@@ -60,7 +60,7 @@ Register via `createExtensionKernel().load([createAzureOpenAIProviderPackage(...
60
60
 
61
61
  ## Security and performance notes
62
62
 
63
- - No credential prefetch at import; resolve per request.
63
+ - No credential prefetch at import; the credential is resolved exactly once per request (a rotating `CredentialValueSource` is never consumed twice — the same resolved token drives the wrapper check and the inner auth header).
64
64
  - Endpoint host is never rewritten to public DNS.
65
65
  - Errors redact credential values via shared transport helpers.
66
66
  - No Azure SDK dependency.
@@ -60,6 +60,7 @@ Uses Bedrock’s OpenAI-compatible runtime route (not Converse eventstream). Hos
60
60
  ## Security and performance notes
61
61
 
62
62
  - No AWS SDK; package-local SigV4 only for `bedrock` service.
63
+ - Input headers are normalized once before signing: names are lowercased and duplicate-case keys merge last-wins, so the canonical request always matches the signed header list (no duplicate-case mismatch); query parameters are canonicalized sorted by encoded key then value.
63
64
  - Private endpoint hosts are not rewritten to public DNS.
64
65
  - Credential secrets are redacted from provider errors.
65
66
  - No credential prefetch at import.
@@ -41,12 +41,12 @@ Options:
41
41
  | `onComment` | `(text) => ProviderEvent \| undefined` | Handle SSE comment lines (text after `:`), e.g. NeuralWatt `: energy` / `: cost` telemetry. Returned events are yielded in stream order. |
42
42
  | `extraHeaders` | `(request) => Record<string, string>` | Optional extra request headers; provider auth and `content-type` still win. |
43
43
  | `transformBody` | `(body, request) => JsonObject` | Optional final body transform, applied last (token limits, compat stripping); wins over everything. |
44
- | `strictCompletion` | `boolean` | Require `[DONE]` and a `finish_reason`; truncated streams yield an `error` and `done` carries the final usage. |
44
+ | `strictCompletion` | `boolean` | Require `[DONE]` **and** a `finish_reason` before emitting `done`; truncated streams yield an `error` and `done` carries the final usage. **Default `true`** (fail-closed); set `false` explicitly to accept streams that end without completion evidence — the documented downgrade whose risk the opting host owns. |
45
45
  | `requestFailedPrefix` | `string` | Prefix for HTTP error messages. Default `OpenAI-compatible request failed`. |
46
46
 
47
47
  The subpath also exports the building blocks for provider packages that keep public body/stream helpers:
48
48
 
49
- - `openAIChatEvents(body, { signal, strictCompletion, doneUsage, mapUsage, onComment })`: the shared SSE stream loop as an `AsyncIterable<ProviderEvent>`.
49
+ - `openAIChatEvents(body, { signal, strictCompletion, doneUsage, mapUsage, onComment })`: the shared SSE stream loop as an `AsyncIterable<ProviderEvent>`. `strictCompletion` defaults to `true` (see the Security notes).
50
50
  - `buildOpenAIChatBody(request, { mapMessages, serializeMessage, buildBodyExtra, transformBody })`: the base Chat Completions request body builder.
51
51
 
52
52
  Provider requests use the standard `ProviderRequest` shape: `model`, `messages`, optional `tools`, `metadata`, and `signal`.
@@ -62,7 +62,7 @@ The returned provider emits normalized `ProviderEvent` values:
62
62
  | streamed `tool_calls` fragments | `tool_call_delta` events. |
63
63
  | complete accumulated tool call | final `tool_call` event. |
64
64
  | `usage` | `usage` event. |
65
- | `[DONE]` or stream end | `done` event. |
65
+ | `[DONE]` + `finish_reason` | `done` event. A stream ending without either terminal variant yields an `error` instead (strict default). |
66
66
  | HTTP/stream/parsing error | `error` event with redacted `ErrorInfo`. |
67
67
 
68
68
  The adapter passes `request.signal` to `fetch` for abort propagation; an already-aborted signal throws before fetch.
@@ -146,6 +146,7 @@ const provider = createOpenAICompatibleProvider({
146
146
  - Redaction only removes known values supplied to the helper. Avoid logging raw provider requests/responses.
147
147
  - `fetch` receives the request `AbortSignal`.
148
148
  - SSE and HTTP error bodies are read through bounded `@arnilo/prism/providers/transport` helpers (`readSseData`, `readBoundedResponseText`) with configurable byte ceilings.
149
+ - **Strict completion is the shared default (since 0.2.1):** a chat stream is complete only when both `[DONE]` and a `finish_reason` were observed. EOF without either is treated as truncation and emits an `error` ("Chat stream ended without completion evidence"), never a successful `done` — a partial answer can no longer be mistaken for a completed one. Providers that legitimately omit one of the terminal markers must pass `strictCompletion: false` explicitly, accepting the truncation-detection downgrade. `done` carries the final stream usage when the stream was strict (or `doneUsage` is set).
149
150
  - Tests should use injected `fetch` and never make real network calls.
150
151
  - Tool-call arguments are accumulated as streamed text, parsed with `parseJsonObjectArguments` when the final tool call is emitted; empty argument text yields `{}`, malformed JSON yields an `error` event.
151
152
 
@@ -59,7 +59,7 @@ const provider = createVertexProvider({
59
59
 
60
60
  - No Google Cloud SDK dependency in the package.
61
61
  - Custom/private endpoint hosts are preserved.
62
- - Tokens redacted from errors; no import-time credential prefetch.
62
+ - Tokens redacted from errors; no import-time credential prefetch — the credential is resolved exactly once per request (a rotating `CredentialValueSource` is never consumed twice; the same resolved token drives the wrapper check and the inner auth header).
63
63
  - Pair with model-router residency allow-lists on `location`.
64
64
 
65
65
  ## Related APIs
@@ -274,6 +274,25 @@ git push origin v0.1.2 # tag push triggers release.yml publish job (prove
274
274
 
275
275
  **Rollback notes.** `release:publish --version 0.1.2 --resume --report release-artifacts/publish-report.json` resumes an interrupted publication and skips only registry versions whose internal dependency fingerprint matches the local manifest. A failed package aborts the run with its status written to the report; re-run after fixing the cause. npm cannot unpublish the `0.1.2` line after 72 hours — a post-publication defect ships as a `0.1.x` patch (additive-only compat promise, `release:gate` enforced), or as a documented break in the next line with a `docs/migration.md` entry. `0.1.2` is store-compatible with `0.1.1` in **both directions** (no migration ran — same checksum-protected contract), so an operator may defer or roll back the patch without a database rollback.
276
276
 
277
+ ### 0.2.1 publish handoff (plan 021 Task 8)
278
+
279
+ **Decision: GO when the operator prerequisites below are recorded.** Release **0.2.1** (plan 021) is the provider-completion and outbound-trust-boundaries cut on the 0.2.x review-remediation line. API surface **additive-only** vs 0.2.0 (plain reviewed compat gate at 0.2.1: the only deltas are the version literal and `@arnilo/prism-mcp` transport helpers `boundResponse`/`defaultResolver`/`isLoopbackAddress`/`isLoopbackHostname`/`normalizeHostname`/`raceAbort`/`requestPinned`/`resolvePinnedAddress` becoming re-exports of the lifted core primitives — same names/signatures, no removal; baselines regenerated with `--update-baseline`, no `--allow-break`; freeze manifest `scripts/phase21-freeze-manifest.json` machine-checks each task's diff and the preserved surface). Five documented security-motivated behavior tightenings in `docs/migration.md` `0.2.0 → 0.2.1`: (1) **strict stream completion is the shared default** (`strictCompletion: true` in `createOpenAICompatibleProvider`; explicit `false` stays the documented opt-out; truncated streams fail `ProviderTransportError` `incomplete_delta` instead of a successful `providerDone`; applies to Azure/Bedrock/Vertex/OpenRouter/ZAI/NeuralWatt); (2) **bounded success bodies** — additive `readBoundedResponseJson` (65,536-byte ceiling, depth 32, properties 4096, shape gate, abort, redacted errors, `response_body_shape` code) replaces unbounded `response.json()` on all ten model-discovery sites plus NeuralWatt quota, Alibaba embeddings, OpenAI uploads, and both OAuth success paths; (3) **DNS-pinned OIDC JWKS/OPA/content fetch, redirects rejected** — core `pinnedFetch` (one resolve, 1–32 bound, per-candidate SSRF validation, pinned-lookup socket) serves the default JWKS, OPA decision, and content/media paths; 3xx fails `MediaContentError` `redirect`; private/metadata/loopback fails `ssrf_denied`; MCP re-exports the lifted helpers byte-identically; (4) **shared bounded OAuth device/token polling** — core `pollDeviceCodeToken` serves provider-openai and credentials-node with equivalent cadence/backoff/redaction; (5) **edge fixes** — Azure/Vertex credential-once, Bedrock duplicate-case/repeated-query SigV4 canonicalization, OpenAI upload failed-DELETE retention, cache `__overflow__` tokens-only. New regression surface: `scripts/phase21-security.test.mjs` (10 conformance tests over built public entrypoints, wired into `security:threat-suites`) and a packed plain-JS `security21.mjs` consumer in install-smoke. Store compatibility with 0.2.0: **compatible, no migration**. Exit gate green: npm test core + script gates (incl. phase21-freeze done-phase), `sdk:ready` exit 0, audit 0 moderate, pack dry-run 50/50 twice byte-identical, plain reviewed compat gate at 0.2.1, live OIDC JWKS + live OPA protected evidence; evidence in `scripts/phase21-baseline.json` `exitGate`. Rollback = restore the 0.2.0 manifests/tag — but rollback restores the five boundary gaps, so hosts should disable truncated-stream acceptance, unbounded-body endpoints, redirect-following fetches, rotating-credential reuse, and upload cleanup at their own boundary if rollback is unavoidable.
280
+
281
+ ```bash
282
+ # Operator prerequisites recorded: clean tree at the v0.2.1 tag candidate, GPG key, npm OIDC publisher.
283
+ npm test # core + workspace suites + all script gates
284
+ npm run security:threat-suites # phase8-11 + phase20 + phase21 public-entry conformance
285
+ npm run sdk:ready # typecheck, lint, format, test, coverage, pack, release:gate
286
+ node scripts/release.mjs gate --version 0.2.1 # plain reviewed additive gate, 0 breaking deltas
287
+ npm run pack:dry-run # twice; diff reports — deterministic
288
+ npm audit --audit-level=moderate
289
+ npm run release:check -- --version 0.2.1 --report /tmp/prism-0.2.1-preflight.json
290
+ npm run release:publish -- --version 0.2.1 --dry-run --allow-dirty --allow-untagged --report /tmp/prism-0.2.1-dry-run.json
291
+ # run the dry-run twice and diff the reports: deterministic, byte-identical
292
+ ```
293
+
294
+ Protected evidence (never a passing skip): live OIDC JWKS through the default pinned path (`createOidcIdentityVerifier` against a real public IdP — real DNS/TLS/JWKS document, e.g. `https://login.microsoftonline.com/common/discovery/v2.0/keys`, success proven by a key-lookup miss after a 200 fetch) and live OPA (`docker run -p 127.0.0.1:8181:8181 openpolicyagent/opa run --server`, push a policy, then prove the default pinned path fails closed `ssrf_denied` against the real server; decision-success behavior is covered by the built public conformance suite since the pinned path refuses private addresses by design). Missing protected evidence records 0.2.1 as **blocked**, never a passing skip.
295
+
277
296
  ### 0.2.0 publish handoff (plan 020 Task 6)
278
297
 
279
298
  **Decision: GO when the operator prerequisites below are recorded.** Release **0.2.0** (plan 020) is the first cut of the 0.2.x review-remediation line — fail-closed runtime and sandbox security. API surface **additive-only** vs 0.1.7 (plain compat gate at 0.2.0: 0 breaking declaration deltas — the three blockers are behavior tightenings, not removals; `containmentClaim` retained deprecated; baseline text regenerated with `--update-baseline`, no `--allow-break` anywhere; freeze manifest `scripts/phase20-freeze-manifest.json` machine-checks each task's diff stayed inside its allowed files). Shipped: (1) **durable-resume input validation** — `assertValidAgentRunResume` at the top of `prepareAgentRunResume` covers all four public resume entrypoints; unknown legacy decisions (`"sideways"`), malformed batches, oversized reasons/elicitation, duplicate approval ids fail closed `ERR_PRISM_DECISION_*` with zero checkpoint writes/tool calls (server parser stays defense in depth); (2) **work-tool environment isolation** — `createCliRunner` children get a fixed base allow-list + explicit env + forced HOME/telemetry controls + late-bound per-identity tokens, 64-name/64-KiB caps `ERR_PRISM_WORK_ENV`, absolute binary/configDir, linear output capture; (3) **explicit sandbox capabilities** — `SandboxAdapter.capabilities` (six immutable booleans, omission/malformed ⇒ all false), composition capabilities from verified wiring, `containmentClaim` deprecated as the conservative projection; Docker reports only verified controls, native reports filesystem/process/privilege false; docs/coding-security.md capability table, docs/host-security.md authorization guidance. New regression surface: `scripts/phase20-security.test.mjs` (public built entrypoints, wired into `security:threat-suites`), packed plain-JS consumer regressions in install-smoke, and the sandbox-browser workflow's fail-loud 0.2.0 blocker gate recording Docker/native capability evidence — **0.2.0 does not ship while any blocker is skipped**. Store compatibility with 0.1.7: **compatible, no migration** (no persisted-shape change; `docs/migration.md` `0.1.7 → 0.2.0` section). Exit gate green: npm test core + script gates (incl. phase20-freeze done-phase), `sdk:ready` exit 0, audit 0 moderate, pack dry-run 50/50 twice byte-identical, plain reviewed compat gate at 0.2.0, Docker daemon + native netns protected evidence; evidence in `scripts/phase20-baseline.json` `exitGate`. Rollback = restore the 0.1.7 manifests/tag — but rollback restores the three defects, so hosts should disable resume side effects and work-tool execution at their own boundary if rollback is unavoidable.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arnilo/prism",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Agent harness for AI providers, agents, sessions, and tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -143,7 +143,7 @@
143
143
  "build": "npm run build:core && npm run build --workspaces --if-present",
144
144
  "typecheck": "npm run build && npm run typecheck --workspaces --if-present && tsc -p examples --noEmit",
145
145
  "sweep:unused": "node scripts/sweep-unused.mjs",
146
- "test": "npm run build && node --test dist/__tests__/*.test.js && node --test scripts/release-gate.test.mjs scripts/tooling-gate.test.mjs scripts/budget-gate.test.mjs scripts/phase8-conformance.test.mjs scripts/phase9-conformance.test.mjs scripts/phase10-conformance.test.mjs scripts/phase11-conformance.test.mjs scripts/phase11-freeze.test.mjs scripts/phase12-freeze.test.mjs scripts/phase13-freeze.test.mjs scripts/phase14-freeze.test.mjs scripts/phase15-freeze.test.mjs scripts/phase16-freeze.test.mjs scripts/phase17-freeze.test.mjs scripts/phase18-freeze.test.mjs scripts/phase19-freeze.test.mjs scripts/phase20-freeze.test.mjs scripts/benchmark-0.1.0.test.mjs scripts/sweep-unused.test.mjs scripts/e2e-enterprise-journey.test.mjs scripts/e2e-coding-journey.test.mjs && npm run test --workspaces --if-present",
146
+ "test": "npm run build && node --test dist/__tests__/*.test.js && node --test scripts/release-gate.test.mjs scripts/tooling-gate.test.mjs scripts/budget-gate.test.mjs scripts/phase8-conformance.test.mjs scripts/phase9-conformance.test.mjs scripts/phase10-conformance.test.mjs scripts/phase11-conformance.test.mjs scripts/phase11-freeze.test.mjs scripts/phase12-freeze.test.mjs scripts/phase13-freeze.test.mjs scripts/phase14-freeze.test.mjs scripts/phase15-freeze.test.mjs scripts/phase16-freeze.test.mjs scripts/phase17-freeze.test.mjs scripts/phase18-freeze.test.mjs scripts/phase19-freeze.test.mjs scripts/phase20-freeze.test.mjs scripts/phase21-freeze.test.mjs scripts/benchmark-0.1.0.test.mjs scripts/sweep-unused.test.mjs scripts/e2e-enterprise-journey.test.mjs scripts/e2e-coding-journey.test.mjs && npm run test --workspaces --if-present",
147
147
  "test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=60 --test-coverage-functions=70 --test-coverage-branches=75 --test-coverage-exclude='**/__tests__/**' --test-coverage-exclude='**/node_modules/**' --test-coverage-exclude='**/scripts/**' --test-coverage-exclude='**/packages/**' --test-coverage-exclude='**/examples/**' dist/__tests__/*.test.js && node scripts/coverage-summary.mjs",
148
148
  "coverage:summary": "node scripts/coverage-summary.mjs",
149
149
  "lint": "biome lint .",
@@ -156,7 +156,7 @@
156
156
  "release:publish": "node scripts/release.mjs publish",
157
157
  "sdk:ready": "npm run typecheck && npm run lint && npm run format:check && npm test && npm run test:coverage && npm run pack:dry-run && npm run release:gate",
158
158
  "release:gate": "node scripts/release.mjs gate",
159
- "security:threat-suites": "node --test scripts/phase8-conformance.test.mjs scripts/phase9-conformance.test.mjs scripts/phase10-conformance.test.mjs scripts/phase11-conformance.test.mjs scripts/phase20-security.test.mjs"
159
+ "security:threat-suites": "node --test scripts/phase8-conformance.test.mjs scripts/phase9-conformance.test.mjs scripts/phase10-conformance.test.mjs scripts/phase11-conformance.test.mjs scripts/phase20-security.test.mjs scripts/phase21-security.test.mjs"
160
160
  },
161
161
  "devDependencies": {
162
162
  "@biomejs/biome": "^2.5.5",