@gmickel/gno 1.28.0 → 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 (143) hide show
  1. package/README.md +46 -20
  2. package/assets/skill/SKILL.md +33 -0
  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 +1 -1
  10. package/spec/cli.md +41 -1
  11. package/spec/db/schema.sql +19 -0
  12. package/spec/mcp.md +66 -0
  13. package/spec/output-schemas/ask.schema.json +31 -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 +89 -2
  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/mcp-http-error.schema.json +113 -2
  22. package/spec/output-schemas/publish-artifact.schema.json +32 -0
  23. package/spec/output-schemas/record-import.schema.json +30 -0
  24. package/spec/output-schemas/retrieval-trace-common.schema.json +31 -0
  25. package/spec/output-schemas/retrieval-trace-export.schema.json +3 -0
  26. package/spec/output-schemas/retrieval-trace-qrels.schema.json +3 -0
  27. package/spec/output-schemas/retrieval-trace-summary.schema.json +4 -0
  28. package/spec/output-schemas/search-results.schema.json +30 -0
  29. package/src/app/context-runtime-contract.ts +16 -7
  30. package/src/cli/commands/ask.ts +7 -0
  31. package/src/cli/commands/collection/index.ts +5 -0
  32. package/src/cli/commands/collection/policy.ts +126 -0
  33. package/src/cli/commands/context-build.ts +5 -0
  34. package/src/cli/commands/daemon.ts +45 -3
  35. package/src/cli/commands/egress-audit.ts +68 -0
  36. package/src/cli/commands/embed.ts +2 -0
  37. package/src/cli/commands/query.ts +12 -0
  38. package/src/cli/commands/replay.ts +13 -3
  39. package/src/cli/commands/vsearch.ts +3 -1
  40. package/src/cli/program.ts +123 -0
  41. package/src/config/index.ts +9 -0
  42. package/src/config/types.ts +50 -0
  43. package/src/core/browser-clip.ts +16 -1
  44. package/src/core/collection-egress-policy-projection.ts +28 -0
  45. package/src/core/collection-egress-policy-service.ts +443 -0
  46. package/src/core/collection-egress-policy-validation.ts +242 -0
  47. package/src/core/config-mutation.ts +31 -19
  48. package/src/core/context-capsule-schema.ts +111 -1
  49. package/src/core/context-capsule-validation.ts +4 -4
  50. package/src/core/context-capsule-verification.ts +5 -1
  51. package/src/core/context-capsule.ts +177 -132
  52. package/src/core/context-evidence.ts +86 -14
  53. package/src/core/destination-classifier.ts +402 -0
  54. package/src/core/egress-audit.ts +239 -0
  55. package/src/core/egress-authorization.ts +50 -0
  56. package/src/core/egress-enforcement.ts +264 -0
  57. package/src/core/egress-policy.ts +440 -0
  58. package/src/core/egress-provenance.ts +336 -0
  59. package/src/core/job-manager.ts +37 -0
  60. package/src/core/network-boundary-inventory.ts +261 -0
  61. package/src/core/retrieval-qrels.ts +6 -0
  62. package/src/core/retrieval-replay.ts +18 -4
  63. package/src/core/retrieval-trace-export.ts +23 -4
  64. package/src/core/retrieval-trace-management-helpers.ts +1 -0
  65. package/src/core/retrieval-trace-management-types.ts +3 -0
  66. package/src/core/retrieval-trace-management.ts +9 -1
  67. package/src/core/retrieval-trace-session.ts +94 -0
  68. package/src/core/retrieval-trace.ts +22 -0
  69. package/src/ingestion/record-adapter.ts +12 -1
  70. package/src/ingestion/record-container.ts +38 -22
  71. package/src/ingestion/sync.ts +15 -0
  72. package/src/ingestion/types.ts +2 -0
  73. package/src/llm/errors.ts +10 -0
  74. package/src/llm/http-inference.ts +175 -0
  75. package/src/llm/http-policy.ts +537 -0
  76. package/src/llm/httpEmbedding.ts +47 -28
  77. package/src/llm/httpGeneration.ts +31 -18
  78. package/src/llm/httpRerank.ts +30 -18
  79. package/src/llm/index.ts +1 -0
  80. package/src/llm/nodeLlamaCpp/adapter.ts +55 -20
  81. package/src/llm/pinned-http-connection.ts +177 -0
  82. package/src/mcp/context.ts +53 -1
  83. package/src/mcp/http-egress.ts +220 -0
  84. package/src/mcp/http-security.ts +32 -21
  85. package/src/mcp/http-session.ts +13 -2
  86. package/src/mcp/http-transport.ts +96 -6
  87. package/src/mcp/sync-egress.ts +24 -0
  88. package/src/mcp/tools/add-collection.ts +4 -0
  89. package/src/mcp/tools/ask.ts +1 -1
  90. package/src/mcp/tools/context.ts +9 -2
  91. package/src/mcp/tools/egress.ts +247 -0
  92. package/src/mcp/tools/embed.ts +1 -0
  93. package/src/mcp/tools/index-cmd.ts +7 -0
  94. package/src/mcp/tools/index.ts +98 -0
  95. package/src/mcp/tools/query.ts +10 -0
  96. package/src/mcp/tools/remove-collection.ts +4 -0
  97. package/src/mcp/tools/sync.ts +6 -0
  98. package/src/mcp/tools/trace.ts +7 -1
  99. package/src/mcp/tools/vsearch.ts +1 -0
  100. package/src/pipeline/egress-lineage.ts +124 -0
  101. package/src/pipeline/hybrid.ts +14 -0
  102. package/src/pipeline/search.ts +12 -0
  103. package/src/pipeline/types.ts +3 -0
  104. package/src/pipeline/vsearch.ts +41 -20
  105. package/src/publish/artifact.ts +31 -3
  106. package/src/publish/export-service.ts +25 -0
  107. package/src/sdk/client.ts +119 -2
  108. package/src/sdk/embed.ts +1 -0
  109. package/src/sdk/types.ts +33 -0
  110. package/src/serve/clipper-capture.ts +5 -1
  111. package/src/serve/closed-json.ts +61 -0
  112. package/src/serve/config-sync.ts +4 -1
  113. package/src/serve/context.ts +1 -0
  114. package/src/serve/doc-events.ts +110 -39
  115. package/src/serve/public/components/CollectionEgressCheckPanel.tsx +411 -0
  116. package/src/serve/public/components/CollectionEgressPolicyEditor.tsx +78 -0
  117. package/src/serve/public/components/CollectionModelDialog.tsx +93 -9
  118. package/src/serve/public/components/EgressAuditPanel.tsx +278 -0
  119. package/src/serve/public/globals.built.css +1 -1
  120. package/src/serve/public/pages/Collections.tsx +3 -0
  121. package/src/serve/resident-request.ts +80 -3
  122. package/src/serve/resident-runtime.ts +63 -1
  123. package/src/serve/routes/api.ts +174 -0
  124. package/src/serve/routes/clipper.ts +64 -1
  125. package/src/serve/routes/mcp.ts +6 -0
  126. package/src/serve/routes/traces.ts +27 -4
  127. package/src/serve/server.ts +118 -7
  128. package/src/store/migrations/023-collection-egress-policy.ts +61 -0
  129. package/src/store/migrations/024-egress-derived-lineage.ts +121 -0
  130. package/src/store/migrations/025-collection-egress-policy-revision.ts +40 -0
  131. package/src/store/migrations/index.ts +6 -0
  132. package/src/store/retrieval-trace-codec.ts +15 -0
  133. package/src/store/sqlite/adapter.ts +170 -7
  134. package/src/store/sqlite/change-journal-store.ts +41 -2
  135. package/src/store/sqlite/egress-audit-store.ts +485 -0
  136. package/src/store/sqlite/retrieval-trace-management-store.ts +47 -6
  137. package/src/store/sqlite/retrieval-trace-retention.ts +2 -0
  138. package/src/store/sqlite/retrieval-trace-rows.ts +8 -1
  139. package/src/store/sqlite/retrieval-trace-store.ts +104 -5
  140. package/src/store/types.ts +126 -1
  141. package/browser-extension/artifacts/gno-browser-clipper-v1.28.0.zip +0 -0
  142. package/browser-extension/artifacts/gno-browser-clipper-v1.28.0.zip.sha256 +0 -1
  143. package/browser-extension/dist/chunk-vn5f663b.js +0 -50
