@gmickel/gno 1.27.1 → 1.29.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 (195) hide show
  1. package/README.md +46 -20
  2. package/assets/skill/SKILL.md +59 -1
  3. package/browser-extension/artifacts/gno-browser-clipper-v1.29.0.zip +0 -0
  4. package/browser-extension/artifacts/gno-browser-clipper-v1.29.0.zip.sha256 +1 -0
  5. package/browser-extension/dist/chunk-b2zm0jjd.js +50 -0
  6. package/browser-extension/dist/manifest.json +1 -1
  7. package/browser-extension/dist/preview.html +1 -1
  8. package/browser-extension/dist/service-worker.js +6 -6
  9. package/package.json +2 -2
  10. package/spec/cli.md +82 -5
  11. package/spec/db/schema.sql +31 -0
  12. package/spec/mcp.md +71 -0
  13. package/spec/output-schemas/ask.schema.json +156 -1
  14. package/spec/output-schemas/browser-clip-preview.schema.json +30 -0
  15. package/spec/output-schemas/collection-egress-check.schema.json +91 -0
  16. package/spec/output-schemas/collection-egress-policy-set.schema.json +56 -0
  17. package/spec/output-schemas/collection-egress-policy.schema.json +41 -0
  18. package/spec/output-schemas/context-capsule-v1.schema.json +183 -3
  19. package/spec/output-schemas/context-capsule-verification.schema.json +1 -1
  20. package/spec/output-schemas/egress-audit-management.schema.json +88 -0
  21. package/spec/output-schemas/get.schema.json +94 -0
  22. package/spec/output-schemas/mcp-http-error.schema.json +113 -2
  23. package/spec/output-schemas/mcp-job-status.schema.json +28 -0
  24. package/spec/output-schemas/multi-get.schema.json +128 -0
  25. package/spec/output-schemas/publish-artifact.schema.json +32 -0
  26. package/spec/output-schemas/record-import.schema.json +223 -0
  27. package/spec/output-schemas/retrieval-trace-common.schema.json +31 -0
  28. package/spec/output-schemas/retrieval-trace-export.schema.json +3 -0
  29. package/spec/output-schemas/retrieval-trace-qrels.schema.json +3 -0
  30. package/spec/output-schemas/retrieval-trace-summary.schema.json +4 -0
  31. package/spec/output-schemas/search-result.schema.json +94 -0
  32. package/spec/output-schemas/search-results.schema.json +155 -0
  33. package/spec/project-profile.schema.json +66 -0
  34. package/src/app/context-format.ts +1 -1
  35. package/src/app/context-runtime-contract.ts +16 -7
  36. package/src/cli/commands/ask.ts +7 -0
  37. package/src/cli/commands/collection/index.ts +5 -0
  38. package/src/cli/commands/collection/policy.ts +126 -0
  39. package/src/cli/commands/context-build.ts +5 -0
  40. package/src/cli/commands/daemon.ts +45 -3
  41. package/src/cli/commands/egress-audit.ts +68 -0
  42. package/src/cli/commands/embed.ts +2 -0
  43. package/src/cli/commands/get.ts +12 -2
  44. package/src/cli/commands/index-cmd.ts +13 -0
  45. package/src/cli/commands/multi-get.ts +9 -2
  46. package/src/cli/commands/query.ts +12 -0
  47. package/src/cli/commands/replay.ts +13 -3
  48. package/src/cli/commands/shared.ts +28 -0
  49. package/src/cli/commands/update.ts +5 -0
  50. package/src/cli/commands/vsearch.ts +3 -1
  51. package/src/cli/program.ts +127 -0
  52. package/src/config/index.ts +9 -0
  53. package/src/config/project-profile.ts +2 -0
  54. package/src/config/types.ts +66 -0
  55. package/src/converters/adapters/browser-export/adapter.ts +199 -0
  56. package/src/converters/adapters/browser-export/formats.ts +358 -0
  57. package/src/converters/adapters/email/adapter.ts +429 -0
  58. package/src/converters/adapters/email/html.ts +162 -0
  59. package/src/converters/adapters/email/mime.ts +454 -0
  60. package/src/converters/adapters/email/parameters.ts +77 -0
  61. package/src/converters/adapters/ical/adapter.ts +475 -0
  62. package/src/converters/adapters/ical/recurrence.ts +186 -0
  63. package/src/converters/adapters/jsonl/adapter.ts +238 -0
  64. package/src/converters/adapters/jsonl/config.ts +105 -0
  65. package/src/converters/adapters/shared/html-text.ts +165 -0
  66. package/src/converters/adapters/shared/record-utils.ts +79 -0
  67. package/src/converters/adapters/shared/utf8-lines.ts +141 -0
  68. package/src/converters/adapters/transcript/adapter.ts +295 -0
  69. package/src/converters/adapters/transcript/json.ts +184 -0
  70. package/src/converters/adapters/transcript/model.ts +171 -0
  71. package/src/converters/adapters/transcript/text.ts +58 -0
  72. package/src/converters/adapters/transcript/timed.ts +152 -0
  73. package/src/converters/index.ts +11 -1
  74. package/src/converters/mime.ts +8 -0
  75. package/src/converters/pipeline.ts +15 -1
  76. package/src/converters/registry.ts +37 -1
  77. package/src/converters/types.ts +136 -0
  78. package/src/core/browser-clip.ts +16 -1
  79. package/src/core/collection-egress-policy-projection.ts +28 -0
  80. package/src/core/collection-egress-policy-service.ts +443 -0
  81. package/src/core/collection-egress-policy-validation.ts +242 -0
  82. package/src/core/config-mutation.ts +31 -19
  83. package/src/core/context-capsule-schema.ts +198 -1
  84. package/src/core/context-capsule-validation.ts +4 -4
  85. package/src/core/context-capsule-verification.ts +5 -1
  86. package/src/core/context-capsule.ts +177 -112
  87. package/src/core/context-evidence.ts +93 -15
  88. package/src/core/destination-classifier.ts +402 -0
  89. package/src/core/document-capabilities.ts +12 -0
  90. package/src/core/egress-audit.ts +239 -0
  91. package/src/core/egress-authorization.ts +50 -0
  92. package/src/core/egress-enforcement.ts +264 -0
  93. package/src/core/egress-policy.ts +440 -0
  94. package/src/core/egress-provenance.ts +336 -0
  95. package/src/core/job-manager.ts +37 -0
  96. package/src/core/network-boundary-inventory.ts +261 -0
  97. package/src/core/project-profile-apply-state.ts +5 -0
  98. package/src/core/project-profile.ts +4 -0
  99. package/src/core/record-metadata.ts +49 -0
  100. package/src/core/retrieval-qrels.ts +6 -0
  101. package/src/core/retrieval-replay.ts +18 -4
  102. package/src/core/retrieval-trace-export.ts +23 -4
  103. package/src/core/retrieval-trace-management-helpers.ts +1 -0
  104. package/src/core/retrieval-trace-management-types.ts +3 -0
  105. package/src/core/retrieval-trace-management.ts +9 -1
  106. package/src/core/retrieval-trace-session.ts +94 -0
  107. package/src/core/retrieval-trace.ts +22 -0
  108. package/src/ingestion/record-adapter-canonical.ts +433 -0
  109. package/src/ingestion/record-adapter.ts +448 -0
  110. package/src/ingestion/record-container.ts +704 -0
  111. package/src/ingestion/record-path.ts +20 -0
  112. package/src/ingestion/record-sync.ts +70 -0
  113. package/src/ingestion/sync.ts +243 -36
  114. package/src/ingestion/types.ts +71 -1
  115. package/src/ingestion/walker.ts +31 -11
  116. package/src/llm/errors.ts +10 -0
  117. package/src/llm/http-inference.ts +175 -0
  118. package/src/llm/http-policy.ts +537 -0
  119. package/src/llm/httpEmbedding.ts +47 -28
  120. package/src/llm/httpGeneration.ts +31 -18
  121. package/src/llm/httpRerank.ts +30 -18
  122. package/src/llm/index.ts +1 -0
  123. package/src/llm/nodeLlamaCpp/adapter.ts +55 -20
  124. package/src/llm/pinned-http-connection.ts +177 -0
  125. package/src/mcp/context.ts +53 -1
  126. package/src/mcp/http-egress.ts +220 -0
  127. package/src/mcp/http-security.ts +32 -21
  128. package/src/mcp/http-session.ts +13 -2
  129. package/src/mcp/http-transport.ts +96 -6
  130. package/src/mcp/sync-egress.ts +24 -0
  131. package/src/mcp/tools/add-collection.ts +4 -0
  132. package/src/mcp/tools/ask.ts +1 -1
  133. package/src/mcp/tools/context.ts +9 -2
  134. package/src/mcp/tools/egress.ts +247 -0
  135. package/src/mcp/tools/embed.ts +1 -0
  136. package/src/mcp/tools/get.ts +10 -2
  137. package/src/mcp/tools/index-cmd.ts +7 -0
  138. package/src/mcp/tools/index.ts +98 -0
  139. package/src/mcp/tools/multi-get.ts +9 -2
  140. package/src/mcp/tools/query.ts +10 -0
  141. package/src/mcp/tools/remove-collection.ts +4 -0
  142. package/src/mcp/tools/sync.ts +6 -0
  143. package/src/mcp/tools/trace.ts +7 -1
  144. package/src/mcp/tools/vsearch.ts +1 -0
  145. package/src/mcp/tools/workspace-write.ts +2 -0
  146. package/src/pipeline/egress-lineage.ts +124 -0
  147. package/src/pipeline/filters.ts +1 -1
  148. package/src/pipeline/graph-retrieval.ts +7 -4
  149. package/src/pipeline/hybrid.ts +21 -4
  150. package/src/pipeline/result-context.ts +12 -4
  151. package/src/pipeline/search.ts +23 -4
  152. package/src/pipeline/types.ts +6 -0
  153. package/src/pipeline/vsearch.ts +67 -28
  154. package/src/publish/artifact.ts +31 -3
  155. package/src/publish/export-service.ts +25 -0
  156. package/src/sdk/client.ts +119 -2
  157. package/src/sdk/documents.ts +13 -5
  158. package/src/sdk/embed.ts +1 -0
  159. package/src/sdk/types.ts +33 -0
  160. package/src/serve/browse-tree.ts +4 -2
  161. package/src/serve/clipper-capture.ts +5 -1
  162. package/src/serve/closed-json.ts +61 -0
  163. package/src/serve/config-sync.ts +4 -1
  164. package/src/serve/context.ts +1 -0
  165. package/src/serve/doc-events.ts +110 -39
  166. package/src/serve/public/components/CollectionEgressCheckPanel.tsx +411 -0
  167. package/src/serve/public/components/CollectionEgressPolicyEditor.tsx +78 -0
  168. package/src/serve/public/components/CollectionModelDialog.tsx +93 -9
  169. package/src/serve/public/components/EgressAuditPanel.tsx +278 -0
  170. package/src/serve/public/globals.built.css +1 -1
  171. package/src/serve/public/pages/Collections.tsx +3 -0
  172. package/src/serve/resident-request.ts +80 -3
  173. package/src/serve/resident-runtime.ts +63 -1
  174. package/src/serve/routes/api.ts +237 -65
  175. package/src/serve/routes/clipper.ts +64 -1
  176. package/src/serve/routes/mcp.ts +6 -0
  177. package/src/serve/routes/traces.ts +27 -4
  178. package/src/serve/server.ts +118 -7
  179. package/src/store/migrations/022-record-export-lineage.ts +42 -0
  180. package/src/store/migrations/023-collection-egress-policy.ts +61 -0
  181. package/src/store/migrations/024-egress-derived-lineage.ts +121 -0
  182. package/src/store/migrations/025-collection-egress-policy-revision.ts +40 -0
  183. package/src/store/migrations/index.ts +8 -0
  184. package/src/store/retrieval-trace-codec.ts +15 -0
  185. package/src/store/sqlite/adapter.ts +284 -14
  186. package/src/store/sqlite/change-journal-store.ts +41 -2
  187. package/src/store/sqlite/egress-audit-store.ts +485 -0
  188. package/src/store/sqlite/retrieval-trace-management-store.ts +47 -6
  189. package/src/store/sqlite/retrieval-trace-retention.ts +2 -0
  190. package/src/store/sqlite/retrieval-trace-rows.ts +8 -1
  191. package/src/store/sqlite/retrieval-trace-store.ts +104 -5
  192. package/src/store/types.ts +166 -1
  193. package/browser-extension/artifacts/gno-browser-clipper-v1.27.1.zip +0 -0
  194. package/browser-extension/artifacts/gno-browser-clipper-v1.27.1.zip.sha256 +0 -1
  195. package/browser-extension/dist/chunk-vn5f663b.js +0 -50
