@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,402 @@
|
|
|
1
|
+
/** Conservative, redacted classification for network and bind destinations. */
|
|
2
|
+
|
|
3
|
+
// node:net provides the platform IP parser; Bun has no equivalent address parser.
|
|
4
|
+
import { isIP } from "node:net";
|
|
5
|
+
|
|
6
|
+
import type { EgressDestinationZone } from "./egress-policy";
|
|
7
|
+
|
|
8
|
+
export const DESTINATION_ADDRESS_CLASSES = [
|
|
9
|
+
"loopback",
|
|
10
|
+
"private",
|
|
11
|
+
"tailscale",
|
|
12
|
+
"public",
|
|
13
|
+
"unknown",
|
|
14
|
+
] as const;
|
|
15
|
+
export type DestinationAddressClass =
|
|
16
|
+
(typeof DESTINATION_ADDRESS_CLASSES)[number];
|
|
17
|
+
|
|
18
|
+
export const DESTINATION_CLASSIFICATION_REASONS = [
|
|
19
|
+
"LOCAL_PROCESS",
|
|
20
|
+
"LOOPBACK_ADDRESS",
|
|
21
|
+
"PRIVATE_ADDRESS",
|
|
22
|
+
"TAILSCALE_ADDRESS",
|
|
23
|
+
"PUBLIC_ADDRESS",
|
|
24
|
+
"UNPROVEN_REMOTE",
|
|
25
|
+
"MIXED_DNS_ANSWERS",
|
|
26
|
+
"REMOTE_PROVIDER",
|
|
27
|
+
"WILDCARD_BIND",
|
|
28
|
+
] as const;
|
|
29
|
+
export type DestinationClassificationReason =
|
|
30
|
+
(typeof DESTINATION_CLASSIFICATION_REASONS)[number];
|
|
31
|
+
|
|
32
|
+
export interface DestinationClassificationAudit {
|
|
33
|
+
readonly source: "local_process" | "bind" | "literal" | "dns" | "provider";
|
|
34
|
+
readonly hostnameKind: "none" | "ip_literal" | "dns_name";
|
|
35
|
+
readonly addressCount: number;
|
|
36
|
+
readonly addressClasses: readonly DestinationAddressClass[];
|
|
37
|
+
readonly mixed: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface DestinationClassification {
|
|
41
|
+
readonly zone: EgressDestinationZone;
|
|
42
|
+
readonly addressClass: DestinationAddressClass;
|
|
43
|
+
readonly reason: DestinationClassificationReason;
|
|
44
|
+
readonly audit: DestinationClassificationAudit;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface DestinationClassifierInput {
|
|
48
|
+
kind?: "network" | "bind" | "local_process";
|
|
49
|
+
hostname?: string;
|
|
50
|
+
addresses?: readonly string[];
|
|
51
|
+
remoteProvider?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const ADDRESS_CLASS_ORDER = new Map<DestinationAddressClass, number>(
|
|
55
|
+
DESTINATION_ADDRESS_CLASSES.map((value, index) => [value, index])
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
function stripAddressDecoration(address: string): string {
|
|
59
|
+
const unwrapped =
|
|
60
|
+
address.startsWith("[") && address.endsWith("]")
|
|
61
|
+
? address.slice(1, -1)
|
|
62
|
+
: address;
|
|
63
|
+
return unwrapped.split("%", 1)[0]?.toLowerCase() ?? "";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseIpv4(address: string): readonly number[] | null {
|
|
67
|
+
const octets = address.split(".");
|
|
68
|
+
if (
|
|
69
|
+
octets.length !== 4 ||
|
|
70
|
+
octets.some(
|
|
71
|
+
(octet) => !/^(?:0|[1-9]\d{0,2})$/u.test(octet) || Number(octet) > 255
|
|
72
|
+
)
|
|
73
|
+
) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return octets.map(Number);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseIpv6(address: string): readonly number[] | null {
|
|
80
|
+
let normalized = stripAddressDecoration(address);
|
|
81
|
+
if (isIP(normalized) !== 6) return null;
|
|
82
|
+
|
|
83
|
+
if (normalized.includes(".")) {
|
|
84
|
+
const separator = normalized.lastIndexOf(":");
|
|
85
|
+
const ipv4 = parseIpv4(normalized.slice(separator + 1));
|
|
86
|
+
if (!ipv4) return null;
|
|
87
|
+
const high = ((ipv4[0] ?? 0) << 8) | (ipv4[1] ?? 0);
|
|
88
|
+
const low = ((ipv4[2] ?? 0) << 8) | (ipv4[3] ?? 0);
|
|
89
|
+
normalized = `${normalized.slice(0, separator)}:${high.toString(
|
|
90
|
+
16
|
|
91
|
+
)}:${low.toString(16)}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const halves = normalized.split("::");
|
|
95
|
+
if (halves.length > 2) return null;
|
|
96
|
+
const left = halves[0] ? halves[0].split(":") : [];
|
|
97
|
+
const right = halves[1] ? halves[1].split(":") : [];
|
|
98
|
+
const missing = 8 - left.length - right.length;
|
|
99
|
+
if (
|
|
100
|
+
(halves.length === 1 && missing !== 0) ||
|
|
101
|
+
(halves.length === 2 && missing < 1)
|
|
102
|
+
) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
const groups = [
|
|
106
|
+
...left,
|
|
107
|
+
...Array.from({ length: missing }, () => "0"),
|
|
108
|
+
...right,
|
|
109
|
+
];
|
|
110
|
+
if (
|
|
111
|
+
groups.length !== 8 ||
|
|
112
|
+
groups.some((group) => !/^[\da-f]{1,4}$/u.test(group))
|
|
113
|
+
) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
return groups.map((group) => Number.parseInt(group, 16));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface Ipv4SpecialPrefix {
|
|
120
|
+
readonly address: readonly [number, number, number, number];
|
|
121
|
+
readonly prefixLength: number;
|
|
122
|
+
readonly addressClass: Exclude<DestinationAddressClass, "public">;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// IANA IPv4 Special-Purpose Address Space, refreshed 2025-10-09. GNO
|
|
126
|
+
// conservatively keeps every registered block out of provider-public proof.
|
|
127
|
+
const IPV4_SPECIAL_PREFIXES = [
|
|
128
|
+
{ address: [0, 0, 0, 0], prefixLength: 8, addressClass: "unknown" },
|
|
129
|
+
{ address: [10, 0, 0, 0], prefixLength: 8, addressClass: "private" },
|
|
130
|
+
{ address: [100, 64, 0, 0], prefixLength: 10, addressClass: "unknown" },
|
|
131
|
+
{ address: [127, 0, 0, 0], prefixLength: 8, addressClass: "loopback" },
|
|
132
|
+
{ address: [169, 254, 0, 0], prefixLength: 16, addressClass: "unknown" },
|
|
133
|
+
{ address: [172, 16, 0, 0], prefixLength: 12, addressClass: "private" },
|
|
134
|
+
{ address: [192, 0, 0, 0], prefixLength: 24, addressClass: "unknown" },
|
|
135
|
+
{ address: [192, 0, 2, 0], prefixLength: 24, addressClass: "unknown" },
|
|
136
|
+
{ address: [192, 31, 196, 0], prefixLength: 24, addressClass: "unknown" },
|
|
137
|
+
{ address: [192, 52, 193, 0], prefixLength: 24, addressClass: "unknown" },
|
|
138
|
+
{ address: [192, 88, 99, 0], prefixLength: 24, addressClass: "unknown" },
|
|
139
|
+
{ address: [192, 168, 0, 0], prefixLength: 16, addressClass: "private" },
|
|
140
|
+
{ address: [192, 175, 48, 0], prefixLength: 24, addressClass: "unknown" },
|
|
141
|
+
{ address: [198, 18, 0, 0], prefixLength: 15, addressClass: "unknown" },
|
|
142
|
+
{ address: [198, 51, 100, 0], prefixLength: 24, addressClass: "unknown" },
|
|
143
|
+
{ address: [203, 0, 113, 0], prefixLength: 24, addressClass: "unknown" },
|
|
144
|
+
{ address: [224, 0, 0, 0], prefixLength: 3, addressClass: "unknown" },
|
|
145
|
+
] as const satisfies readonly Ipv4SpecialPrefix[];
|
|
146
|
+
|
|
147
|
+
function ipv4Integer(octets: readonly number[]): number {
|
|
148
|
+
return (
|
|
149
|
+
((((octets[0] ?? 0) << 24) >>> 0) |
|
|
150
|
+
((octets[1] ?? 0) << 16) |
|
|
151
|
+
((octets[2] ?? 0) << 8) |
|
|
152
|
+
(octets[3] ?? 0)) >>>
|
|
153
|
+
0
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function matchesIpv4Prefix(
|
|
158
|
+
octets: readonly number[],
|
|
159
|
+
prefix: Ipv4SpecialPrefix
|
|
160
|
+
): boolean {
|
|
161
|
+
const mask =
|
|
162
|
+
prefix.prefixLength === 0
|
|
163
|
+
? 0
|
|
164
|
+
: (0xffffffff << (32 - prefix.prefixLength)) >>> 0;
|
|
165
|
+
return (
|
|
166
|
+
(ipv4Integer(octets) & mask) >>> 0 ===
|
|
167
|
+
(ipv4Integer(prefix.address) & mask) >>> 0
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function classifyIpv4(octets: readonly number[]): DestinationAddressClass {
|
|
172
|
+
for (const prefix of IPV4_SPECIAL_PREFIXES) {
|
|
173
|
+
if (matchesIpv4Prefix(octets, prefix)) return prefix.addressClass;
|
|
174
|
+
}
|
|
175
|
+
return "public";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
interface Ipv6Prefix {
|
|
179
|
+
readonly groups: readonly number[];
|
|
180
|
+
readonly prefixLength: number;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// IANA IPv6 Special-Purpose Address Space, refreshed 2025-10-09. Mapped IPv4
|
|
184
|
+
// and the globally reachable NAT64 WKP are handled separately by target
|
|
185
|
+
// semantics; every prefix below remains ineligible for provider-public proof.
|
|
186
|
+
const IPV6_SPECIAL_PREFIXES = [
|
|
187
|
+
{ groups: [0x0100], prefixLength: 64 },
|
|
188
|
+
{ groups: [0x0100, 0, 0, 1], prefixLength: 64 },
|
|
189
|
+
{ groups: [0x2001], prefixLength: 23 },
|
|
190
|
+
{ groups: [0x2001, 0x0db8], prefixLength: 32 },
|
|
191
|
+
{ groups: [0x2002], prefixLength: 16 },
|
|
192
|
+
{ groups: [0x2620, 0x004f, 0x8000], prefixLength: 48 },
|
|
193
|
+
{ groups: [0x3fff], prefixLength: 20 },
|
|
194
|
+
{ groups: [0x5f00], prefixLength: 16 },
|
|
195
|
+
] as const satisfies readonly Ipv6Prefix[];
|
|
196
|
+
|
|
197
|
+
function matchesIpv6Prefix(
|
|
198
|
+
groups: readonly number[],
|
|
199
|
+
prefix: Ipv6Prefix
|
|
200
|
+
): boolean {
|
|
201
|
+
const fullGroups = Math.floor(prefix.prefixLength / 16);
|
|
202
|
+
for (let index = 0; index < fullGroups; index += 1) {
|
|
203
|
+
if ((groups[index] ?? 0) !== (prefix.groups[index] ?? 0)) return false;
|
|
204
|
+
}
|
|
205
|
+
const remainingBits = prefix.prefixLength % 16;
|
|
206
|
+
if (remainingBits === 0) return true;
|
|
207
|
+
const mask = (0xffff << (16 - remainingBits)) & 0xffff;
|
|
208
|
+
return (
|
|
209
|
+
((groups[fullGroups] ?? 0) & mask) ===
|
|
210
|
+
((prefix.groups[fullGroups] ?? 0) & mask)
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function embeddedIpv4Class(
|
|
215
|
+
groups: readonly number[],
|
|
216
|
+
highGroupIndex: number
|
|
217
|
+
): DestinationAddressClass {
|
|
218
|
+
const high = groups[highGroupIndex] ?? 0;
|
|
219
|
+
const low = groups[highGroupIndex + 1] ?? 0;
|
|
220
|
+
return classifyIpv4([high >> 8, high & 0xff, low >> 8, low & 0xff]);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function classifyIpv6(groups: readonly number[]): DestinationAddressClass {
|
|
224
|
+
const [first = -1, second = -1, third = -1] = groups;
|
|
225
|
+
const isLoopback =
|
|
226
|
+
groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1;
|
|
227
|
+
if (isLoopback) return "loopback";
|
|
228
|
+
const isUnspecified = groups.every((group) => group === 0);
|
|
229
|
+
if (isUnspecified || (first & 0xff00) === 0xff00) return "unknown";
|
|
230
|
+
const isMappedIpv4 =
|
|
231
|
+
groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff;
|
|
232
|
+
if (isMappedIpv4) {
|
|
233
|
+
return embeddedIpv4Class(groups, 6);
|
|
234
|
+
}
|
|
235
|
+
const isCompatibleIpv4 = groups.slice(0, 6).every((group) => group === 0);
|
|
236
|
+
if (isCompatibleIpv4) return "unknown";
|
|
237
|
+
const isPublicNat64 =
|
|
238
|
+
first === 0x0064 &&
|
|
239
|
+
second === 0xff9b &&
|
|
240
|
+
groups.slice(2, 6).every((group) => group === 0);
|
|
241
|
+
if (isPublicNat64) {
|
|
242
|
+
return embeddedIpv4Class(groups, 6) === "public" ? "public" : "unknown";
|
|
243
|
+
}
|
|
244
|
+
const isLocalNat64 =
|
|
245
|
+
first === 0x0064 && second === 0xff9b && third === 0x0001;
|
|
246
|
+
if (isLocalNat64 || first === 0x2002) return "unknown";
|
|
247
|
+
if (first === 0xfd7a && second === 0x115c && third === 0xa1e0) {
|
|
248
|
+
return "tailscale";
|
|
249
|
+
}
|
|
250
|
+
const isAwsInstanceMetadata =
|
|
251
|
+
first === 0xfd00 &&
|
|
252
|
+
second === 0x0ec2 &&
|
|
253
|
+
groups.slice(2, 7).every((group) => group === 0) &&
|
|
254
|
+
groups[7] === 0x0254;
|
|
255
|
+
if (isAwsInstanceMetadata) return "unknown";
|
|
256
|
+
if ((first & 0xfe00) === 0xfc00 || (first & 0xffc0) === 0xfe80) {
|
|
257
|
+
return "private";
|
|
258
|
+
}
|
|
259
|
+
if (
|
|
260
|
+
IPV6_SPECIAL_PREFIXES.some((prefix) => matchesIpv6Prefix(groups, prefix))
|
|
261
|
+
) {
|
|
262
|
+
return "unknown";
|
|
263
|
+
}
|
|
264
|
+
return matchesIpv6Prefix(groups, {
|
|
265
|
+
groups: [0x2000],
|
|
266
|
+
prefixLength: 3,
|
|
267
|
+
})
|
|
268
|
+
? "public"
|
|
269
|
+
: "unknown";
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function classifyNetworkAddress(
|
|
273
|
+
address: string
|
|
274
|
+
): DestinationAddressClass {
|
|
275
|
+
const normalized = stripAddressDecoration(address);
|
|
276
|
+
const family = isIP(normalized);
|
|
277
|
+
if (family === 4) {
|
|
278
|
+
const octets = parseIpv4(normalized);
|
|
279
|
+
return octets ? classifyIpv4(octets) : "unknown";
|
|
280
|
+
}
|
|
281
|
+
if (family === 6) {
|
|
282
|
+
const groups = parseIpv6(normalized);
|
|
283
|
+
return groups ? classifyIpv6(groups) : "unknown";
|
|
284
|
+
}
|
|
285
|
+
return "unknown";
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function isNetworkAddress(address: string): boolean {
|
|
289
|
+
return isIP(stripAddressDecoration(address)) !== 0;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function auditFor(
|
|
293
|
+
source: DestinationClassificationAudit["source"],
|
|
294
|
+
hostnameKind: DestinationClassificationAudit["hostnameKind"],
|
|
295
|
+
addressClasses: readonly DestinationAddressClass[],
|
|
296
|
+
addressCount: number
|
|
297
|
+
): DestinationClassificationAudit {
|
|
298
|
+
const uniqueClasses = [...new Set(addressClasses)].sort(
|
|
299
|
+
(left, right) =>
|
|
300
|
+
(ADDRESS_CLASS_ORDER.get(left) ?? 0) -
|
|
301
|
+
(ADDRESS_CLASS_ORDER.get(right) ?? 0)
|
|
302
|
+
);
|
|
303
|
+
return {
|
|
304
|
+
source,
|
|
305
|
+
hostnameKind,
|
|
306
|
+
addressCount,
|
|
307
|
+
addressClasses: uniqueClasses,
|
|
308
|
+
mixed: uniqueClasses.length > 1,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function classification(
|
|
313
|
+
zone: EgressDestinationZone,
|
|
314
|
+
addressClass: DestinationAddressClass,
|
|
315
|
+
reason: DestinationClassificationReason,
|
|
316
|
+
audit: DestinationClassificationAudit
|
|
317
|
+
): DestinationClassification {
|
|
318
|
+
return { zone, addressClass, reason, audit };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function classifyAddressSet(
|
|
322
|
+
classes: readonly DestinationAddressClass[],
|
|
323
|
+
audit: DestinationClassificationAudit
|
|
324
|
+
): DestinationClassification {
|
|
325
|
+
if (classes.length === 0 || classes.includes("unknown")) {
|
|
326
|
+
return classification("remote", "unknown", "UNPROVEN_REMOTE", audit);
|
|
327
|
+
}
|
|
328
|
+
if (classes.every((value) => value === "loopback")) {
|
|
329
|
+
return classification("loopback", "loopback", "LOOPBACK_ADDRESS", audit);
|
|
330
|
+
}
|
|
331
|
+
if (
|
|
332
|
+
classes.every((value) => value === "private") ||
|
|
333
|
+
classes.every((value) => value === "tailscale")
|
|
334
|
+
) {
|
|
335
|
+
const addressClass = classes[0] ?? "private";
|
|
336
|
+
return classification(
|
|
337
|
+
"lan",
|
|
338
|
+
addressClass,
|
|
339
|
+
addressClass === "tailscale" ? "TAILSCALE_ADDRESS" : "PRIVATE_ADDRESS",
|
|
340
|
+
audit
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
if (classes.every((value) => value === "public")) {
|
|
344
|
+
return classification("remote", "public", "PUBLIC_ADDRESS", audit);
|
|
345
|
+
}
|
|
346
|
+
return classification("remote", "unknown", "MIXED_DNS_ANSWERS", audit);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Classify only explicit addresses or supplied DNS answers. Names alone never
|
|
351
|
+
* prove loopback/LAN membership, and provider identity never relaxes a zone.
|
|
352
|
+
*/
|
|
353
|
+
export function classifyDestination(
|
|
354
|
+
input: DestinationClassifierInput
|
|
355
|
+
): DestinationClassification {
|
|
356
|
+
if (input.kind === "local_process") {
|
|
357
|
+
return classification(
|
|
358
|
+
"local_process",
|
|
359
|
+
"loopback",
|
|
360
|
+
"LOCAL_PROCESS",
|
|
361
|
+
auditFor("local_process", "none", [], 0)
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const hostname = stripAddressDecoration(input.hostname ?? "");
|
|
366
|
+
const hostnameIsIp = isIP(hostname) !== 0;
|
|
367
|
+
const hostnameKind = hostnameIsIp
|
|
368
|
+
? "ip_literal"
|
|
369
|
+
: hostname
|
|
370
|
+
? "dns_name"
|
|
371
|
+
: "none";
|
|
372
|
+
const source =
|
|
373
|
+
input.kind === "bind"
|
|
374
|
+
? "bind"
|
|
375
|
+
: input.remoteProvider
|
|
376
|
+
? "provider"
|
|
377
|
+
: hostnameIsIp
|
|
378
|
+
? "literal"
|
|
379
|
+
: "dns";
|
|
380
|
+
const addresses = hostnameIsIp
|
|
381
|
+
? [hostname]
|
|
382
|
+
: input.addresses && input.addresses.length > 0
|
|
383
|
+
? input.addresses
|
|
384
|
+
: [];
|
|
385
|
+
const classes = addresses.map(classifyNetworkAddress);
|
|
386
|
+
const audit = auditFor(source, hostnameKind, classes, addresses.length);
|
|
387
|
+
|
|
388
|
+
if (input.kind === "bind" && (hostname === "0.0.0.0" || hostname === "::")) {
|
|
389
|
+
return classification("remote", "unknown", "WILDCARD_BIND", audit);
|
|
390
|
+
}
|
|
391
|
+
const addressSet = classifyAddressSet(classes, audit);
|
|
392
|
+
if (input.remoteProvider && addressSet.addressClass === "public") {
|
|
393
|
+
return classification("remote", "public", "REMOTE_PROVIDER", audit);
|
|
394
|
+
}
|
|
395
|
+
return addressSet;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function classifyBindDestination(
|
|
399
|
+
host: string
|
|
400
|
+
): DestinationClassification {
|
|
401
|
+
return classifyDestination({ kind: "bind", hostname: host });
|
|
402
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/** Bounded local management service for content-free egress receipts. */
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
EgressAuditDeleteResult as StoredEgressAuditDeleteResult,
|
|
5
|
+
EgressAuditPage,
|
|
6
|
+
EgressAuditPurgeResult,
|
|
7
|
+
EgressAuditRetentionPolicy,
|
|
8
|
+
EgressAuditRetentionResult,
|
|
9
|
+
StorePort,
|
|
10
|
+
StoreResult,
|
|
11
|
+
} from "../store/types";
|
|
12
|
+
import type { EgressContentClass, EgressDecision } from "./egress-policy";
|
|
13
|
+
import type { EgressLineage } from "./egress-provenance";
|
|
14
|
+
|
|
15
|
+
import { canonicalTraceJson } from "../store/retrieval-trace-codec";
|
|
16
|
+
import { err, ok } from "../store/types";
|
|
17
|
+
import { defaultEgressPolicyPort } from "./egress-policy";
|
|
18
|
+
import { egressLineageSchema } from "./egress-provenance";
|
|
19
|
+
|
|
20
|
+
const CURSOR_PREFIX = "gno-egress-audit-v1.";
|
|
21
|
+
const DEFAULT_RETENTION: EgressAuditRetentionPolicy = {
|
|
22
|
+
maxAgeDays: 30,
|
|
23
|
+
maxReceipts: 10_000,
|
|
24
|
+
maxBytes: 4 * 1024 * 1024,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const encodeCursor = (cursor: {
|
|
28
|
+
createdAtMs: number;
|
|
29
|
+
auditId: string;
|
|
30
|
+
}): string =>
|
|
31
|
+
`${CURSOR_PREFIX}${new TextEncoder()
|
|
32
|
+
.encode(canonicalTraceJson(cursor))
|
|
33
|
+
.toBase64({ alphabet: "base64url", omitPadding: true })}`;
|
|
34
|
+
|
|
35
|
+
const decodeCursor = (
|
|
36
|
+
cursor: string | undefined
|
|
37
|
+
): StoreResult<{ createdAtMs: number; auditId: string } | undefined> => {
|
|
38
|
+
if (cursor === undefined) return ok(undefined);
|
|
39
|
+
if (!cursor.startsWith(CURSOR_PREFIX)) {
|
|
40
|
+
return err("INVALID_INPUT", "Invalid egress audit cursor");
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const decoded = JSON.parse(
|
|
44
|
+
new TextDecoder("utf-8", { fatal: true }).decode(
|
|
45
|
+
Uint8Array.fromBase64(cursor.slice(CURSOR_PREFIX.length), {
|
|
46
|
+
alphabet: "base64url",
|
|
47
|
+
})
|
|
48
|
+
)
|
|
49
|
+
) as Record<string, unknown>;
|
|
50
|
+
if (
|
|
51
|
+
Object.keys(decoded).sort().join(",") !== "auditId,createdAtMs" ||
|
|
52
|
+
typeof decoded.auditId !== "string" ||
|
|
53
|
+
decoded.auditId.length < 1 ||
|
|
54
|
+
decoded.auditId.length > 128 ||
|
|
55
|
+
!Number.isSafeInteger(decoded.createdAtMs) ||
|
|
56
|
+
(decoded.createdAtMs as number) < 0
|
|
57
|
+
) {
|
|
58
|
+
throw new TypeError("Invalid egress audit cursor");
|
|
59
|
+
}
|
|
60
|
+
return ok({
|
|
61
|
+
createdAtMs: decoded.createdAtMs as number,
|
|
62
|
+
auditId: decoded.auditId,
|
|
63
|
+
});
|
|
64
|
+
} catch {
|
|
65
|
+
return err("INVALID_INPUT", "Invalid egress audit cursor");
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export interface EgressAuditListResult extends Omit<
|
|
70
|
+
EgressAuditPage,
|
|
71
|
+
"nextCursor"
|
|
72
|
+
> {
|
|
73
|
+
schemaVersion: "1.0";
|
|
74
|
+
nextCursor: string | null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface EgressAuditStatusResult {
|
|
78
|
+
schemaVersion: "1.0";
|
|
79
|
+
retention: EgressAuditRetentionPolicy;
|
|
80
|
+
receipts: number;
|
|
81
|
+
bytes: number;
|
|
82
|
+
oldestCreatedAtMs: number | null;
|
|
83
|
+
newestCreatedAtMs: number | null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface EgressAuditShowResult {
|
|
87
|
+
schemaVersion: "1.0";
|
|
88
|
+
receipt: EgressAuditPage["receipts"][number];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type EgressAuditDeleteResult = StoredEgressAuditDeleteResult & {
|
|
92
|
+
schemaVersion: "1.0";
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export type EgressAuditPurgeManagementResult = EgressAuditPurgeResult & {
|
|
96
|
+
schemaVersion: "1.0";
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export class EgressAuditService {
|
|
100
|
+
private readonly clock: () => number;
|
|
101
|
+
private readonly idFactory: () => string;
|
|
102
|
+
|
|
103
|
+
constructor(
|
|
104
|
+
private readonly store: StorePort,
|
|
105
|
+
deps: { clock?: () => number; idFactory?: () => string } = {}
|
|
106
|
+
) {
|
|
107
|
+
this.clock = deps.clock ?? Date.now;
|
|
108
|
+
this.idFactory = deps.idFactory ?? (() => crypto.randomUUID());
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async record(input: {
|
|
112
|
+
decision: EgressDecision;
|
|
113
|
+
lineage: EgressLineage;
|
|
114
|
+
contentClass: EgressContentClass;
|
|
115
|
+
retention?: EgressAuditRetentionPolicy;
|
|
116
|
+
}): Promise<StoreResult<"inserted" | "duplicate">> {
|
|
117
|
+
const lineage = egressLineageSchema.safeParse(input.lineage);
|
|
118
|
+
const audit = input.decision.audit;
|
|
119
|
+
if (
|
|
120
|
+
!lineage.success ||
|
|
121
|
+
audit.action === "unknown" ||
|
|
122
|
+
audit.destinationZone === "unknown" ||
|
|
123
|
+
audit.contentClass !== input.contentClass ||
|
|
124
|
+
typeof audit.callerAuthenticated !== "boolean" ||
|
|
125
|
+
typeof audit.callerOperationAuthorized !== "boolean"
|
|
126
|
+
) {
|
|
127
|
+
return err("INVALID_INPUT", "Invalid egress audit decision metadata");
|
|
128
|
+
}
|
|
129
|
+
const expected = defaultEgressPolicyPort.evaluate({
|
|
130
|
+
collections: lineage.data.sources,
|
|
131
|
+
action: audit.action,
|
|
132
|
+
destination: { zone: audit.destinationZone },
|
|
133
|
+
caller: {
|
|
134
|
+
authenticated: audit.callerAuthenticated,
|
|
135
|
+
operationAuthorized: audit.callerOperationAuthorized,
|
|
136
|
+
},
|
|
137
|
+
contentClass: input.contentClass,
|
|
138
|
+
});
|
|
139
|
+
if (
|
|
140
|
+
canonicalTraceJson(expected) !== canonicalTraceJson(input.decision) ||
|
|
141
|
+
audit.collectionCount !== lineage.data.sources.length ||
|
|
142
|
+
canonicalTraceJson(audit.collections) !==
|
|
143
|
+
canonicalTraceJson(
|
|
144
|
+
lineage.data.sources.map(({ collection }) => collection)
|
|
145
|
+
) ||
|
|
146
|
+
audit.effectivePolicy !== lineage.data.effectivePolicy
|
|
147
|
+
) {
|
|
148
|
+
return err(
|
|
149
|
+
"INVALID_INPUT",
|
|
150
|
+
"Egress audit decision does not match its policy lineage"
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
const retention = input.retention ?? DEFAULT_RETENTION;
|
|
154
|
+
if (!this.store.appendEgressAuditReceiptWithRetention) {
|
|
155
|
+
return err(
|
|
156
|
+
"CONSTRAINT_VIOLATION",
|
|
157
|
+
"Store cannot atomically persist bounded egress audits"
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
const nowMs = this.clock();
|
|
161
|
+
return this.store.appendEgressAuditReceiptWithRetention(
|
|
162
|
+
{
|
|
163
|
+
auditId: this.idFactory(),
|
|
164
|
+
decision: input.decision.allowed ? "allow" : "deny",
|
|
165
|
+
action: audit.action,
|
|
166
|
+
destinationZone: audit.destinationZone,
|
|
167
|
+
contentClass: input.contentClass,
|
|
168
|
+
effectivePolicy: lineage.data.effectivePolicy,
|
|
169
|
+
reasonCode: input.decision.reason,
|
|
170
|
+
lineageDigest: lineage.data.digest,
|
|
171
|
+
createdAtMs: nowMs,
|
|
172
|
+
expiresAtMs: nowMs + retention.maxAgeDays * 86_400_000,
|
|
173
|
+
},
|
|
174
|
+
retention,
|
|
175
|
+
nowMs
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async list(
|
|
180
|
+
input: {
|
|
181
|
+
limit?: number;
|
|
182
|
+
cursor?: string;
|
|
183
|
+
} = {}
|
|
184
|
+
): Promise<StoreResult<EgressAuditListResult>> {
|
|
185
|
+
const cursor = decodeCursor(input.cursor);
|
|
186
|
+
if (!cursor.ok) return cursor;
|
|
187
|
+
const page = await this.store.listEgressAuditReceipts(
|
|
188
|
+
input.limit ?? 100,
|
|
189
|
+
cursor.value
|
|
190
|
+
);
|
|
191
|
+
if (!page.ok) return page;
|
|
192
|
+
return ok({
|
|
193
|
+
schemaVersion: "1.0",
|
|
194
|
+
receipts: page.value.receipts,
|
|
195
|
+
nextCursor: page.value.nextCursor
|
|
196
|
+
? encodeCursor(page.value.nextCursor)
|
|
197
|
+
: null,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async show(auditId: string): Promise<StoreResult<EgressAuditShowResult>> {
|
|
202
|
+
const receipt = await this.store.getEgressAuditReceipt(auditId);
|
|
203
|
+
if (!receipt.ok) return receipt;
|
|
204
|
+
return receipt.value
|
|
205
|
+
? ok({ schemaVersion: "1.0", receipt: receipt.value })
|
|
206
|
+
: err("NOT_FOUND", "Egress audit receipt not found");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async status(): Promise<StoreResult<EgressAuditStatusResult>> {
|
|
210
|
+
const status = await this.store.getEgressAuditStatus();
|
|
211
|
+
return status.ok
|
|
212
|
+
? ok({
|
|
213
|
+
schemaVersion: "1.0",
|
|
214
|
+
retention: { ...DEFAULT_RETENTION },
|
|
215
|
+
...status.value,
|
|
216
|
+
})
|
|
217
|
+
: status;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async delete(auditId: string): Promise<StoreResult<EgressAuditDeleteResult>> {
|
|
221
|
+
const deleted = await this.store.deleteEgressAuditReceipt(auditId);
|
|
222
|
+
if (!deleted.ok) return deleted;
|
|
223
|
+
return deleted.value.deleted === 0
|
|
224
|
+
? err("NOT_FOUND", "Egress audit receipt not found")
|
|
225
|
+
: ok({ schemaVersion: "1.0" as const, ...deleted.value });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
enforceRetention(
|
|
229
|
+
policy: EgressAuditRetentionPolicy,
|
|
230
|
+
nowMs = this.clock()
|
|
231
|
+
): Promise<StoreResult<EgressAuditRetentionResult>> {
|
|
232
|
+
return this.store.enforceEgressAuditRetention(policy, nowMs);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async purge(): Promise<StoreResult<EgressAuditPurgeManagementResult>> {
|
|
236
|
+
const purged = await this.store.purgeEgressAuditReceipts();
|
|
237
|
+
return purged.ok ? ok({ schemaVersion: "1.0", ...purged.value }) : purged;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Shared current-policy authorization boundary for derived artifacts. */
|
|
2
|
+
|
|
3
|
+
import type { Collection } from "../config/types";
|
|
4
|
+
import type { StorePort, StoreResult } from "../store/types";
|
|
5
|
+
import type {
|
|
6
|
+
EgressAction,
|
|
7
|
+
EgressCallerContext,
|
|
8
|
+
EgressContentClass,
|
|
9
|
+
EgressDestinationZone,
|
|
10
|
+
} from "./egress-policy";
|
|
11
|
+
import type { EgressLineage } from "./egress-provenance";
|
|
12
|
+
|
|
13
|
+
import { err, ok } from "../store/types";
|
|
14
|
+
import { currentEgressSources } from "./collection-egress-policy-service";
|
|
15
|
+
import { EgressAuditService } from "./egress-audit";
|
|
16
|
+
import { evaluateEgressPolicy } from "./egress-policy";
|
|
17
|
+
import { resolveEgressLineage } from "./egress-provenance";
|
|
18
|
+
|
|
19
|
+
export const authorizeCurrentEgress = async (input: {
|
|
20
|
+
store: StorePort;
|
|
21
|
+
config: { collections: readonly Collection[] };
|
|
22
|
+
lineage: EgressLineage;
|
|
23
|
+
action: EgressAction;
|
|
24
|
+
destinationZone: EgressDestinationZone;
|
|
25
|
+
caller: EgressCallerContext;
|
|
26
|
+
contentClass: EgressContentClass;
|
|
27
|
+
}): Promise<StoreResult<EgressLineage>> => {
|
|
28
|
+
const currentLineage = resolveEgressLineage(
|
|
29
|
+
currentEgressSources(
|
|
30
|
+
input.config,
|
|
31
|
+
input.lineage.sources.map(({ collection }) => collection)
|
|
32
|
+
)
|
|
33
|
+
);
|
|
34
|
+
const decision = evaluateEgressPolicy({
|
|
35
|
+
collections: currentLineage.sources,
|
|
36
|
+
action: input.action,
|
|
37
|
+
destination: { zone: input.destinationZone },
|
|
38
|
+
caller: input.caller,
|
|
39
|
+
contentClass: input.contentClass,
|
|
40
|
+
});
|
|
41
|
+
const recorded = await new EgressAuditService(input.store).record({
|
|
42
|
+
decision,
|
|
43
|
+
lineage: currentLineage,
|
|
44
|
+
contentClass: input.contentClass,
|
|
45
|
+
});
|
|
46
|
+
if (!recorded.ok) return recorded;
|
|
47
|
+
return decision.allowed
|
|
48
|
+
? ok(currentLineage)
|
|
49
|
+
: err("EGRESS_DENIED", "Operation blocked by collection egress policy");
|
|
50
|
+
};
|