@arnilo/prism 0.5.6 → 0.6.0

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.
Files changed (64) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +10 -10
  3. package/dist/agent-approval.js +7 -6
  4. package/dist/agent-loops.js +51 -12
  5. package/dist/agent-session/session.d.ts +1 -0
  6. package/dist/agent-session/session.js +20 -2
  7. package/dist/agent-tool-dispatch.js +5 -4
  8. package/dist/content.d.ts +3 -16
  9. package/dist/content.js +9 -99
  10. package/dist/context-budget.d.ts +12 -1
  11. package/dist/context-budget.js +42 -19
  12. package/dist/contracts-core/agent.d.ts +11 -0
  13. package/dist/contracts-core/agent.js +4 -1
  14. package/dist/index.d.ts +4 -4
  15. package/dist/index.js +3 -3
  16. package/dist/input.d.ts +6 -0
  17. package/dist/input.js +12 -1
  18. package/dist/media-types.d.ts +34 -0
  19. package/dist/media-types.js +158 -0
  20. package/dist/pinned-fetch.d.ts +2 -2
  21. package/dist/pinned-fetch.js +11 -12
  22. package/dist/redaction.js +74 -1
  23. package/dist/session-stores.d.ts +11 -0
  24. package/dist/session-stores.js +23 -8
  25. package/docs/acp.md +1 -1
  26. package/docs/ag-ui.md +4 -2
  27. package/docs/agent-events.md +2 -0
  28. package/docs/agent-loops.md +1 -1
  29. package/docs/agent-session-runtime.md +3 -1
  30. package/docs/browser-automation.md +5 -2
  31. package/docs/contributing.md +37 -0
  32. package/docs/core.md +2 -0
  33. package/docs/document-reader.md +2 -0
  34. package/docs/documents.md +1 -1
  35. package/docs/graft.md +3 -1
  36. package/docs/history/release-handoffs.md +33 -0
  37. package/docs/host-security.md +2 -2
  38. package/docs/index.md +27 -14
  39. package/docs/input-and-prompt-assembly.md +4 -4
  40. package/docs/language-intelligence.md +1 -1
  41. package/docs/migrate-to-0.5.md +7 -2
  42. package/docs/migrate-to-0.6.md +89 -0
  43. package/docs/migration.md +30 -0
  44. package/docs/model-registry.md +1 -1
  45. package/docs/multimodal-content.md +1 -1
  46. package/docs/obscura.md +3 -1
  47. package/docs/options-index.md +286 -0
  48. package/docs/peer-dependencies.md +94 -0
  49. package/docs/performance.md +34 -2
  50. package/docs/ponytail.md +2 -0
  51. package/docs/postgres-persistence.md +3 -1
  52. package/docs/provider-conformance.md +1 -1
  53. package/docs/provider-packages.md +21 -21
  54. package/docs/provider-primitives.md +2 -1
  55. package/docs/providers/ai-sdk.md +5 -2
  56. package/docs/public-contracts.md +2 -2
  57. package/docs/release-and-install.md +75 -55
  58. package/docs/server.md +1 -1
  59. package/docs/session-stores.md +3 -1
  60. package/docs/sqlite-persistence.md +2 -0
  61. package/docs/testing.md +38 -0
  62. package/docs/tools.md +1 -1
  63. package/docs/wiki.md +1 -1
  64. package/package.json +5 -5
