@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.
- package/README.md +46 -20
- package/assets/skill/SKILL.md +33 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.29.0.zip +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.29.0.zip.sha256 +1 -0
- package/browser-extension/dist/chunk-b2zm0jjd.js +50 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/browser-extension/dist/preview.html +1 -1
- package/browser-extension/dist/service-worker.js +6 -6
- package/package.json +1 -1
- package/spec/cli.md +41 -1
- package/spec/db/schema.sql +19 -0
- package/spec/mcp.md +66 -0
- package/spec/output-schemas/ask.schema.json +31 -1
- package/spec/output-schemas/browser-clip-preview.schema.json +30 -0
- package/spec/output-schemas/collection-egress-check.schema.json +91 -0
- package/spec/output-schemas/collection-egress-policy-set.schema.json +56 -0
- package/spec/output-schemas/collection-egress-policy.schema.json +41 -0
- package/spec/output-schemas/context-capsule-v1.schema.json +89 -2
- package/spec/output-schemas/context-capsule-verification.schema.json +1 -1
- package/spec/output-schemas/egress-audit-management.schema.json +88 -0
- package/spec/output-schemas/mcp-http-error.schema.json +113 -2
- package/spec/output-schemas/publish-artifact.schema.json +32 -0
- package/spec/output-schemas/record-import.schema.json +30 -0
- package/spec/output-schemas/retrieval-trace-common.schema.json +31 -0
- package/spec/output-schemas/retrieval-trace-export.schema.json +3 -0
- package/spec/output-schemas/retrieval-trace-qrels.schema.json +3 -0
- package/spec/output-schemas/retrieval-trace-summary.schema.json +4 -0
- package/spec/output-schemas/search-results.schema.json +30 -0
- package/src/app/context-runtime-contract.ts +16 -7
- package/src/cli/commands/ask.ts +7 -0
- package/src/cli/commands/collection/index.ts +5 -0
- package/src/cli/commands/collection/policy.ts +126 -0
- package/src/cli/commands/context-build.ts +5 -0
- package/src/cli/commands/daemon.ts +45 -3
- package/src/cli/commands/egress-audit.ts +68 -0
- package/src/cli/commands/embed.ts +2 -0
- package/src/cli/commands/query.ts +12 -0
- package/src/cli/commands/replay.ts +13 -3
- package/src/cli/commands/vsearch.ts +3 -1
- package/src/cli/program.ts +123 -0
- package/src/config/index.ts +9 -0
- package/src/config/types.ts +50 -0
- package/src/core/browser-clip.ts +16 -1
- package/src/core/collection-egress-policy-projection.ts +28 -0
- package/src/core/collection-egress-policy-service.ts +443 -0
- package/src/core/collection-egress-policy-validation.ts +242 -0
- package/src/core/config-mutation.ts +31 -19
- package/src/core/context-capsule-schema.ts +111 -1
- package/src/core/context-capsule-validation.ts +4 -4
- package/src/core/context-capsule-verification.ts +5 -1
- package/src/core/context-capsule.ts +177 -132
- package/src/core/context-evidence.ts +86 -14
- package/src/core/destination-classifier.ts +402 -0
- package/src/core/egress-audit.ts +239 -0
- package/src/core/egress-authorization.ts +50 -0
- package/src/core/egress-enforcement.ts +264 -0
- package/src/core/egress-policy.ts +440 -0
- package/src/core/egress-provenance.ts +336 -0
- package/src/core/job-manager.ts +37 -0
- package/src/core/network-boundary-inventory.ts +261 -0
- package/src/core/retrieval-qrels.ts +6 -0
- package/src/core/retrieval-replay.ts +18 -4
- package/src/core/retrieval-trace-export.ts +23 -4
- package/src/core/retrieval-trace-management-helpers.ts +1 -0
- package/src/core/retrieval-trace-management-types.ts +3 -0
- package/src/core/retrieval-trace-management.ts +9 -1
- package/src/core/retrieval-trace-session.ts +94 -0
- package/src/core/retrieval-trace.ts +22 -0
- package/src/ingestion/record-adapter.ts +12 -1
- package/src/ingestion/record-container.ts +38 -22
- package/src/ingestion/sync.ts +15 -0
- package/src/ingestion/types.ts +2 -0
- package/src/llm/errors.ts +10 -0
- package/src/llm/http-inference.ts +175 -0
- package/src/llm/http-policy.ts +537 -0
- package/src/llm/httpEmbedding.ts +47 -28
- package/src/llm/httpGeneration.ts +31 -18
- package/src/llm/httpRerank.ts +30 -18
- package/src/llm/index.ts +1 -0
- package/src/llm/nodeLlamaCpp/adapter.ts +55 -20
- package/src/llm/pinned-http-connection.ts +177 -0
- package/src/mcp/context.ts +53 -1
- package/src/mcp/http-egress.ts +220 -0
- package/src/mcp/http-security.ts +32 -21
- package/src/mcp/http-session.ts +13 -2
- package/src/mcp/http-transport.ts +96 -6
- package/src/mcp/sync-egress.ts +24 -0
- package/src/mcp/tools/add-collection.ts +4 -0
- package/src/mcp/tools/ask.ts +1 -1
- package/src/mcp/tools/context.ts +9 -2
- package/src/mcp/tools/egress.ts +247 -0
- package/src/mcp/tools/embed.ts +1 -0
- package/src/mcp/tools/index-cmd.ts +7 -0
- package/src/mcp/tools/index.ts +98 -0
- package/src/mcp/tools/query.ts +10 -0
- package/src/mcp/tools/remove-collection.ts +4 -0
- package/src/mcp/tools/sync.ts +6 -0
- package/src/mcp/tools/trace.ts +7 -1
- package/src/mcp/tools/vsearch.ts +1 -0
- package/src/pipeline/egress-lineage.ts +124 -0
- package/src/pipeline/hybrid.ts +14 -0
- package/src/pipeline/search.ts +12 -0
- package/src/pipeline/types.ts +3 -0
- package/src/pipeline/vsearch.ts +41 -20
- package/src/publish/artifact.ts +31 -3
- package/src/publish/export-service.ts +25 -0
- package/src/sdk/client.ts +119 -2
- package/src/sdk/embed.ts +1 -0
- package/src/sdk/types.ts +33 -0
- package/src/serve/clipper-capture.ts +5 -1
- package/src/serve/closed-json.ts +61 -0
- package/src/serve/config-sync.ts +4 -1
- package/src/serve/context.ts +1 -0
- package/src/serve/doc-events.ts +110 -39
- package/src/serve/public/components/CollectionEgressCheckPanel.tsx +411 -0
- package/src/serve/public/components/CollectionEgressPolicyEditor.tsx +78 -0
- package/src/serve/public/components/CollectionModelDialog.tsx +93 -9
- package/src/serve/public/components/EgressAuditPanel.tsx +278 -0
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/pages/Collections.tsx +3 -0
- package/src/serve/resident-request.ts +80 -3
- package/src/serve/resident-runtime.ts +63 -1
- package/src/serve/routes/api.ts +174 -0
- package/src/serve/routes/clipper.ts +64 -1
- package/src/serve/routes/mcp.ts +6 -0
- package/src/serve/routes/traces.ts +27 -4
- package/src/serve/server.ts +118 -7
- package/src/store/migrations/023-collection-egress-policy.ts +61 -0
- package/src/store/migrations/024-egress-derived-lineage.ts +121 -0
- package/src/store/migrations/025-collection-egress-policy-revision.ts +40 -0
- package/src/store/migrations/index.ts +6 -0
- package/src/store/retrieval-trace-codec.ts +15 -0
- package/src/store/sqlite/adapter.ts +170 -7
- package/src/store/sqlite/change-journal-store.ts +41 -2
- package/src/store/sqlite/egress-audit-store.ts +485 -0
- package/src/store/sqlite/retrieval-trace-management-store.ts +47 -6
- package/src/store/sqlite/retrieval-trace-retention.ts +2 -0
- package/src/store/sqlite/retrieval-trace-rows.ts +8 -1
- package/src/store/sqlite/retrieval-trace-store.ts +104 -5
- package/src/store/types.ts +126 -1
- package/browser-extension/artifacts/gno-browser-clipper-v1.28.0.zip +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.28.0.zip.sha256 +0 -1
- 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
|
+
}
|