@@ -0,0 +1,175 @@
1
+ /** Policy-gated, DNS-pinned HTTP transport shared by inference adapters. */
2
+
3
+ import type { Collection } from "../config/types";
4
+ import type { CollectionEgressState } from "../core/egress-policy";
5
+ import type { EgressDestinationZone } from "../core/egress-policy";
6
+ import type {
7
+ HttpDestinationPolicyOptions,
8
+ HttpDestinationResolver,
9
+ PinnedHttpFetch,
10
+ } from "./http-policy";
11
+
12
+ import {
13
+ collectionEgressStates,
14
+ EgressDeniedError,
15
+ maximumDestinationZoneForCollections,
16
+ } from "../core/egress-enforcement";
17
+ import { evaluateEgressPolicy } from "../core/egress-policy";
18
+ import { EgressProvenanceError } from "../core/egress-provenance";
19
+ import { classifyHttpDestination, prepareHttpDestination } from "./http-policy";
20
+
21
+ const REDIRECT_STATUSES = new Set([301, 302, 307, 308]);
22
+
23
+ export interface HttpInferenceOptions {
24
+ collections: readonly Collection[];
25
+ collectionNames: readonly string[];
26
+ authenticated?: boolean;
27
+ operationAuthorized?: boolean;
28
+ resolver?: HttpDestinationResolver;
29
+ fetchFn?: PinnedHttpFetch;
30
+ env?: HttpDestinationPolicyOptions["env"];
31
+ }
32
+
33
+ const scopedStates = (
34
+ options: HttpInferenceOptions
35
+ ): readonly CollectionEgressState[] => {
36
+ try {
37
+ return collectionEgressStates(options.collections, options.collectionNames);
38
+ } catch (error) {
39
+ if (error instanceof EgressProvenanceError) return [];
40
+ throw error;
41
+ }
42
+ };
43
+
44
+ const enforce = (
45
+ states: readonly CollectionEgressState[],
46
+ zone: EgressDestinationZone,
47
+ options: HttpInferenceOptions
48
+ ): void => {
49
+ const decision = evaluateEgressPolicy({
50
+ collections: states,
51
+ action: "remote_inference",
52
+ destination: { zone },
53
+ caller: {
54
+ authenticated: options.authenticated ?? true,
55
+ operationAuthorized: options.operationAuthorized ?? true,
56
+ },
57
+ contentClass: "source",
58
+ });
59
+ if (!decision.allowed) throw new EgressDeniedError(decision);
60
+ };
61
+
62
+ export const requestHttpInference = async (
63
+ rawUrl: string,
64
+ init: BunFetchRequestInit,
65
+ options: HttpInferenceOptions
66
+ ): Promise<Response> => {
67
+ const states = scopedStates(options);
68
+ if (states.length === 0) {
69
+ enforce(states, "remote", options);
70
+ }
71
+ const maximumZone = maximumDestinationZoneForCollections(
72
+ options.collections,
73
+ options.collectionNames
74
+ );
75
+
76
+ // DNS-only classification carries no headers, body, credentials, or model
77
+ // metadata. Policy evaluates the proven zone before transport preparation.
78
+ const classification = await classifyHttpDestination(rawUrl, {
79
+ resolver: options.resolver,
80
+ });
81
+ if (!classification.ok) {
82
+ throw new EgressDeniedError(
83
+ evaluateEgressPolicy({
84
+ collections: states,
85
+ action: "remote_inference",
86
+ destination: { zone: classification.classification.zone },
87
+ caller: {
88
+ authenticated: options.authenticated ?? true,
89
+ operationAuthorized: false,
90
+ },
91
+ contentClass: "source",
92
+ })
93
+ );
94
+ }
95
+ enforce(states, classification.classification.zone, options);
96
+ const url = new URL(rawUrl);
97
+
98
+ let prepared = await prepareHttpDestination(url.href, {
99
+ maximumZone,
100
+ resolver: options.resolver,
101
+ remoteProvider: classification.classification.zone === "remote",
102
+ env: options.env,
103
+ });
104
+ if (!prepared.ok) {
105
+ throw new EgressDeniedError(
106
+ evaluateEgressPolicy({
107
+ collections: states,
108
+ action: "remote_inference",
109
+ destination: { zone: prepared.classification.zone },
110
+ caller: {
111
+ authenticated: options.authenticated ?? true,
112
+ operationAuthorized: false,
113
+ },
114
+ contentClass: "source",
115
+ })
116
+ );
117
+ }
118
+
119
+ let currentUrl = url;
120
+ for (;;) {
121
+ enforce(states, prepared.value.classification.zone, options);
122
+ const connection = await prepared.value.acquireConnection();
123
+ if (!connection.ok) {
124
+ throw new EgressDeniedError(
125
+ evaluateEgressPolicy({
126
+ collections: states,
127
+ action: "remote_inference",
128
+ destination: { zone: connection.classification.zone },
129
+ caller: {
130
+ authenticated: options.authenticated ?? true,
131
+ operationAuthorized: false,
132
+ },
133
+ contentClass: "source",
134
+ })
135
+ );
136
+ }
137
+ const response = await connection.value.request(init, options.fetchFn);
138
+ if (!REDIRECT_STATUSES.has(response.status)) return response;
139
+
140
+ const location = response.headers.get("location");
141
+ if (!location) return response;
142
+ const nextUrl = new URL(location, currentUrl);
143
+ if (nextUrl.origin.toLowerCase() !== currentUrl.origin.toLowerCase()) {
144
+ throw new EgressDeniedError(
145
+ evaluateEgressPolicy({
146
+ collections: states,
147
+ action: "remote_inference",
148
+ destination: { zone: prepared.value.classification.zone },
149
+ caller: {
150
+ authenticated: options.authenticated ?? true,
151
+ operationAuthorized: false,
152
+ },
153
+ contentClass: "source",
154
+ })
155
+ );
156
+ }
157
+ const next = await prepared.value.followRedirect(nextUrl.href);
158
+ if (!next.ok) {
159
+ throw new EgressDeniedError(
160
+ evaluateEgressPolicy({
161
+ collections: states,
162
+ action: "remote_inference",
163
+ destination: { zone: next.classification.zone },
164
+ caller: {
165
+ authenticated: options.authenticated ?? true,
166
+ operationAuthorized: false,
167
+ },
168
+ contentClass: "source",
169
+ })
170
+ );
171
+ }
172
+ currentUrl = nextUrl;
173
+ prepared = next;
174
+ }
175
+ };
@@ -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
+ }