@@ -22,8 +22,8 @@ export function estimateTextTokens(text) {
22
22
  export function estimateTextBytes(text) {
23
23
  return Buffer.byteLength(text, "utf8");
24
24
  }
25
- export function estimateMessageTokens(message) {
26
- return estimateTextTokens(messageText(message));
25
+ export function estimateMessageTokens(message, estimateTokens = estimateTextTokens) {
26
+ return estimateTokens(messageText(message));
27
27
  }
28
28
  export function estimateMessageBytes(message) {
29
29
  return estimateTextBytes(messageText(message));
@@ -41,6 +41,9 @@ export function resolveContextBudget(budget) {
41
41
  assertPositiveCap(budget.maxInputTokens, "maxInputTokens", HARD_MAX_CONTEXT_BUDGET_TOKENS);
42
42
  if (hasBytes)
43
43
  assertPositiveCap(budget.maxInputBytes, "maxInputBytes", HARD_MAX_CONTEXT_BUDGET_BYTES);
44
+ if (budget.tokenEstimator !== undefined && typeof budget.tokenEstimator !== "function") {
45
+ throw new TypeError("contextBudget.tokenEstimator must be a function");
46
+ }
44
47
  return { ...budget, reportOmissions: budget.reportOmissions === true };
45
48
  }
46
49
  export function getContextBudgetReport(request) {
@@ -49,6 +52,7 @@ export function getContextBudgetReport(request) {
49
52
  }
50
53
  export function applyContextBudget(options) {
51
54
  const budget = resolveContextBudget(options.budget);
55
+ const estimateTokens = resolveTokenEstimator(budget);
52
56
  const layout = options.layout ?? "cache_aware";
53
57
  const groups = {
54
58
  instructions: [...options.groups.instructions],
@@ -67,9 +71,9 @@ export function applyContextBudget(options) {
67
71
  const historyCursor = { index: 0 };
68
72
  // Measure once, then subtract each dropped item's own estimate (dropNext computes it
69
73
  // with the same estimators) — avoids an O(n²) re-scan of the full keep-set per drop.
70
- const kept = measureAll(groups, context, skills, tools, skillContext, demotedBodies);
74
+ const kept = measureAll(groups, context, skills, tools, skillContext, demotedBodies, estimateTokens);
71
75
  while (overBudget(kept, budget)) {
72
- const drop = dropNext(groups, context, skills, layout, skillContext, demotedBodies, historyCursor);
76
+ const drop = dropNext(groups, context, skills, layout, skillContext, demotedBodies, historyCursor, estimateTokens);
73
77
  if (!drop) {
74
78
  throw new ContextBudgetError();
75
79
  }
@@ -97,7 +101,7 @@ export function applyContextBudget(options) {
97
101
  },
98
102
  };
99
103
  }
100
- function dropNext(groups, context, skills, layout, skillContext, demotedBodies, historyCursor) {
104
+ function dropNext(groups, context, skills, layout, skillContext, demotedBodies, historyCursor, estimateTokens) {
101
105
  // ponytail: drop droppable groups in layout order; within history, advance a cursor and slice once.
102
106
  // cache_aware keeps attachments longer so stable prefix stays intact while budget still allows it.
103
107
  const order = layout === "cache_aware"
@@ -106,19 +110,19 @@ function dropNext(groups, context, skills, layout, skillContext, demotedBodies,
106
110
  for (const kind of order) {
107
111
  if (kind === "tool_results" && groups.toolResults.length > 0) {
108
112
  const message = groups.toolResults.pop();
109
- return omission("tool_results", message.id ?? toolResultId(message), message);
113
+ return omission("tool_results", message.id ?? toolResultId(message), message, estimateTokens);
110
114
  }
111
115
  if (kind === "history" && historyCursor.index < groups.history.length) {
112
116
  const message = groups.history[historyCursor.index++];
113
- return omission("history", message.id, message);
117
+ return omission("history", message.id, message, estimateTokens);
114
118
  }
115
119
  if (kind === "summaries" && groups.summaries.length > 0) {
116
120
  const message = groups.summaries.pop();
117
- return omission("summaries", message.id, message);
121
+ return omission("summaries", message.id, message, estimateTokens);
118
122
  }
119
123
  if (kind === "attachments" && groups.attachments.length > 0) {
120
124
  const message = groups.attachments.pop();
121
- return omission("attachments", message.id, message);
125
+ return omission("attachments", message.id, message, estimateTokens);
122
126
  }
123
127
  if (kind === "context" && context.length > 0) {
124
128
  const index = pickVictimIndex(context, (block) => block.priority ?? 0);
@@ -127,7 +131,7 @@ function dropNext(groups, context, skills, layout, skillContext, demotedBodies,
127
131
  return {
128
132
  kind: "context",
129
133
  id: block.id ?? block.title,
130
- tokenEstimate: estimateTextTokens(text),
134
+ tokenEstimate: estimateTokens(text),
131
135
  byteLength: estimateTextBytes(text),
132
136
  };
133
137
  }
@@ -142,7 +146,7 @@ function dropNext(groups, context, skills, layout, skillContext, demotedBodies,
142
146
  return {
143
147
  kind: "skill_body",
144
148
  id: skill.name,
145
- tokenEstimate: estimateTextTokens(beforeText) - estimateTextTokens(afterText),
149
+ tokenEstimate: estimateTokens(beforeText) - estimateTokens(afterText),
146
150
  byteLength: estimateTextBytes(beforeText) - estimateTextBytes(afterText),
147
151
  };
148
152
  }
@@ -152,31 +156,50 @@ function dropNext(groups, context, skills, layout, skillContext, demotedBodies,
152
156
  return {
153
157
  kind: "skills",
154
158
  id: skill.name,
155
- tokenEstimate: estimateTextTokens(text),
159
+ tokenEstimate: estimateTokens(text),
156
160
  byteLength: estimateTextBytes(text),
157
161
  };
158
162
  }
159
163
  }
160
164
  return undefined;
161
165
  }
162
- function omission(kind, id, message) {
166
+ function omission(kind, id, message, estimateTokens) {
163
167
  return {
164
168
  kind,
165
169
  id,
166
- tokenEstimate: estimateMessageTokens(message),
170
+ tokenEstimate: estimateMessageTokens(message, estimateTokens),
167
171
  byteLength: estimateMessageBytes(message),
168
172
  };
169
173
  }
174
+ /**
175
+ * Resolves the budget's estimator, validating each return value: a host estimator that
176
+ * yields NaN/negative/non-finite tokens would make every eviction decision unsound, so it
177
+ * fails the assembly closed instead of silently keeping or dropping the wrong content.
178
+ */
179
+ function resolveTokenEstimator(budget) {
180
+ const estimator = budget.tokenEstimator;
181
+ if (estimator === undefined)
182
+ return estimateTextTokens;
183
+ if (typeof estimator !== "function")
184
+ throw new TypeError("contextBudget.tokenEstimator must be a function");
185
+ return (text) => {
186
+ const tokens = estimator(text);
187
+ if (typeof tokens !== "number" || !Number.isFinite(tokens) || tokens < 0) {
188
+ throw new TypeError("contextBudget.tokenEstimator must return a non-negative finite number of tokens");
189
+ }
190
+ return tokens;
191
+ };
192
+ }
170
193
  function toolResultId(message) {
171
194
  const block = message.content.find((part) => part.type === "tool_result");
172
195
  return block && block.type === "tool_result" ? block.toolCallId : undefined;
173
196
  }
174
- function measureAll(groups, context, skills, tools, skillContext, demotedBodies) {
197
+ function measureAll(groups, context, skills, tools, skillContext, demotedBodies, estimateTokens) {
175
198
  const renderContext = withDemoted(skillContext, demotedBodies);
176
199
  let tokens = 0;
177
200
  let bytes = 0;
178
201
  const addMessage = (message) => {
179
- tokens += estimateMessageTokens(message);
202
+ tokens += estimateMessageTokens(message, estimateTokens);
180
203
  bytes += estimateMessageBytes(message);
181
204
  };
182
205
  for (const message of groups.instructions)
@@ -193,17 +216,17 @@ function measureAll(groups, context, skills, tools, skillContext, demotedBodies)
193
216
  addMessage(message);
194
217
  for (const block of context) {
195
218
  const text = `${block.title ? `${block.title}:\n` : "Context:\n"}${contextBlockText(block)}`;
196
- tokens += estimateTextTokens(text);
219
+ tokens += estimateTokens(text);
197
220
  bytes += estimateTextBytes(text);
198
221
  }
199
222
  for (const skill of skills) {
200
223
  const text = skillPromptText(skill, renderContext) ?? "";
201
- tokens += estimateTextTokens(text);
224
+ tokens += estimateTokens(text);
202
225
  bytes += estimateTextBytes(text);
203
226
  }
204
227
  if (tools?.length) {
205
228
  const text = `Available tools:\n${tools.map((tool) => `- ${tool.name}${tool.description ? `: ${tool.description}` : ""}`).join("\n")}`;
206
- tokens += estimateTextTokens(text);
229
+ tokens += estimateTokens(text);
207
230
  bytes += estimateTextBytes(text);
208
231
  }
209
232
  return { tokens, bytes };
@@ -127,7 +127,18 @@ export interface AgentSessionConfig {
127
127
  readonly store?: SessionStore;
128
128
  readonly leafId?: string;
129
129
  readonly metadata?: Readonly<Record<string, unknown>>;
130
+ /**
131
+ * TTL of the in-memory `session.snapshot()` branch cache in milliseconds.
132
+ * Default `DEFAULT_SNAPSHOT_CACHE_TTL_MS`; `0` disables the cache (every snapshot read
133
+ * rebuilds from the store); at most `HARD_MAX_SNAPSHOT_CACHE_TTL_MS`. The cache is always
134
+ * invalidated by a new leaf or a mutation, so the TTL only bounds staleness-free reuse.
135
+ */
136
+ readonly snapshotCacheTtlMs?: number;
130
137
  }
138
+ /** Default `session.snapshot()` branch-cache TTL (milliseconds). */
139
+ export declare const DEFAULT_SNAPSHOT_CACHE_TTL_MS = 1000;
140
+ /** Upper bound for `AgentSessionConfig.snapshotCacheTtlMs` (milliseconds). */
141
+ export declare const HARD_MAX_SNAPSHOT_CACHE_TTL_MS = 30000;
131
142
  export interface AgentSessionForkOptions {
132
143
  readonly leafId?: string;
133
144
  }
@@ -1,6 +1,9 @@
1
1
  /** Contracts-core agent family (0.2.5 plan 025 Task 1 split).
2
2
  * Moved verbatim from contracts-core.ts; public surface unchanged behind the barrel. */
3
- export {};
3
+ /** Default `session.snapshot()` branch-cache TTL (milliseconds). */
4
+ export const DEFAULT_SNAPSHOT_CACHE_TTL_MS = 1_000;
5
+ /** Upper bound for `AgentSessionConfig.snapshotCacheTtlMs` (milliseconds). */
6
+ export const HARD_MAX_SNAPSHOT_CACHE_TTL_MS = 30_000;
4
7
  /** Directory-name spelling for discovered contribution kinds. Maps to a
5
8
  * {@link ManifestContributionDeclaration} kind for non-skill kinds:
6
9
  * `context` → `contextProvider`, `instructions` → `systemPromptContribution`. */
package/dist/index.d.ts CHANGED
@@ -23,11 +23,11 @@ export type { ConfigLayer, ConfigLoadContext, ConfigProvider } from "./config.js
23
23
  export { assertJsonObject, isJsonObject, loadConfigLayers, mergeConfigLayers } from "./config.js";
24
24
  export type { AudioContent, DocumentContent, FileContent, MediaContentBlock, MediaContentBounds, MediaHostAddress, MediaHostnameResolver, MediaMimePolicy, MediaUrlRequest, MediaUrlRequester, ModelInputCapability, ResolvedMediaContent, ResolveMediaContentOptions, SsrfPolicy, } from "./content.js";
25
25
  export { assertDeclaredMediaTypeMatches, assertMediaBlocksWithinBounds, assertMessagesSupportModelCapabilities, assertModelSupportsContentBlocks, assertSsrfAllowedUrl, collectMessageContentBlocks, contentBlockInputModality, DEFAULT_MAX_AUDIO_DURATION_MS, DEFAULT_MAX_MEDIA_ITEM_BYTES, DEFAULT_MAX_MEDIA_ITEMS_PER_REQUEST, DEFAULT_MAX_MEDIA_REQUEST_BYTES, DEFAULT_MEDIA_FETCH_TIMEOUT_MS, loadBoundedBinaryResource, MediaContentError, MODEL_INPUT_CAPABILITIES, resolveMediaContentBlock, resolveMediaContentBlocks, sniffMediaMimeType, UnsupportedModalityError, } from "./content.js";
26
- export type { ContextBudget, ContextBudgetMessageGroups, ContextBudgetOmission, ContextBudgetOmissionKind, ContextBudgetReport, } from "./context-budget.js";
26
+ export type { ContextBudget, ContextBudgetMessageGroups, ContextBudgetOmission, ContextBudgetOmissionKind, ContextBudgetReport, TokenEstimator, } from "./context-budget.js";
27
27
  export { applyContextBudget, CONTEXT_BUDGET_ERROR_CODE, CONTEXT_BUDGET_REPORT_METADATA_KEY, ContextBudgetError, DEFAULT_MAX_CONTEXT_BUDGET_OMISSIONS, estimateAssemblyTokens, estimateMessageBytes, estimateMessageTokens, estimateTextBytes, estimateTextTokens, getContextBudgetReport, HARD_MAX_CONTEXT_BUDGET_BYTES, HARD_MAX_CONTEXT_BUDGET_OMISSIONS, HARD_MAX_CONTEXT_BUDGET_TOKENS, isContextBudgetError, resolveContextBudget, } from "./context-budget.js";
28
28
  export type * from "./contracts.js";
29
29
  export type { ApprovalOutcome, DecisionScope, NestedRunApproval, NestedRunOutcome, NestedRunRef, PendingDecision, PendingDecisionKind, ProviderResolver, RealtimeCaps, RealtimeEvent, RealtimeSession, RealtimeSessionFactory, RealtimeSessionOptions, ResumeNestedRun, RunDecision, RunLimitCounters, RunLimitName, SecureAgentOptions, StickyDecision, ToolCallAuthority, ToolEffectClassifier, ToolEffectDeclaration, ToolEffectIdempotency, ToolEffectKey, ToolEffectKind, ToolEffectRecord, ToolEffectStatus, ToolEffectStore, ToolEffectTransition, ToolElicitationRequest, } from "./contracts.js";
30
- export { AgentDecisionError, AgentDelegationSuspendedError, AgentLoopStateError, AgentRunError, AgentRunStateError, assertBatchJobsSupported, assertEmbeddingsSupported, assertImageGenerationSupported, assertModerationSupported, assertSessionMetadataKey, assertSpeechSupported, assertTranscriptionSupported, assertVideoGenerationSupported, BatchJobsError, DEFAULT_MAX_PENDING_DECISIONS, DEFAULT_MAX_PENDING_STEER_BYTES, DEFAULT_MAX_PENDING_STEERS, DEFAULT_MAX_SESSION_SEARCH_CURSOR_BYTES, DEFAULT_MAX_SESSION_SEARCH_FTS_CANDIDATES, DEFAULT_MAX_SESSION_SEARCH_LINEAR_BYTES, DEFAULT_MAX_SESSION_SEARCH_LINEAR_ENTRIES, DEFAULT_MAX_SESSION_SEARCH_LINEAR_SESSIONS, DEFAULT_MAX_SESSION_SEARCH_QUERY_BYTES, DEFAULT_MAX_SESSION_SEARCH_SNIPPET_BYTES, DEFAULT_MAX_STICKY_DECISIONS, DEFAULT_SESSION_SEARCH_LIMIT, EmbeddingsError, HARD_MAX_ACTION_CONSTRAINT_BYTES, HARD_MAX_ACTION_CONSTRAINTS, HARD_MAX_DECISION_REASON_BYTES, HARD_MAX_ELICITATION_BYTES, HARD_MAX_PENDING_DECISIONS, HARD_MAX_PENDING_STEER_BYTES, HARD_MAX_PENDING_STEERS, HARD_MAX_SESSION_SEARCH_CURSOR_BYTES, HARD_MAX_SESSION_SEARCH_FTS_CANDIDATES, HARD_MAX_SESSION_SEARCH_LIMIT, HARD_MAX_SESSION_SEARCH_LINEAR_BYTES, HARD_MAX_SESSION_SEARCH_LINEAR_ENTRIES, HARD_MAX_SESSION_SEARCH_LINEAR_SESSIONS, HARD_MAX_SESSION_SEARCH_QUERY_BYTES, HARD_MAX_SESSION_SEARCH_SNIPPET_BYTES, HARD_MAX_STICKY_DECISIONS, ImageGenerationError, isBatchJobTerminal, isSessionAppendConflict, isSessionEntryKind, isSessionMetadataConflict, isSessionSearchUnsupported, MAX_ACTION_CONSTRAINT_BYTES, MAX_ACTION_CONSTRAINTS, MAX_ATTRIBUTION_DEPTH, MAX_DECISION_REASON_BYTES, MAX_ELICITATION_BYTES, ModerationError, modelSupportsBatchJobs, modelSupportsEmbeddings, modelSupportsImageGeneration, modelSupportsModeration, modelSupportsSpeech, modelSupportsTranscription, modelSupportsVideoGeneration, pollBatch, resolveSessionSearchQuery, SESSION_APPEND_CONFLICT_CODE, SESSION_ENTRY_KINDS, SESSION_ENTRY_SCHEMA_VERSION, SESSION_METADATA_CONFLICT_CODE, SESSION_SEARCH_UNSUPPORTED_CODE, SESSION_SEARCH_WORKSPACE_METADATA_KEY, SessionAppendConflictError, SessionMetadataConflictError, SessionSearchUnsupportedError, SpeechError, TranscriptionError, VideoGenerationError, } from "./contracts.js";
30
+ export { AgentDecisionError, AgentDelegationSuspendedError, AgentLoopStateError, AgentRunError, AgentRunStateError, assertBatchJobsSupported, assertEmbeddingsSupported, assertImageGenerationSupported, assertModerationSupported, assertSessionMetadataKey, assertSpeechSupported, assertTranscriptionSupported, assertVideoGenerationSupported, BatchJobsError, DEFAULT_MAX_PENDING_DECISIONS, DEFAULT_MAX_PENDING_STEER_BYTES, DEFAULT_MAX_PENDING_STEERS, DEFAULT_MAX_SESSION_SEARCH_CURSOR_BYTES, DEFAULT_MAX_SESSION_SEARCH_FTS_CANDIDATES, DEFAULT_MAX_SESSION_SEARCH_LINEAR_BYTES, DEFAULT_MAX_SESSION_SEARCH_LINEAR_ENTRIES, DEFAULT_MAX_SESSION_SEARCH_LINEAR_SESSIONS, DEFAULT_MAX_SESSION_SEARCH_QUERY_BYTES, DEFAULT_MAX_SESSION_SEARCH_SNIPPET_BYTES, DEFAULT_MAX_STICKY_DECISIONS, DEFAULT_SESSION_SEARCH_LIMIT, DEFAULT_SNAPSHOT_CACHE_TTL_MS, EmbeddingsError, HARD_MAX_ACTION_CONSTRAINT_BYTES, HARD_MAX_ACTION_CONSTRAINTS, HARD_MAX_DECISION_REASON_BYTES, HARD_MAX_ELICITATION_BYTES, HARD_MAX_PENDING_DECISIONS, HARD_MAX_PENDING_STEER_BYTES, HARD_MAX_PENDING_STEERS, HARD_MAX_SESSION_SEARCH_CURSOR_BYTES, HARD_MAX_SESSION_SEARCH_FTS_CANDIDATES, HARD_MAX_SESSION_SEARCH_LIMIT, HARD_MAX_SESSION_SEARCH_LINEAR_BYTES, HARD_MAX_SESSION_SEARCH_LINEAR_ENTRIES, HARD_MAX_SESSION_SEARCH_LINEAR_SESSIONS, HARD_MAX_SESSION_SEARCH_QUERY_BYTES, HARD_MAX_SESSION_SEARCH_SNIPPET_BYTES, HARD_MAX_SNAPSHOT_CACHE_TTL_MS, HARD_MAX_STICKY_DECISIONS, ImageGenerationError, isBatchJobTerminal, isSessionAppendConflict, isSessionEntryKind, isSessionMetadataConflict, isSessionSearchUnsupported, MAX_ACTION_CONSTRAINT_BYTES, MAX_ACTION_CONSTRAINTS, MAX_ATTRIBUTION_DEPTH, MAX_DECISION_REASON_BYTES, MAX_ELICITATION_BYTES, ModerationError, modelSupportsBatchJobs, modelSupportsEmbeddings, modelSupportsImageGeneration, modelSupportsModeration, modelSupportsSpeech, modelSupportsTranscription, modelSupportsVideoGeneration, pollBatch, resolveSessionSearchQuery, SESSION_APPEND_CONFLICT_CODE, SESSION_ENTRY_KINDS, SESSION_ENTRY_SCHEMA_VERSION, SESSION_METADATA_CONFLICT_CODE, SESSION_SEARCH_UNSUPPORTED_CODE, SESSION_SEARCH_WORKSPACE_METADATA_KEY, SessionAppendConflictError, SessionMetadataConflictError, SessionSearchUnsupportedError, SpeechError, TranscriptionError, VideoGenerationError, } from "./contracts.js";
31
31
  export { parseAgentFile, parseSkillFile } from "./contribution-parsing.js";
32
32
  export type { ContributionRegistries, ContributionRegistriesOptions, ContributionRegistry, ContributionRegistryOptions, } from "./contributions.js";
33
33
  export { createContributionRegistries, createContributionRegistry, registerDiscoveredContributions } from "./contributions.js";
@@ -54,7 +54,7 @@ export { assertGuardrailsAllowed, GuardrailError, MAX_GUARDRAIL_CONCURRENCY, run
54
54
  export type { AgentIdentity, AssertIdentityActiveOptions, IdentityLimits, IdentityVerifier, NarrowIdentityOptions, Principal, ResolvedIdentityLimits, } from "./identity.js";
55
55
  export { assertIdentityActive, assertIdentityMatchesOwnership, assertIdentityPropagation, DEFAULT_IDENTITY_LIMITS, HARD_IDENTITY_LIMITS, IdentityError, identityTelemetryAttributes, narrowIdentity, ownershipFromIdentity, resolveIdentityLimits, resolveRunIdentity, } from "./identity.js";
56
56
  export type { AgentInput, AssembleProviderInputOptions, DefaultInputBuildContext, DefaultInputBuilder, DefaultPromptBuilder, InputAttachment, PromptInstruction, PromptTemplateOptions, ResolveContextOptions, } from "./input.js";
57
- export { assembleProviderInput, createDefaultInputBuilder, createDefaultPromptBuilder, renderPromptTemplate, resolveContextProviders, } from "./input.js";
57
+ export { assembleProviderInput, createDefaultInputBuilder, createDefaultPromptBuilder, EMPTY_TOOL_RESULT_TEXT, renderPromptTemplate, resolveContextProviders, } from "./input.js";
58
58
  export type { ResolveInstructionInjectorsOptions } from "./instruction-injection.js";
59
59
  export { resolveInstructionInjectors, runInstructionInjectors } from "./instruction-injection.js";
60
60
  export { createMemoryLeaseStore, LEASE_CONFLICT_CODE, LeaseConflictError } from "./leases.js";
@@ -119,5 +119,5 @@ export { trimTrailingSlashes } from "./trim-trailing-slashes.js";
119
119
  export type { ResolvedUseCaseModel, ResolveUseCaseModelInput, UseCaseModelBinding, } from "./use-case-model.js";
120
120
  export { resolveUseCaseModel, resolveUseCaseModelBinding, useCaseCredentialProviderId, } from "./use-case-model.js";
121
121
  export declare const name = "prism";
122
- export declare const version = "0.5.6";
122
+ export declare const version = "0.6.0";
123
123
  export declare const description = "Agent harness for AI providers, agents, sessions, and tools.";
package/dist/index.js CHANGED
@@ -13,7 +13,7 @@ export { createDefaultCompactionStrategy, isCompactionEntryData } from "./compac
13
13
  export { assertJsonObject, isJsonObject, loadConfigLayers, mergeConfigLayers } from "./config.js";
14
14
  export { assertDeclaredMediaTypeMatches, assertMediaBlocksWithinBounds, assertMessagesSupportModelCapabilities, assertModelSupportsContentBlocks, assertSsrfAllowedUrl, collectMessageContentBlocks, contentBlockInputModality, DEFAULT_MAX_AUDIO_DURATION_MS, DEFAULT_MAX_MEDIA_ITEM_BYTES, DEFAULT_MAX_MEDIA_ITEMS_PER_REQUEST, DEFAULT_MAX_MEDIA_REQUEST_BYTES, DEFAULT_MEDIA_FETCH_TIMEOUT_MS, loadBoundedBinaryResource, MediaContentError, MODEL_INPUT_CAPABILITIES, resolveMediaContentBlock, resolveMediaContentBlocks, sniffMediaMimeType, UnsupportedModalityError, } from "./content.js";
15
15
  export { applyContextBudget, CONTEXT_BUDGET_ERROR_CODE, CONTEXT_BUDGET_REPORT_METADATA_KEY, ContextBudgetError, DEFAULT_MAX_CONTEXT_BUDGET_OMISSIONS, estimateAssemblyTokens, estimateMessageBytes, estimateMessageTokens, estimateTextBytes, estimateTextTokens, getContextBudgetReport, HARD_MAX_CONTEXT_BUDGET_BYTES, HARD_MAX_CONTEXT_BUDGET_OMISSIONS, HARD_MAX_CONTEXT_BUDGET_TOKENS, isContextBudgetError, resolveContextBudget, } from "./context-budget.js";
16
- export { AgentDecisionError, AgentDelegationSuspendedError, AgentLoopStateError, AgentRunError, AgentRunStateError, assertBatchJobsSupported, assertEmbeddingsSupported, assertImageGenerationSupported, assertModerationSupported, assertSessionMetadataKey, assertSpeechSupported, assertTranscriptionSupported, assertVideoGenerationSupported, BatchJobsError, DEFAULT_MAX_PENDING_DECISIONS, DEFAULT_MAX_PENDING_STEER_BYTES, DEFAULT_MAX_PENDING_STEERS, DEFAULT_MAX_SESSION_SEARCH_CURSOR_BYTES, DEFAULT_MAX_SESSION_SEARCH_FTS_CANDIDATES, DEFAULT_MAX_SESSION_SEARCH_LINEAR_BYTES, DEFAULT_MAX_SESSION_SEARCH_LINEAR_ENTRIES, DEFAULT_MAX_SESSION_SEARCH_LINEAR_SESSIONS, DEFAULT_MAX_SESSION_SEARCH_QUERY_BYTES, DEFAULT_MAX_SESSION_SEARCH_SNIPPET_BYTES, DEFAULT_MAX_STICKY_DECISIONS, DEFAULT_SESSION_SEARCH_LIMIT, EmbeddingsError, HARD_MAX_ACTION_CONSTRAINT_BYTES, HARD_MAX_ACTION_CONSTRAINTS, HARD_MAX_DECISION_REASON_BYTES, HARD_MAX_ELICITATION_BYTES, HARD_MAX_PENDING_DECISIONS, HARD_MAX_PENDING_STEER_BYTES, HARD_MAX_PENDING_STEERS, HARD_MAX_SESSION_SEARCH_CURSOR_BYTES, HARD_MAX_SESSION_SEARCH_FTS_CANDIDATES, HARD_MAX_SESSION_SEARCH_LIMIT, HARD_MAX_SESSION_SEARCH_LINEAR_BYTES, HARD_MAX_SESSION_SEARCH_LINEAR_ENTRIES, HARD_MAX_SESSION_SEARCH_LINEAR_SESSIONS, HARD_MAX_SESSION_SEARCH_QUERY_BYTES, HARD_MAX_SESSION_SEARCH_SNIPPET_BYTES, HARD_MAX_STICKY_DECISIONS, ImageGenerationError, isBatchJobTerminal, isSessionAppendConflict, isSessionEntryKind, isSessionMetadataConflict, isSessionSearchUnsupported, MAX_ACTION_CONSTRAINT_BYTES, MAX_ACTION_CONSTRAINTS, MAX_ATTRIBUTION_DEPTH, MAX_DECISION_REASON_BYTES, MAX_ELICITATION_BYTES, ModerationError, modelSupportsBatchJobs, modelSupportsEmbeddings, modelSupportsImageGeneration, modelSupportsModeration, modelSupportsSpeech, modelSupportsTranscription, modelSupportsVideoGeneration, pollBatch, resolveSessionSearchQuery, SESSION_APPEND_CONFLICT_CODE, SESSION_ENTRY_KINDS, SESSION_ENTRY_SCHEMA_VERSION, SESSION_METADATA_CONFLICT_CODE, SESSION_SEARCH_UNSUPPORTED_CODE, SESSION_SEARCH_WORKSPACE_METADATA_KEY, SessionAppendConflictError, SessionMetadataConflictError, SessionSearchUnsupportedError, SpeechError, TranscriptionError, VideoGenerationError, } from "./contracts.js";
16
+ export { AgentDecisionError, AgentDelegationSuspendedError, AgentLoopStateError, AgentRunError, AgentRunStateError, assertBatchJobsSupported, assertEmbeddingsSupported, assertImageGenerationSupported, assertModerationSupported, assertSessionMetadataKey, assertSpeechSupported, assertTranscriptionSupported, assertVideoGenerationSupported, BatchJobsError, DEFAULT_MAX_PENDING_DECISIONS, DEFAULT_MAX_PENDING_STEER_BYTES, DEFAULT_MAX_PENDING_STEERS, DEFAULT_MAX_SESSION_SEARCH_CURSOR_BYTES, DEFAULT_MAX_SESSION_SEARCH_FTS_CANDIDATES, DEFAULT_MAX_SESSION_SEARCH_LINEAR_BYTES, DEFAULT_MAX_SESSION_SEARCH_LINEAR_ENTRIES, DEFAULT_MAX_SESSION_SEARCH_LINEAR_SESSIONS, DEFAULT_MAX_SESSION_SEARCH_QUERY_BYTES, DEFAULT_MAX_SESSION_SEARCH_SNIPPET_BYTES, DEFAULT_MAX_STICKY_DECISIONS, DEFAULT_SESSION_SEARCH_LIMIT, DEFAULT_SNAPSHOT_CACHE_TTL_MS, EmbeddingsError, HARD_MAX_ACTION_CONSTRAINT_BYTES, HARD_MAX_ACTION_CONSTRAINTS, HARD_MAX_DECISION_REASON_BYTES, HARD_MAX_ELICITATION_BYTES, HARD_MAX_PENDING_DECISIONS, HARD_MAX_PENDING_STEER_BYTES, HARD_MAX_PENDING_STEERS, HARD_MAX_SESSION_SEARCH_CURSOR_BYTES, HARD_MAX_SESSION_SEARCH_FTS_CANDIDATES, HARD_MAX_SESSION_SEARCH_LIMIT, HARD_MAX_SESSION_SEARCH_LINEAR_BYTES, HARD_MAX_SESSION_SEARCH_LINEAR_ENTRIES, HARD_MAX_SESSION_SEARCH_LINEAR_SESSIONS, HARD_MAX_SESSION_SEARCH_QUERY_BYTES, HARD_MAX_SESSION_SEARCH_SNIPPET_BYTES, HARD_MAX_SNAPSHOT_CACHE_TTL_MS, HARD_MAX_STICKY_DECISIONS, ImageGenerationError, isBatchJobTerminal, isSessionAppendConflict, isSessionEntryKind, isSessionMetadataConflict, isSessionSearchUnsupported, MAX_ACTION_CONSTRAINT_BYTES, MAX_ACTION_CONSTRAINTS, MAX_ATTRIBUTION_DEPTH, MAX_DECISION_REASON_BYTES, MAX_ELICITATION_BYTES, ModerationError, modelSupportsBatchJobs, modelSupportsEmbeddings, modelSupportsImageGeneration, modelSupportsModeration, modelSupportsSpeech, modelSupportsTranscription, modelSupportsVideoGeneration, pollBatch, resolveSessionSearchQuery, SESSION_APPEND_CONFLICT_CODE, SESSION_ENTRY_KINDS, SESSION_ENTRY_SCHEMA_VERSION, SESSION_METADATA_CONFLICT_CODE, SESSION_SEARCH_UNSUPPORTED_CODE, SESSION_SEARCH_WORKSPACE_METADATA_KEY, SessionAppendConflictError, SessionMetadataConflictError, SessionSearchUnsupportedError, SpeechError, TranscriptionError, VideoGenerationError, } from "./contracts.js";
17
17
  export { parseAgentFile, parseSkillFile } from "./contribution-parsing.js";
18
18
  export { createContributionRegistries, createContributionRegistry, registerDiscoveredContributions } from "./contributions.js";
19
19
  export { CONVERSATION_METADATA_KEY, ConversationError, conversationMarkerMetadata, conversationThreadFromRecord, DEFAULT_MAX_CONVERSATION_CURSOR_BYTES, decodeConversationReplayCursor, encodeConversationReplayCursor, HARD_MAX_CONVERSATION_CURSOR_BYTES, } from "./conversations.js";
@@ -27,7 +27,7 @@ export { createMemoryRunFeedbackStore, prepareRunFeedback, RunFeedbackError, req
27
27
  export { ALLOW_FIELD_POLICY, applyFieldPolicy, createAuditFieldRedactor, createProtectedFieldPolicy, FIELD_POLICY_LIMITS, FieldPolicyError, } from "./field-policy.js";
28
28
  export { assertGuardrailsAllowed, GuardrailError, MAX_GUARDRAIL_CONCURRENCY, runGuardrails } from "./guardrails.js";
29
29
  export { assertIdentityActive, assertIdentityMatchesOwnership, assertIdentityPropagation, DEFAULT_IDENTITY_LIMITS, HARD_IDENTITY_LIMITS, IdentityError, identityTelemetryAttributes, narrowIdentity, ownershipFromIdentity, resolveIdentityLimits, resolveRunIdentity, } from "./identity.js";
30
- export { assembleProviderInput, createDefaultInputBuilder, createDefaultPromptBuilder, renderPromptTemplate, resolveContextProviders, } from "./input.js";
30
+ export { assembleProviderInput, createDefaultInputBuilder, createDefaultPromptBuilder, EMPTY_TOOL_RESULT_TEXT, renderPromptTemplate, resolveContextProviders, } from "./input.js";
31
31
  export { resolveInstructionInjectors, runInstructionInjectors } from "./instruction-injection.js";
32
32
  export { createMemoryLeaseStore, LEASE_CONFLICT_CODE, LeaseConflictError } from "./leases.js";
33
33
  export { definePrismManifest, parsePrismManifest } from "./manifests.js";
@@ -66,6 +66,6 @@ export { createToolParameterValidator, createToolRegistry, dispatchToolCall, fil
66
66
  export { trimTrailingSlashes } from "./trim-trailing-slashes.js";
67
67
  export { resolveUseCaseModel, resolveUseCaseModelBinding, useCaseCredentialProviderId, } from "./use-case-model.js";
68
68
  export const name = "prism";
69
- export const version = "0.5.6";
69
+ export const version = "0.6.0";
70
70
  export const description = "Agent harness for AI providers, agents, sessions, and tools.";
71
71
  //# sourceMappingURL=index.js.map
package/dist/input.d.ts CHANGED
@@ -6,6 +6,12 @@ import { type LoadedSkillSet, type SkillsDisclosure } from "./skill-disclosure.j
6
6
  import { type ResolvedToolResultFoldOptions } from "./tool-result-fold.js";
7
7
  import { type ToolsDisclosure, type ToolsSearchOptions } from "./tool-search.js";
8
8
  export type AgentInput = string | Message | readonly Message[];
9
+ /**
10
+ * Wire payload for a tool result that carries no `value`, no `type:text` content, and no error.
11
+ * Every provider route serializes a tool result from this block, so the empty case must be a
12
+ * constant non-empty string instead of an absent payload (strict providers reject empty results).
13
+ */
14
+ export declare const EMPTY_TOOL_RESULT_TEXT = "(tool completed with no output)";
9
15
  export interface PromptInstruction {
10
16
  readonly text: string;
11
17
  readonly label?: string;
package/dist/input.js CHANGED
@@ -8,6 +8,12 @@ import { skillMessages as buildSkillMessages } from "./skill-disclosure.js";
8
8
  import { composeSystemPrompt } from "./system-prompts.js";
9
9
  import { foldToolResultHistory, foldToolResults } from "./tool-result-fold.js";
10
10
  import { selectDisclosedTools } from "./tool-search.js";
11
+ /**
12
+ * Wire payload for a tool result that carries no `value`, no `type:text` content, and no error.
13
+ * Every provider route serializes a tool result from this block, so the empty case must be a
14
+ * constant non-empty string instead of an absent payload (strict providers reject empty results).
15
+ */
16
+ export const EMPTY_TOOL_RESULT_TEXT = "(tool completed with no output)";
11
17
  export function createDefaultInputBuilder() {
12
18
  return {
13
19
  name: "default-input",
@@ -328,7 +334,12 @@ function toolResultPayload(result) {
328
334
  .map((block) => block.text)
329
335
  .filter(Boolean)
330
336
  .join("\n");
331
- return text.length > 0 ? text : undefined;
337
+ if (text.length > 0)
338
+ return text;
339
+ // An error already carries the outcome; without one, send the constant sentinel so no route
340
+ // (JSON string content, a content string, a function response, or a typed output part) emits
341
+ // an empty or absent payload.
342
+ return result.error === undefined || result.error === null ? EMPTY_TOOL_RESULT_TEXT : undefined;
332
343
  }
333
344
  function toolResultMessages(results) {
334
345
  return (results ?? []).map(toToolResultMessage);
@@ -0,0 +1,34 @@
1
+ export interface SsrfPolicy {
2
+ /** When true (default), deny private/link-local/metadata hostnames and IPs. */
3
+ readonly denyPrivateHosts?: boolean;
4
+ /** Optional hostname allow-list. When set, only listed hosts are permitted. */
5
+ readonly allowedHostnames?: readonly string[];
6
+ /**
7
+ * Optional IP-literal CIDR allow-list (IPv4 + IPv6, e.g. `"10.0.0.0/8"`). Checked
8
+ * after the hostname allow-list and the denied-name list, and applied to both URL
9
+ * literals and resolved DNS candidates. Membership bypasses **only** the private-IP
10
+ * block: metadata-style hostnames (`metadata.google.internal`), loopback names, and
11
+ * embedded credentials stay denied, and a hostname in the list can never match.
12
+ * An unparseable entry fails the check closed. Explicit host trust override — see
13
+ * `docs/multimodal-content.md` / `docs/host-security.md`.
14
+ */
15
+ readonly allowedCidrs?: readonly string[];
16
+ }
17
+ export interface MediaHostAddress {
18
+ readonly address: string;
19
+ readonly family: 4 | 6;
20
+ }
21
+ export type MediaHostnameResolver = (hostname: string, signal: AbortSignal) => Promise<readonly MediaHostAddress[]>;
22
+ export declare class MediaContentError extends Error {
23
+ 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";
24
+ constructor(code: MediaContentError["code"], message: string, options?: ErrorOptions);
25
+ }
26
+ export declare function assertSsrfAllowedUrl(url: string, policy?: SsrfPolicy): void;
27
+ export declare function normalizeHostname(value: string): string;
28
+ export declare function isBlockedIp(hostname: string): boolean;
29
+ /**
30
+ * Membership test for `SsrfPolicy.allowedCidrs`. Non-IP hostnames can never match; an
31
+ * entry that does not parse as `address/prefix` throws `ssrf_denied` (fail closed,
32
+ * including entries of the other address family than the one being tested).
33
+ */
34
+ export declare function isAllowedByCidr(hostname: string, allowedCidrs: readonly string[] | undefined): boolean;
@@ -0,0 +1,158 @@
1
+ /**
2
+ * SSRF policy, host/address types, `MediaContentError`, and the URL gate shared by the
3
+ * media content pipeline (`content.ts`) and the DNS-pinned fetch primitive
4
+ * (`pinned-fetch.ts`).
5
+ *
6
+ * Leaf module: it imports nothing from either consumer, which is what keeps the two off
7
+ * each other's import graph (plan 070 Task 10, shipped in 0.6.0 — the pair previously formed
8
+ * a deliberate ESM cycle where each module referenced the other's exports only inside
9
+ * function bodies). Declarations moved here verbatim; `assertSsrfAllowedUrl` is
10
+ * re-exported from `content.ts` so every import path and the class identity stay put.
11
+ */
12
+ import { isIP } from "node:net";
13
+ export class MediaContentError extends Error {
14
+ code;
15
+ constructor(code, message, options) {
16
+ super(message, options);
17
+ this.name = "MediaContentError";
18
+ this.code = code;
19
+ }
20
+ }
21
+ export function assertSsrfAllowedUrl(url, policy = {}) {
22
+ let parsed;
23
+ try {
24
+ parsed = new URL(url);
25
+ }
26
+ catch {
27
+ throw new MediaContentError("ssrf_denied", "Media URL is not a valid absolute URL");
28
+ }
29
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
30
+ throw new MediaContentError("unsupported_url_scheme", `Media URL scheme ${parsed.protocol} is not allowed`);
31
+ }
32
+ if (parsed.username || parsed.password) {
33
+ throw new MediaContentError("ssrf_denied", "Media URL must not embed credentials");
34
+ }
35
+ const hostname = normalizeHostname(parsed.hostname);
36
+ // Parsed up front so a malformed policy entry always fails closed, even when another
37
+ // allow-list short-circuits below. Membership only matters after the denied-name list.
38
+ const cidrAllowed = isAllowedByCidr(hostname, policy.allowedCidrs);
39
+ if (policy.allowedHostnames?.length) {
40
+ if (!policy.allowedHostnames.some((allowed) => hostname === normalizeHostname(allowed))) {
41
+ throw new MediaContentError("ssrf_denied", `Media URL host ${hostname} is not allow-listed`);
42
+ }
43
+ return;
44
+ }
45
+ if (policy.denyPrivateHosts === false)
46
+ return;
47
+ if (hostname === "localhost" ||
48
+ hostname.endsWith(".localhost") ||
49
+ hostname.endsWith(".local") ||
50
+ hostname === "metadata" ||
51
+ hostname === "metadata.google.internal" ||
52
+ hostname === "instance-data") {
53
+ throw new MediaContentError("ssrf_denied", `Media URL host ${hostname} is not allowed`);
54
+ }
55
+ // Validates the CIDR list even for a denied/absent IP: an unparseable entry fails closed.
56
+ if (cidrAllowed)
57
+ return;
58
+ if (isBlockedIp(hostname)) {
59
+ throw new MediaContentError("ssrf_denied", `Media URL host ${hostname} is not allowed`);
60
+ }
61
+ }
62
+ export function normalizeHostname(value) {
63
+ return value
64
+ .toLowerCase()
65
+ .replace(/^\[|\]$/g, "")
66
+ .replace(/\.$/, "");
67
+ }
68
+ export function isBlockedIp(hostname) {
69
+ const normalized = normalizeHostname(hostname);
70
+ const family = isIP(normalized);
71
+ if (family === 4)
72
+ return isBlockedIpv4(normalized);
73
+ if (family === 6)
74
+ return isBlockedIpv6(normalized);
75
+ return false;
76
+ }
77
+ /**
78
+ * Membership test for `SsrfPolicy.allowedCidrs`. Non-IP hostnames can never match; an
79
+ * entry that does not parse as `address/prefix` throws `ssrf_denied` (fail closed,
80
+ * including entries of the other address family than the one being tested).
81
+ */
82
+ export function isAllowedByCidr(hostname, allowedCidrs) {
83
+ if (!allowedCidrs?.length)
84
+ return false;
85
+ const ranges = allowedCidrs.map((entry) => {
86
+ const range = parseCidr(entry);
87
+ if (!range)
88
+ throw new MediaContentError("ssrf_denied", `SSRF policy CIDR '${entry}' is not a valid range`);
89
+ return range;
90
+ });
91
+ const address = normalizeHostname(hostname);
92
+ const family = isIP(address);
93
+ if (family !== 4 && family !== 6)
94
+ return false;
95
+ const bits = family === 4 ? 32 : 128;
96
+ const target = addressToBigInt(address, family);
97
+ return ranges.some((range) => range.bits === bits && target >> BigInt(bits - range.prefix) === range.base >> BigInt(bits - range.prefix));
98
+ }
99
+ function parseCidr(value) {
100
+ const [address, prefixText, ...rest] = value.split("/");
101
+ if (rest.length > 0 || address === undefined || prefixText === undefined)
102
+ return undefined;
103
+ const family = isIP(normalizeHostname(address));
104
+ if (family !== 4 && family !== 6)
105
+ return undefined;
106
+ const prefix = Number(prefixText);
107
+ const bits = family === 4 ? 32 : 128;
108
+ if (!Number.isInteger(prefix) || prefix < 0 || prefix > bits)
109
+ return undefined;
110
+ return { base: addressToBigInt(normalizeHostname(address), family), bits, prefix };
111
+ }
112
+ function addressToBigInt(address, family) {
113
+ const words = family === 4 ? address.split(".").map(Number) : (parseIpv6Words(address) ?? []);
114
+ return words.reduce((accumulator, word) => (accumulator << BigInt(family === 4 ? 8 : 16)) | BigInt(word), 0n);
115
+ }
116
+ function isBlockedIpv4(address) {
117
+ const [a, b] = address.split(".").map(Number);
118
+ return (a === 0 ||
119
+ a === 10 ||
120
+ a === 127 ||
121
+ (a === 100 && b >= 64 && b <= 127) ||
122
+ (a === 169 && b === 254) ||
123
+ (a === 172 && b >= 16 && b <= 31) ||
124
+ (a === 192 && (b === 0 || b === 168)) ||
125
+ (a === 198 && (b === 18 || b === 19 || b === 51)) ||
126
+ (a === 203 && b === 0) ||
127
+ a >= 224);
128
+ }
129
+ function isBlockedIpv6(address) {
130
+ const words = parseIpv6Words(address);
131
+ if (!words)
132
+ return true;
133
+ if (words.every((word) => word === 0) || (words.slice(0, 7).every((word) => word === 0) && words[7] === 1))
134
+ return true;
135
+ if ((words[0] & 0xfe00) === 0xfc00)
136
+ return true;
137
+ if ((words[0] & 0xffc0) === 0xfe80 || (words[0] & 0xffc0) === 0xfec0)
138
+ return true;
139
+ if ((words[0] & 0xff00) === 0xff00)
140
+ return true;
141
+ if (words[0] === 0x2001 && words[1] === 0x0db8)
142
+ return true;
143
+ const mapped = words.slice(0, 5).every((word) => word === 0) && (words[5] === 0 || words[5] === 0xffff);
144
+ return mapped && isBlockedIpv4(`${words[6] >> 8}.${words[6] & 0xff}.${words[7] >> 8}.${words[7] & 0xff}`);
145
+ }
146
+ function parseIpv6Words(address) {
147
+ const parts = address.split("::");
148
+ if (parts.length > 2)
149
+ return undefined;
150
+ const left = parts[0] ? parts[0].split(":") : [];
151
+ const right = parts[1] ? parts[1].split(":") : [];
152
+ const missing = 8 - left.length - right.length;
153
+ if (missing < 0 || (parts.length === 1 && missing !== 0))
154
+ return undefined;
155
+ const words = [...left, ...Array.from({ length: missing }, () => "0"), ...right].map((part) => Number.parseInt(part, 16));
156
+ return words.length === 8 && words.every((word) => Number.isInteger(word) && word >= 0 && word <= 0xffff) ? words : undefined;
157
+ }
158
+ //# sourceMappingURL=media-types.js.map
@@ -1,4 +1,5 @@
1
- import { type MediaHostAddress, type MediaHostnameResolver, type SsrfPolicy } from "./content.js";
1
+ import { type MediaHostAddress, type MediaHostnameResolver, normalizeHostname, type SsrfPolicy } from "./media-types.js";
2
+ export { normalizeHostname };
2
3
  export interface PinnedFetchOptions {
3
4
  /** Prefix for request-level error messages ("redirects are not allowed", "response exceeds", ...). Default "Request". */
4
5
  readonly errorPrefix?: string;
@@ -20,6 +21,5 @@ export declare function defaultResolver(hostname: string): Promise<readonly Medi
20
21
  export declare function requestPinned(url: URL, address: MediaHostAddress, init: RequestInit | undefined, errorPrefix?: string): Promise<Response>;
21
22
  export declare function boundResponse(response: Response, maxBytes: number, errorPrefix?: string): Response;
22
23
  export declare function raceAbort<T>(promise: Promise<T>, signal: AbortSignal | null | undefined): Promise<T>;
23
- export declare function normalizeHostname(value: string): string;
24
24
  export declare function isLoopbackHostname(value: string): boolean;
25
25
  export declare function isLoopbackAddress(value: string): boolean;
@@ -11,15 +11,17 @@
11
11
  * for 3xx). Error messages are parameterized by `errorPrefix` so each caller
12
12
  * (MCP, OIDC, OPA, content) keeps its own taxonomy and message text.
13
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.
14
+ * The SSRF gate, `MediaContentError`, and the host/address types come from the leaf
15
+ * module `./media-types.js` — shared with `content.ts`, which routes its default media
16
+ * fetch through here. This module transitively imports nothing from `content.ts` (plan
17
+ * 070 Task 10 broke the deliberate ESM cycle that used to sit between the two).
17
18
  */
18
19
  import { lookup as dnsLookup } from "node:dns/promises";
19
20
  import { request as httpRequest } from "node:http";
20
21
  import { request as httpsRequest } from "node:https";
21
22
  import { isIP } from "node:net";
22
- import { assertSsrfAllowedUrl, MediaContentError } from "./content.js";
23
+ import { assertSsrfAllowedUrl, MediaContentError, normalizeHostname, } from "./media-types.js";
24
+ export { normalizeHostname };
23
25
  /** One DNS-pinned, redirect-free, byte-bounded fetch. See module comment. */
24
26
  export async function pinnedFetch(url, init, options) {
25
27
  const errorPrefix = options?.errorPrefix ?? "Request";
@@ -72,8 +74,11 @@ export async function resolvePinnedAddress(url, resolver, signal, allowLoopback,
72
74
  }
73
75
  const literal = candidate.family === 6 ? `[${normalized}]` : normalized;
74
76
  // Fail closed on resolved candidates: an explicit hostname allow-list is honored
75
- // for the URL itself, but every resolved address is still private-checked.
76
- const candidatePolicy = ssrf?.allowedHostnames?.length ? { denyPrivateHosts: ssrf.denyPrivateHosts } : ssrf;
77
+ // for the URL itself, but every resolved address is still private-checked. An
78
+ // allowed CIDR is a range rule, so it does apply to resolved addresses.
79
+ const candidatePolicy = ssrf?.allowedHostnames?.length
80
+ ? { denyPrivateHosts: ssrf.denyPrivateHosts, ...(ssrf.allowedCidrs ? { allowedCidrs: ssrf.allowedCidrs } : {}) }
81
+ : ssrf;
77
82
  try {
78
83
  assertSsrfAllowedUrl(`${url.protocol}//${literal}`, candidatePolicy);
79
84
  }
@@ -250,12 +255,6 @@ export async function raceAbort(promise, signal) {
250
255
  promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
251
256
  });
252
257
  }
253
- export function normalizeHostname(value) {
254
- return value
255
- .toLowerCase()
256
- .replace(/^\[|\]$/g, "")
257
- .replace(/\.$/, "");
258
- }
259
258
  export function isLoopbackHostname(value) {
260
259
  const hostname = normalizeHostname(value);
261
260
  return hostname === "localhost" || hostname.endsWith(".localhost") || isLoopbackAddress(hostname);