@@ -0,0 +1,537 @@
1
+ /** DNS-pinned, redirect-aware destination policy for outbound HTTP inference. */
2
+
3
+ import type { DestinationClassification } from "../core/destination-classifier";
4
+ import type { HttpDestinationPolicyAudit } from "./pinned-http-connection";
5
+
6
+ import {
7
+ classifyDestination,
8
+ isNetworkAddress,
9
+ } from "../core/destination-classifier";
10
+ import { PinnedHttpConnection } from "./pinned-http-connection";
11
+
12
+ export type {
13
+ HttpDestinationPolicyAudit,
14
+ PinnedHttpFetch,
15
+ } from "./pinned-http-connection";
16
+ export {
17
+ PinnedHttpConnection,
18
+ PinnedHttpRequestError,
19
+ } from "./pinned-http-connection";
20
+
21
+ export const MAX_HTTP_DESTINATION_ADDRESSES = 32;
22
+ export const MAX_HTTP_DESTINATION_REDIRECTS = 5;
23
+
24
+ export const HTTP_DESTINATION_POLICY_REASONS = [
25
+ "DESTINATION_ALLOWED",
26
+ "INVALID_DESTINATION",
27
+ "UNSUPPORTED_PROTOCOL",
28
+ "CREDENTIALS_IN_URL",
29
+ "DNS_UNRESOLVED",
30
+ "DNS_INVALID_ANSWER",
31
+ "DNS_RESULT_LIMIT",
32
+ "ZONE_NOT_ALLOWED",
33
+ "PROVIDER_ADDRESS_NOT_PUBLIC",
34
+ "PROVIDER_HTTPS_REQUIRED",
35
+ "PROXY_ENVIRONMENT_ACTIVE",
36
+ "DNS_REBINDING",
37
+ "INVALID_REDIRECT_COUNT",
38
+ "REDIRECT_LIMIT",
39
+ "REDIRECT_ZONE_CHANGED",
40
+ "PROVIDER_REDIRECT_ORIGIN_CHANGED",
41
+ "HTTPS_DOWNGRADE",
42
+ ] as const;
43
+ export type HttpDestinationPolicyReason =
44
+ (typeof HTTP_DESTINATION_POLICY_REASONS)[number];
45
+
46
+ export type HttpDestinationMaximumZone = "loopback" | "lan" | "remote";
47
+
48
+ export interface HttpDestinationPolicyDenial {
49
+ ok: false;
50
+ reason: Exclude<HttpDestinationPolicyReason, "DESTINATION_ALLOWED">;
51
+ classification: DestinationClassification;
52
+ audit: HttpDestinationPolicyAudit;
53
+ }
54
+
55
+ export interface HttpDestinationResolver {
56
+ lookup(hostname: string, port: number): Promise<readonly string[]>;
57
+ }
58
+
59
+ export interface HttpDestinationPolicyOptions {
60
+ maximumZone: HttpDestinationMaximumZone;
61
+ resolver?: HttpDestinationResolver;
62
+ remoteProvider?: boolean;
63
+ env?: Readonly<Record<string, string | undefined>>;
64
+ }
65
+
66
+ export type HttpDestinationConnectionResult =
67
+ | { ok: true; value: PinnedHttpConnection }
68
+ | HttpDestinationPolicyDenial;
69
+
70
+ export type HttpDestinationPolicyResult =
71
+ | { ok: true; reason: "DESTINATION_ALLOWED"; value: HttpDestinationPin }
72
+ | HttpDestinationPolicyDenial;
73
+
74
+ export type HttpDestinationClassificationResult =
75
+ | {
76
+ ok: true;
77
+ reason: "DESTINATION_ALLOWED";
78
+ classification: DestinationClassification;
79
+ }
80
+ | HttpDestinationPolicyDenial;
81
+
82
+ const ZONE_ORDER: Record<HttpDestinationMaximumZone, number> = {
83
+ loopback: 0,
84
+ lan: 1,
85
+ remote: 2,
86
+ };
87
+ const PROXY_ENV_NAMES = [
88
+ "HTTP_PROXY",
89
+ "HTTPS_PROXY",
90
+ "ALL_PROXY",
91
+ "http_proxy",
92
+ "https_proxy",
93
+ "all_proxy",
94
+ ] as const;
95
+
96
+ const defaultResolver: HttpDestinationResolver = {
97
+ async lookup(hostname: string, port: number): Promise<readonly string[]> {
98
+ const results = await Bun.dns.lookup(hostname, {
99
+ family: "any",
100
+ socketType: "tcp",
101
+ port,
102
+ });
103
+ return results.map(({ address }) => address);
104
+ },
105
+ };
106
+
107
+ function normalizeHostname(hostname: string): string {
108
+ return hostname.startsWith("[") && hostname.endsWith("]")
109
+ ? hostname.slice(1, -1).toLowerCase()
110
+ : hostname.toLowerCase();
111
+ }
112
+
113
+ function normalizeAddresses(addresses: readonly string[]): string[] {
114
+ return [...new Set(addresses.map((address) => address.trim().toLowerCase()))]
115
+ .filter(Boolean)
116
+ .sort();
117
+ }
118
+
119
+ function protocolFor(url: URL): HttpDestinationPolicyAudit["protocol"] {
120
+ if (url.protocol === "http:") return "http";
121
+ if (url.protocol === "https:") return "https";
122
+ return "unknown";
123
+ }
124
+
125
+ function portFor(url: URL): number {
126
+ if (url.port) return Number(url.port);
127
+ return url.protocol === "https:" ? 443 : 80;
128
+ }
129
+
130
+ function hasActiveProxy(
131
+ env: Readonly<Record<string, string | undefined>>
132
+ ): boolean {
133
+ return PROXY_ENV_NAMES.some((name) => Boolean(env[name]?.trim()));
134
+ }
135
+
136
+ function invalidClassification(): DestinationClassification {
137
+ return classifyDestination({ kind: "network" });
138
+ }
139
+
140
+ function createAudit(
141
+ classification: DestinationClassification,
142
+ url: URL | null,
143
+ redirectCount: number
144
+ ): HttpDestinationPolicyAudit {
145
+ return {
146
+ protocol: url ? protocolFor(url) : "unknown",
147
+ port: url ? portFor(url) : null,
148
+ redirectCount,
149
+ classification: classification.audit,
150
+ };
151
+ }
152
+
153
+ function denial(
154
+ reason: HttpDestinationPolicyDenial["reason"],
155
+ classification: DestinationClassification,
156
+ url: URL | null,
157
+ redirectCount: number
158
+ ): HttpDestinationPolicyDenial {
159
+ return {
160
+ ok: false,
161
+ reason,
162
+ classification,
163
+ audit: createAudit(classification, url, redirectCount),
164
+ };
165
+ }
166
+
167
+ function zoneAllowed(
168
+ actual: DestinationClassification["zone"],
169
+ maximum: HttpDestinationMaximumZone
170
+ ): boolean {
171
+ if (actual === "local_process") return true;
172
+ return ZONE_ORDER[actual] <= ZONE_ORDER[maximum];
173
+ }
174
+
175
+ async function resolveAddresses(
176
+ url: URL,
177
+ resolver: HttpDestinationResolver
178
+ ): Promise<
179
+ | { ok: true; hostname: string; addresses: string[] }
180
+ | {
181
+ ok: false;
182
+ reason: "DNS_UNRESOLVED" | "DNS_INVALID_ANSWER" | "DNS_RESULT_LIMIT";
183
+ }
184
+ > {
185
+ const hostname = normalizeHostname(url.hostname);
186
+ if (isNetworkAddress(hostname)) {
187
+ return { ok: true, hostname, addresses: [hostname] };
188
+ }
189
+ try {
190
+ const raw = await resolver.lookup(hostname, portFor(url));
191
+ if (raw.length > MAX_HTTP_DESTINATION_ADDRESSES) {
192
+ return { ok: false, reason: "DNS_RESULT_LIMIT" };
193
+ }
194
+ const addresses = normalizeAddresses(raw);
195
+ if (addresses.length === 0) {
196
+ return { ok: false, reason: "DNS_UNRESOLVED" };
197
+ }
198
+ if (
199
+ addresses.some(
200
+ (address) => address.includes("%") || !isNetworkAddress(address)
201
+ )
202
+ ) {
203
+ return { ok: false, reason: "DNS_INVALID_ANSWER" };
204
+ }
205
+ return { ok: true, hostname, addresses };
206
+ } catch {
207
+ return { ok: false, reason: "DNS_UNRESOLVED" };
208
+ }
209
+ }
210
+
211
+ function parseHttpUrl(rawUrl: string): URL | null {
212
+ try {
213
+ return new URL(rawUrl);
214
+ } catch {
215
+ return null;
216
+ }
217
+ }
218
+
219
+ function providerAddressesArePublic(
220
+ classification: DestinationClassification
221
+ ): boolean {
222
+ return (
223
+ classification.audit.addressClasses.length === 1 &&
224
+ classification.audit.addressClasses[0] === "public"
225
+ );
226
+ }
227
+
228
+ interface PreparedDestination {
229
+ url: URL;
230
+ hostname: string;
231
+ addresses: string[];
232
+ classification: DestinationClassification;
233
+ }
234
+
235
+ function freezeClassification(
236
+ classification: DestinationClassification
237
+ ): DestinationClassification {
238
+ const audit = Object.freeze({
239
+ ...classification.audit,
240
+ addressClasses: Object.freeze([...classification.audit.addressClasses]),
241
+ });
242
+ return Object.freeze({ ...classification, audit });
243
+ }
244
+
245
+ function isPolicyDenial(
246
+ value: PreparedDestination | HttpDestinationPolicyDenial
247
+ ): value is HttpDestinationPolicyDenial {
248
+ return "ok" in value && value.ok === false;
249
+ }
250
+
251
+ async function prepareDestination(
252
+ rawUrl: string,
253
+ options: Required<
254
+ Pick<HttpDestinationPolicyOptions, "maximumZone" | "remoteProvider" | "env">
255
+ > & { resolver: HttpDestinationResolver },
256
+ redirectCount: number
257
+ ): Promise<PreparedDestination | HttpDestinationPolicyDenial> {
258
+ const url = parseHttpUrl(rawUrl);
259
+ const invalid = invalidClassification();
260
+ if (!url) return denial("INVALID_DESTINATION", invalid, null, redirectCount);
261
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
262
+ return denial("UNSUPPORTED_PROTOCOL", invalid, url, redirectCount);
263
+ }
264
+ if (url.username || url.password) {
265
+ return denial("CREDENTIALS_IN_URL", invalid, url, redirectCount);
266
+ }
267
+ if (options.remoteProvider && url.protocol !== "https:") {
268
+ return denial("PROVIDER_HTTPS_REQUIRED", invalid, url, redirectCount);
269
+ }
270
+ if (options.maximumZone !== "remote" && hasActiveProxy(options.env)) {
271
+ return denial("PROXY_ENVIRONMENT_ACTIVE", invalid, url, redirectCount);
272
+ }
273
+
274
+ const resolved = await resolveAddresses(url, options.resolver);
275
+ if (!resolved.ok) {
276
+ return denial(resolved.reason, invalid, url, redirectCount);
277
+ }
278
+ const classification = classifyDestination({
279
+ kind: "network",
280
+ hostname: resolved.hostname,
281
+ addresses: resolved.addresses,
282
+ remoteProvider: options.remoteProvider,
283
+ });
284
+ if (options.remoteProvider && !providerAddressesArePublic(classification)) {
285
+ return denial(
286
+ "PROVIDER_ADDRESS_NOT_PUBLIC",
287
+ classification,
288
+ url,
289
+ redirectCount
290
+ );
291
+ }
292
+ if (!zoneAllowed(classification.zone, options.maximumZone)) {
293
+ return denial("ZONE_NOT_ALLOWED", classification, url, redirectCount);
294
+ }
295
+ return {
296
+ url,
297
+ hostname: resolved.hostname,
298
+ addresses: resolved.addresses,
299
+ classification,
300
+ };
301
+ }
302
+
303
+ /**
304
+ * Opaque DNS pin. JSON/log projection is intentionally redacted; raw connection
305
+ * details are released only by acquireConnection() after an exact DNS recheck.
306
+ */
307
+ export class HttpDestinationPin {
308
+ readonly #classification: DestinationClassification;
309
+ readonly #classificationZone: DestinationClassification["zone"];
310
+ readonly #url: URL;
311
+ readonly #hostname: string;
312
+ readonly #addresses: readonly string[];
313
+ readonly #maximumZone: HttpDestinationMaximumZone;
314
+ readonly #resolver: HttpDestinationResolver;
315
+ readonly #remoteProvider: boolean;
316
+ readonly #env: Readonly<Record<string, string | undefined>>;
317
+ readonly #redirectCount: number;
318
+
319
+ constructor(
320
+ prepared: PreparedDestination,
321
+ options: Required<
322
+ Pick<
323
+ HttpDestinationPolicyOptions,
324
+ "maximumZone" | "remoteProvider" | "env"
325
+ >
326
+ > & { resolver: HttpDestinationResolver },
327
+ redirectCount: number
328
+ ) {
329
+ this.#classification = freezeClassification(prepared.classification);
330
+ this.#classificationZone = prepared.classification.zone;
331
+ this.#url = prepared.url;
332
+ this.#hostname = prepared.hostname;
333
+ this.#addresses = prepared.addresses;
334
+ this.#maximumZone = options.maximumZone;
335
+ this.#resolver = options.resolver;
336
+ this.#remoteProvider = options.remoteProvider;
337
+ this.#env = options.env;
338
+ this.#redirectCount = redirectCount;
339
+ }
340
+
341
+ toJSON(): {
342
+ classification: DestinationClassification;
343
+ audit: HttpDestinationPolicyAudit;
344
+ } {
345
+ return {
346
+ classification: this.#classification,
347
+ audit: createAudit(this.#classification, this.#url, this.#redirectCount),
348
+ };
349
+ }
350
+
351
+ get classification(): DestinationClassification {
352
+ return this.#classification;
353
+ }
354
+
355
+ async acquireConnection(): Promise<HttpDestinationConnectionResult> {
356
+ const prepared = await prepareDestination(
357
+ this.#url.href,
358
+ {
359
+ maximumZone: this.#maximumZone,
360
+ resolver: this.#resolver,
361
+ remoteProvider: this.#remoteProvider,
362
+ env: this.#env,
363
+ },
364
+ this.#redirectCount
365
+ );
366
+ if (isPolicyDenial(prepared)) {
367
+ return denial(
368
+ "DNS_REBINDING",
369
+ prepared.classification,
370
+ this.#url,
371
+ this.#redirectCount
372
+ );
373
+ }
374
+ if (
375
+ prepared.addresses.length !== this.#addresses.length ||
376
+ prepared.addresses.some(
377
+ (address, index) => address !== this.#addresses[index]
378
+ )
379
+ ) {
380
+ return denial(
381
+ "DNS_REBINDING",
382
+ prepared.classification,
383
+ this.#url,
384
+ this.#redirectCount
385
+ );
386
+ }
387
+
388
+ const address = this.#addresses[0];
389
+ if (!address) {
390
+ return denial(
391
+ "DNS_UNRESOLVED",
392
+ this.#classification,
393
+ this.#url,
394
+ this.#redirectCount
395
+ );
396
+ }
397
+ const target = new URL(this.#url.href);
398
+ target.hostname = address.includes(":") ? `[${address}]` : address;
399
+ return {
400
+ ok: true,
401
+ value: new PinnedHttpConnection(
402
+ target.href,
403
+ this.#url.host,
404
+ isNetworkAddress(this.#hostname) ? undefined : this.#hostname,
405
+ createAudit(this.#classification, this.#url, this.#redirectCount)
406
+ ),
407
+ };
408
+ }
409
+
410
+ async followRedirect(location: string): Promise<HttpDestinationPolicyResult> {
411
+ if (this.#redirectCount >= MAX_HTTP_DESTINATION_REDIRECTS) {
412
+ return denial(
413
+ "REDIRECT_LIMIT",
414
+ this.#classification,
415
+ this.#url,
416
+ this.#redirectCount
417
+ );
418
+ }
419
+ let nextUrl: URL;
420
+ try {
421
+ nextUrl = new URL(location, this.#url);
422
+ } catch {
423
+ return denial(
424
+ "INVALID_DESTINATION",
425
+ invalidClassification(),
426
+ null,
427
+ this.#redirectCount + 1
428
+ );
429
+ }
430
+ if (this.#url.protocol === "https:" && nextUrl.protocol === "http:") {
431
+ return denial(
432
+ "HTTPS_DOWNGRADE",
433
+ invalidClassification(),
434
+ nextUrl,
435
+ this.#redirectCount + 1
436
+ );
437
+ }
438
+ if (
439
+ this.#remoteProvider &&
440
+ nextUrl.origin.toLowerCase() !== this.#url.origin.toLowerCase()
441
+ ) {
442
+ return denial(
443
+ "PROVIDER_REDIRECT_ORIGIN_CHANGED",
444
+ invalidClassification(),
445
+ nextUrl,
446
+ this.#redirectCount + 1
447
+ );
448
+ }
449
+ const next = await prepareHttpDestination(nextUrl.href, {
450
+ maximumZone: this.#maximumZone,
451
+ resolver: this.#resolver,
452
+ remoteProvider: this.#remoteProvider,
453
+ env: this.#env,
454
+ redirectCount: this.#redirectCount + 1,
455
+ });
456
+ if (!next.ok) return next;
457
+ if (next.value.classification.zone !== this.#classificationZone) {
458
+ return denial(
459
+ "REDIRECT_ZONE_CHANGED",
460
+ next.value.classification,
461
+ nextUrl,
462
+ this.#redirectCount + 1
463
+ );
464
+ }
465
+ return next;
466
+ }
467
+ }
468
+
469
+ export async function prepareHttpDestination(
470
+ rawUrl: string,
471
+ options: HttpDestinationPolicyOptions & { redirectCount?: number }
472
+ ): Promise<HttpDestinationPolicyResult> {
473
+ const redirectCount =
474
+ options.redirectCount === undefined ? 0 : options.redirectCount;
475
+ if (!Number.isSafeInteger(redirectCount) || redirectCount < 0) {
476
+ return denial("INVALID_REDIRECT_COUNT", invalidClassification(), null, 0);
477
+ }
478
+ if (redirectCount > MAX_HTTP_DESTINATION_REDIRECTS) {
479
+ return denial(
480
+ "REDIRECT_LIMIT",
481
+ invalidClassification(),
482
+ null,
483
+ redirectCount
484
+ );
485
+ }
486
+ const resolvedOptions = {
487
+ maximumZone: options.maximumZone,
488
+ resolver: options.resolver ?? defaultResolver,
489
+ remoteProvider: options.remoteProvider ?? false,
490
+ env: options.env ?? process.env,
491
+ };
492
+ const prepared = await prepareDestination(
493
+ rawUrl,
494
+ resolvedOptions,
495
+ redirectCount
496
+ );
497
+ if (isPolicyDenial(prepared)) return prepared;
498
+ return {
499
+ ok: true,
500
+ reason: "DESTINATION_ALLOWED",
501
+ value: new HttpDestinationPin(prepared, resolvedOptions, redirectCount),
502
+ };
503
+ }
504
+
505
+ /**
506
+ * Resolve and classify an HTTP destination without opening a socket or
507
+ * transferring request headers/body. This bounded DNS-only phase lets the
508
+ * collection policy evaluate a hostname's proven zone before transport setup.
509
+ */
510
+ export async function classifyHttpDestination(
511
+ rawUrl: string,
512
+ options: Pick<HttpDestinationPolicyOptions, "resolver">
513
+ ): Promise<HttpDestinationClassificationResult> {
514
+ const url = parseHttpUrl(rawUrl);
515
+ const invalid = invalidClassification();
516
+ if (!url) return denial("INVALID_DESTINATION", invalid, null, 0);
517
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
518
+ return denial("UNSUPPORTED_PROTOCOL", invalid, url, 0);
519
+ }
520
+ if (url.username || url.password) {
521
+ return denial("CREDENTIALS_IN_URL", invalid, url, 0);
522
+ }
523
+ const resolved = await resolveAddresses(
524
+ url,
525
+ options.resolver ?? defaultResolver
526
+ );
527
+ if (!resolved.ok) return denial(resolved.reason, invalid, url, 0);
528
+ return {
529
+ ok: true,
530
+ reason: "DESTINATION_ALLOWED",
531
+ classification: classifyDestination({
532
+ kind: "network",
533
+ hostname: resolved.hostname,
534
+ addresses: resolved.addresses,
535
+ }),
536
+ };
537
+ }
@@ -5,9 +5,12 @@
5
5
  * @module src/llm/httpEmbedding
6
6
  */
7
7
 
8
+ import type { HttpInferenceOptions } from "./http-inference";
8
9
  import type { EmbeddingPort, LlmResult } from "./types";
9
10
 
10
- import { inferenceFailedError } from "./errors";
11
+ import { EgressDeniedError } from "../core/egress-enforcement";
12
+ import { egressDeniedInferenceError, inferenceFailedError } from "./errors";
13
+ import { requestHttpInference } from "./http-inference";
11
14
 
12
15
  // ─────────────────────────────────────────────────────────────────────────────
13
16
  // Types
@@ -34,11 +37,13 @@ interface OpenAIEmbeddingResponse {
34
37
  export class HttpEmbedding implements EmbeddingPort {
35
38
  private readonly apiUrl: string;
36
39
  private readonly modelName: string;
40
+ private readonly requestOptions: HttpInferenceOptions;
37
41
  private dims: number | null = null;
38
42
  readonly modelUri: string;
39
43
 
40
- constructor(modelUri: string) {
44
+ constructor(modelUri: string, requestOptions: HttpInferenceOptions) {
41
45
  this.modelUri = modelUri;
46
+ this.requestOptions = requestOptions;
42
47
  // Parse URI: http://host:port/v1/embeddings#modelname or just http://host:port
43
48
  const hashIndex = modelUri.indexOf("#");
44
49
  if (hashIndex > 0) {
@@ -64,16 +69,20 @@ export class HttpEmbedding implements EmbeddingPort {
64
69
 
65
70
  async embed(text: string): Promise<LlmResult<number[]>> {
66
71
  try {
67
- const response = await fetch(this.apiUrl, {
68
- method: "POST",
69
- headers: {
70
- "Content-Type": "application/json",
72
+ const response = await requestHttpInference(
73
+ this.apiUrl,
74
+ {
75
+ method: "POST",
76
+ headers: {
77
+ "Content-Type": "application/json",
78
+ },
79
+ body: JSON.stringify({
80
+ input: text,
81
+ model: this.modelName,
82
+ }),
71
83
  },
72
- body: JSON.stringify({
73
- input: text,
74
- model: this.modelName,
75
- }),
76
- });
84
+ this.requestOptions
85
+ );
77
86
 
78
87
  if (!response.ok) {
79
88
  const errorText = await response.text();
@@ -108,26 +117,33 @@ export class HttpEmbedding implements EmbeddingPort {
108
117
  } catch (e) {
109
118
  return {
110
119
  ok: false,
111
- error: inferenceFailedError(
112
- this.modelUri,
113
- e instanceof Error ? e : new Error(String(e))
114
- ),
120
+ error:
121
+ e instanceof EgressDeniedError
122
+ ? egressDeniedInferenceError(e)
123
+ : inferenceFailedError(
124
+ this.modelUri,
125
+ e instanceof Error ? e : new Error(String(e))
126
+ ),
115
127
  };
116
128
  }
117
129
  }
118
130
 
119
131
  async embedBatch(texts: string[]): Promise<LlmResult<number[][]>> {
120
132
  try {
121
- const response = await fetch(this.apiUrl, {
122
- method: "POST",
123
- headers: {
124
- "Content-Type": "application/json",
133
+ const response = await requestHttpInference(
134
+ this.apiUrl,
135
+ {
136
+ method: "POST",
137
+ headers: {
138
+ "Content-Type": "application/json",
139
+ },
140
+ body: JSON.stringify({
141
+ input: texts,
142
+ model: this.modelName,
143
+ }),
125
144
  },
126
- body: JSON.stringify({
127
- input: texts,
128
- model: this.modelName,
129
- }),
130
- });
145
+ this.requestOptions
146
+ );
131
147
 
132
148
  if (!response.ok) {
133
149
  const errorText = await response.text();
@@ -168,10 +184,13 @@ export class HttpEmbedding implements EmbeddingPort {
168
184
  } catch (e) {
169
185
  return {
170
186
  ok: false,
171
- error: inferenceFailedError(
172
- this.modelUri,
173
- e instanceof Error ? e : new Error(String(e))
174
- ),
187
+ error:
188
+ e instanceof EgressDeniedError
189
+ ? egressDeniedInferenceError(e)
190
+ : inferenceFailedError(
191
+ this.modelUri,
192
+ e instanceof Error ? e : new Error(String(e))
193
+ ),
175
194
  };
176
195
  }
177
196
  }