@gmickel/gno 1.28.0 → 1.29.1

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.1.zip +0 -0
  4. package/browser-extension/artifacts/gno-browser-clipper-v1.29.1.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,264 @@
1
+ /** Shared fail-closed enforcement helpers for content-transfer boundaries. */
2
+
3
+ import type { Collection } from "../config/types";
4
+ import type { StorePort } from "../store/types";
5
+ import type {
6
+ EgressAction,
7
+ EgressCallerContext,
8
+ EgressContentClass,
9
+ EgressDecision,
10
+ EgressDestinationZone,
11
+ } from "./egress-policy";
12
+ import type { EgressLineage, EgressLineageInput } from "./egress-provenance";
13
+
14
+ import { resolveConfiguredEgressPolicy } from "../config/types";
15
+ import { EgressAuditService } from "./egress-audit";
16
+ import { evaluateEgressPolicy } from "./egress-policy";
17
+ import {
18
+ EgressProvenanceError,
19
+ resolveEgressLineage,
20
+ } from "./egress-provenance";
21
+
22
+ export const EGRESS_DENIED_MESSAGE =
23
+ "Operation blocked by collection egress policy";
24
+
25
+ export class EgressDeniedError extends Error {
26
+ readonly code = "EGRESS_DENIED";
27
+ readonly decision: EgressDecision;
28
+
29
+ constructor(decision: EgressDecision) {
30
+ super(`${decision.code}: ${EGRESS_DENIED_MESSAGE}`);
31
+ this.name = "EgressDeniedError";
32
+ this.decision = decision;
33
+ this.stack = `${this.name}: ${this.message}`;
34
+ }
35
+
36
+ toJSON(): {
37
+ code: "EGRESS_DENIED";
38
+ message: string;
39
+ reason: EgressDecision["reason"];
40
+ audit: EgressDecision["audit"];
41
+ } {
42
+ return {
43
+ code: this.code,
44
+ message: EGRESS_DENIED_MESSAGE,
45
+ reason: this.decision.reason,
46
+ audit: this.decision.audit,
47
+ };
48
+ }
49
+ }
50
+
51
+ const collectionLineageInputs = (
52
+ collections: readonly Collection[]
53
+ ): readonly EgressLineageInput[] => {
54
+ try {
55
+ if (!Array.isArray(collections)) {
56
+ throw new EgressProvenanceError(
57
+ "INVALID_EGRESS_LINEAGE",
58
+ "Collection policy scope must be a readable dense bounded array"
59
+ );
60
+ }
61
+ const { length } = collections;
62
+ if (!Number.isSafeInteger(length) || length > 128) {
63
+ throw new EgressProvenanceError(
64
+ "INVALID_EGRESS_LINEAGE",
65
+ "Collection policy scope must be a readable dense bounded array"
66
+ );
67
+ }
68
+ const inputs: EgressLineageInput[] = [];
69
+ for (let index = 0; index < length; index += 1) {
70
+ if (!(index in collections)) {
71
+ throw new EgressProvenanceError(
72
+ "INVALID_EGRESS_LINEAGE",
73
+ "Collection policy scope must be a readable dense bounded array"
74
+ );
75
+ }
76
+ const collection: unknown = collections[index];
77
+ if (collection === null || typeof collection !== "object") {
78
+ throw new EgressProvenanceError(
79
+ "INVALID_EGRESS_LINEAGE",
80
+ "Collection policy scope contains an invalid collection"
81
+ );
82
+ }
83
+ const candidate = collection as Partial<Collection>;
84
+ const name = candidate.name;
85
+ const egressPolicy = candidate.egressPolicy;
86
+ const effective = resolveConfiguredEgressPolicy({ egressPolicy });
87
+ inputs.push({
88
+ collection: name as string,
89
+ policy: effective.policy,
90
+ source: effective.source,
91
+ });
92
+ }
93
+ return inputs;
94
+ } catch (error) {
95
+ if (error instanceof EgressProvenanceError) throw error;
96
+ throw new EgressProvenanceError(
97
+ "INVALID_EGRESS_LINEAGE",
98
+ "Collection policy scope could not be validated"
99
+ );
100
+ }
101
+ };
102
+
103
+ const resolveCollectionEgressLineage = (
104
+ collections: readonly Collection[],
105
+ names?: readonly string[]
106
+ ): EgressLineage =>
107
+ resolveEgressLineage(collectionLineageInputs(collections), names);
108
+
109
+ export const collectionEgressStates = (
110
+ collections: readonly Collection[],
111
+ names?: readonly string[]
112
+ ) => {
113
+ return resolveCollectionEgressLineage(collections, names).sources;
114
+ };
115
+
116
+ export interface EnforceCollectionEgressInput {
117
+ collections: readonly Collection[];
118
+ collectionNames?: readonly string[];
119
+ action: EgressAction;
120
+ destinationZone: EgressDestinationZone;
121
+ caller: EgressCallerContext;
122
+ contentClass: EgressContentClass;
123
+ }
124
+
125
+ export interface MixedCollectionEgressPlan {
126
+ mode: "complete" | "partial";
127
+ sourceLineage: EgressLineage;
128
+ allowedCollections: string[];
129
+ omittedCollections: Array<{
130
+ collection: string;
131
+ reason: EgressDecision["reason"];
132
+ }>;
133
+ disclosure: null | {
134
+ code: "EGRESS_PARTIAL_RESULT";
135
+ omittedCount: number;
136
+ omittedCollections: string[];
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Plan one mixed-source boundary. Denial is the default; partial emission
142
+ * requires an explicit caller opt-in and always discloses sorted omissions.
143
+ */
144
+ export const planCollectionEgress = (
145
+ input: EnforceCollectionEgressInput & {
146
+ partialResults?: "deny" | "explicit";
147
+ }
148
+ ): MixedCollectionEgressPlan => {
149
+ const sourceLineage = resolveCollectionEgressLineage(
150
+ input.collections,
151
+ input.collectionNames
152
+ );
153
+ const aggregate = evaluateEgressPolicy({
154
+ collections: sourceLineage.sources,
155
+ action: input.action,
156
+ destination: { zone: input.destinationZone },
157
+ caller: input.caller,
158
+ contentClass: input.contentClass,
159
+ });
160
+ if (aggregate.allowed) {
161
+ return {
162
+ mode: "complete",
163
+ sourceLineage,
164
+ allowedCollections: sourceLineage.sources.map(
165
+ ({ collection }) => collection
166
+ ),
167
+ omittedCollections: [],
168
+ disclosure: null,
169
+ };
170
+ }
171
+ if (input.partialResults !== "explicit") {
172
+ throw new EgressDeniedError(aggregate);
173
+ }
174
+
175
+ const allowedCollections: string[] = [];
176
+ const omittedCollections: MixedCollectionEgressPlan["omittedCollections"] =
177
+ [];
178
+ for (const source of sourceLineage.sources) {
179
+ const perCollection = evaluateEgressPolicy({
180
+ collections: [source],
181
+ action: input.action,
182
+ destination: { zone: input.destinationZone },
183
+ caller: input.caller,
184
+ contentClass: input.contentClass,
185
+ });
186
+ if (perCollection.allowed) allowedCollections.push(source.collection);
187
+ else {
188
+ omittedCollections.push({
189
+ collection: source.collection,
190
+ reason: perCollection.reason,
191
+ });
192
+ }
193
+ }
194
+ if (allowedCollections.length === 0) {
195
+ throw new EgressDeniedError(aggregate);
196
+ }
197
+ return {
198
+ mode: "partial",
199
+ sourceLineage,
200
+ allowedCollections,
201
+ omittedCollections,
202
+ disclosure: {
203
+ code: "EGRESS_PARTIAL_RESULT",
204
+ omittedCount: omittedCollections.length,
205
+ omittedCollections: omittedCollections.map(
206
+ ({ collection }) => collection
207
+ ),
208
+ },
209
+ };
210
+ };
211
+
212
+ export const enforceCollectionEgress = (
213
+ input: EnforceCollectionEgressInput
214
+ ): EgressDecision => {
215
+ const plan = planCollectionEgress(input);
216
+ const decision = evaluateEgressPolicy({
217
+ collections: plan.sourceLineage.sources,
218
+ action: input.action,
219
+ destination: { zone: input.destinationZone },
220
+ caller: input.caller,
221
+ contentClass: input.contentClass,
222
+ });
223
+ if (!decision.allowed) throw new EgressDeniedError(decision);
224
+ return decision;
225
+ };
226
+
227
+ /** Enforce and durably record the content-free decision before transfer. */
228
+ export const enforceCollectionEgressWithAudit = async (
229
+ input: EnforceCollectionEgressInput & { store: StorePort }
230
+ ): Promise<{ decision: EgressDecision; lineage: EgressLineage }> => {
231
+ const lineage = resolveCollectionEgressLineage(
232
+ input.collections,
233
+ input.collectionNames
234
+ );
235
+ let decision: EgressDecision;
236
+ let denied: EgressDeniedError | null = null;
237
+ try {
238
+ decision = enforceCollectionEgress(input);
239
+ } catch (error) {
240
+ if (!(error instanceof EgressDeniedError)) throw error;
241
+ decision = error.decision;
242
+ denied = error;
243
+ }
244
+ const recorded = await new EgressAuditService(input.store).record({
245
+ decision,
246
+ lineage,
247
+ contentClass: input.contentClass,
248
+ });
249
+ if (!recorded.ok) {
250
+ throw new Error(`Failed to record egress audit: ${recorded.error.code}`);
251
+ }
252
+ if (denied) throw denied;
253
+ return { decision, lineage };
254
+ };
255
+
256
+ export const maximumDestinationZoneForCollections = (
257
+ collections: readonly Collection[],
258
+ names?: readonly string[]
259
+ ): Exclude<EgressDestinationZone, "local_process"> => {
260
+ const states = collectionEgressStates(collections, names);
261
+ if (states.some(({ policy }) => policy === "local_only")) return "loopback";
262
+ if (states.some(({ policy }) => policy === "lan")) return "lan";
263
+ return "remote";
264
+ };
@@ -0,0 +1,440 @@
1
+ import {
2
+ EGRESS_POLICIES,
3
+ EGRESS_POLICY_SOURCES,
4
+ type EgressPolicy,
5
+ type EgressPolicySource,
6
+ } from "../config/types";
7
+
8
+ export const EGRESS_ACTIONS = [
9
+ "retrieve",
10
+ "serve",
11
+ "publish",
12
+ "remote_inference",
13
+ "export",
14
+ "clip_write",
15
+ ] as const;
16
+ export type EgressAction = (typeof EGRESS_ACTIONS)[number];
17
+
18
+ export const EGRESS_DESTINATION_ZONES = [
19
+ "local_process",
20
+ "loopback",
21
+ "lan",
22
+ "remote",
23
+ ] as const;
24
+ export type EgressDestinationZone = (typeof EGRESS_DESTINATION_ZONES)[number];
25
+
26
+ /**
27
+ * Source and derived classes retain the same collection-owned policy.
28
+ * Transformation never declassifies indexed content.
29
+ */
30
+ export const EGRESS_CONTENT_CLASSES = [
31
+ "source",
32
+ "snippet",
33
+ "metadata",
34
+ "attachment",
35
+ "embedding",
36
+ "capsule",
37
+ "audit_log",
38
+ "retrieval_trace",
39
+ ] as const;
40
+ export type EgressContentClass = (typeof EGRESS_CONTENT_CLASSES)[number];
41
+
42
+ /** Hard cap for one decision and its bounded audit projection. */
43
+ export const EGRESS_MAX_COLLECTIONS = 64;
44
+
45
+ export const EGRESS_REASON_CODES = [
46
+ "LOCAL_DESTINATION",
47
+ "LAN_POLICY_AUTHENTICATED",
48
+ "REMOTE_POLICY_AUTHENTICATED",
49
+ "INVALID_INPUT",
50
+ "NO_COLLECTION_POLICY",
51
+ "COLLECTION_LIMIT_EXCEEDED",
52
+ "INVALID_COLLECTION",
53
+ "UNKNOWN_ACTION",
54
+ "UNKNOWN_DESTINATION",
55
+ "ACTION_DESTINATION_MISMATCH",
56
+ "UNKNOWN_CONTENT_CLASS",
57
+ "UNKNOWN_POLICY",
58
+ "UNKNOWN_POLICY_SOURCE",
59
+ "INVALID_POLICY_SOURCE_PAIR",
60
+ "INVALID_CALLER",
61
+ "CALLER_NOT_AUTHORIZED",
62
+ "AUTHENTICATION_REQUIRED",
63
+ "POLICY_LOCAL_ONLY",
64
+ "POLICY_LAN_ONLY",
65
+ ] as const;
66
+ export type EgressReasonCode = (typeof EGRESS_REASON_CODES)[number];
67
+
68
+ export interface CollectionEgressState {
69
+ /** Stable configured collection identifier; never a filesystem path. */
70
+ collection: string;
71
+ policy: EgressPolicy;
72
+ source: EgressPolicySource;
73
+ }
74
+
75
+ export interface EgressCallerContext {
76
+ /**
77
+ * Transport or feature authentication succeeded. Network destinations
78
+ * always require this; policy cannot make authentication optional.
79
+ */
80
+ authenticated: boolean;
81
+ /**
82
+ * The surrounding authorization layer permits this operation. A false value
83
+ * narrows access even for a policy that would otherwise allow it.
84
+ */
85
+ operationAuthorized: boolean;
86
+ }
87
+
88
+ export interface EgressDestination {
89
+ zone: EgressDestinationZone;
90
+ /**
91
+ * Optional classifier inputs are deliberately excluded from decisions and
92
+ * audit metadata so hosts, provider names, and credentials cannot leak.
93
+ */
94
+ host?: string;
95
+ provider?: string;
96
+ }
97
+
98
+ export interface EgressEvaluationInput {
99
+ collections: readonly CollectionEgressState[];
100
+ action: EgressAction;
101
+ destination: EgressDestination;
102
+ caller: EgressCallerContext;
103
+ contentClass: EgressContentClass;
104
+ }
105
+
106
+ export interface EgressDecisionMetadata {
107
+ action: EgressAction | "unknown";
108
+ destinationZone: EgressDestinationZone | "unknown";
109
+ contentClass: EgressContentClass | "unknown";
110
+ collectionCount: number;
111
+ collections: readonly string[];
112
+ effectivePolicy: EgressPolicy | "unknown";
113
+ effectivePolicySource: EgressPolicySource | "mixed" | "unknown";
114
+ callerAuthenticated: boolean | null;
115
+ callerOperationAuthorized: boolean | null;
116
+ }
117
+
118
+ export interface EgressDecision {
119
+ allowed: boolean;
120
+ code: "EGRESS_ALLOWED" | "EGRESS_DENIED";
121
+ reason: EgressReasonCode;
122
+ audit: EgressDecisionMetadata;
123
+ }
124
+
125
+ export interface EgressPolicyPort {
126
+ evaluate(input: EgressEvaluationInput): EgressDecision;
127
+ }
128
+
129
+ interface RuntimeCollectionEgressState {
130
+ collection: unknown;
131
+ policy: unknown;
132
+ source: unknown;
133
+ }
134
+
135
+ interface EgressEvaluationSnapshot {
136
+ collections: RuntimeCollectionEgressState[];
137
+ action: unknown;
138
+ destinationZone: unknown;
139
+ callerAuthenticated: unknown;
140
+ callerOperationAuthorized: unknown;
141
+ contentClass: unknown;
142
+ }
143
+
144
+ type EgressSnapshotResult =
145
+ | { kind: "invalid" }
146
+ | { kind: "limit_exceeded" }
147
+ | { kind: "snapshot"; value: EgressEvaluationSnapshot };
148
+
149
+ const ACTIONS = new Set<unknown>(EGRESS_ACTIONS);
150
+ const DESTINATION_ZONES = new Set<unknown>(EGRESS_DESTINATION_ZONES);
151
+ const CONTENT_CLASSES = new Set<unknown>(EGRESS_CONTENT_CLASSES);
152
+ const POLICIES = new Set<unknown>(EGRESS_POLICIES);
153
+ const POLICY_SOURCES = new Set<unknown>(EGRESS_POLICY_SOURCES);
154
+ const COLLECTION_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
155
+
156
+ const POLICY_ORDER: Record<EgressPolicy, number> = {
157
+ local_only: 0,
158
+ lan: 1,
159
+ remote: 2,
160
+ };
161
+
162
+ const isAction = (value: unknown): value is EgressAction => ACTIONS.has(value);
163
+ const isDestinationZone = (value: unknown): value is EgressDestinationZone =>
164
+ DESTINATION_ZONES.has(value);
165
+ const isContentClass = (value: unknown): value is EgressContentClass =>
166
+ CONTENT_CLASSES.has(value);
167
+ const isPolicy = (value: unknown): value is EgressPolicy => POLICIES.has(value);
168
+ const isPolicySource = (value: unknown): value is EgressPolicySource =>
169
+ POLICY_SOURCES.has(value);
170
+
171
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
172
+ typeof value === "object" && value !== null;
173
+
174
+ function invalidInputAudit(): EgressDecisionMetadata {
175
+ return {
176
+ action: "unknown",
177
+ destinationZone: "unknown",
178
+ contentClass: "unknown",
179
+ collectionCount: 0,
180
+ collections: [],
181
+ effectivePolicy: "unknown",
182
+ effectivePolicySource: "unknown",
183
+ callerAuthenticated: null,
184
+ callerOperationAuthorized: null,
185
+ };
186
+ }
187
+
188
+ function limitExceededAudit(): EgressDecisionMetadata {
189
+ return {
190
+ ...invalidInputAudit(),
191
+ collectionCount: EGRESS_MAX_COLLECTIONS + 1,
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Read untrusted object graphs once into bounded plain data. Any throwing
197
+ * getter or revoked proxy escapes to the evaluator's fixed fail-closed catch.
198
+ */
199
+ function snapshotInput(input: unknown): EgressSnapshotResult {
200
+ if (!isRecord(input)) return { kind: "invalid" };
201
+
202
+ const rawCollections = input.collections;
203
+ if (!Array.isArray(rawCollections)) return { kind: "invalid" };
204
+ const collectionCount = rawCollections.length;
205
+ if (collectionCount > EGRESS_MAX_COLLECTIONS) {
206
+ return { kind: "limit_exceeded" };
207
+ }
208
+
209
+ const collections: RuntimeCollectionEgressState[] = [];
210
+ for (let index = 0; index < collectionCount; index += 1) {
211
+ const collection = rawCollections[index];
212
+ if (!isRecord(collection)) {
213
+ collections.push({
214
+ collection: undefined,
215
+ policy: undefined,
216
+ source: undefined,
217
+ });
218
+ continue;
219
+ }
220
+ collections.push({
221
+ collection: collection.collection,
222
+ policy: collection.policy,
223
+ source: collection.source,
224
+ });
225
+ }
226
+
227
+ const destination = input.destination;
228
+ const caller = input.caller;
229
+ return {
230
+ kind: "snapshot",
231
+ value: {
232
+ collections,
233
+ action: input.action,
234
+ destinationZone: isRecord(destination) ? destination.zone : undefined,
235
+ callerAuthenticated: isRecord(caller) ? caller.authenticated : undefined,
236
+ callerOperationAuthorized: isRecord(caller)
237
+ ? caller.operationAuthorized
238
+ : undefined,
239
+ contentClass: input.contentClass,
240
+ },
241
+ };
242
+ }
243
+
244
+ function snapshotAudit(
245
+ snapshot: EgressEvaluationSnapshot
246
+ ): EgressDecisionMetadata {
247
+ const collections = snapshot.collections
248
+ .map(({ collection }) =>
249
+ typeof collection === "string" && COLLECTION_NAME_PATTERN.test(collection)
250
+ ? collection
251
+ : "unknown"
252
+ )
253
+ .sort();
254
+ return {
255
+ action: isAction(snapshot.action) ? snapshot.action : "unknown",
256
+ destinationZone: isDestinationZone(snapshot.destinationZone)
257
+ ? snapshot.destinationZone
258
+ : "unknown",
259
+ contentClass: isContentClass(snapshot.contentClass)
260
+ ? snapshot.contentClass
261
+ : "unknown",
262
+ collectionCount: snapshot.collections.length,
263
+ collections,
264
+ effectivePolicy: "unknown",
265
+ effectivePolicySource: "unknown",
266
+ callerAuthenticated:
267
+ typeof snapshot.callerAuthenticated === "boolean"
268
+ ? snapshot.callerAuthenticated
269
+ : null,
270
+ callerOperationAuthorized:
271
+ typeof snapshot.callerOperationAuthorized === "boolean"
272
+ ? snapshot.callerOperationAuthorized
273
+ : null,
274
+ };
275
+ }
276
+
277
+ function decision(
278
+ allowed: boolean,
279
+ reason: EgressReasonCode,
280
+ audit: EgressDecisionMetadata
281
+ ): EgressDecision {
282
+ return {
283
+ allowed,
284
+ code: allowed ? "EGRESS_ALLOWED" : "EGRESS_DENIED",
285
+ reason,
286
+ audit,
287
+ };
288
+ }
289
+
290
+ function effectiveCollectionPolicy(
291
+ collections: readonly CollectionEgressState[]
292
+ ): {
293
+ policy: EgressPolicy;
294
+ source: EgressPolicySource | "mixed";
295
+ } {
296
+ let policy: EgressPolicy = "remote";
297
+ for (const collection of collections) {
298
+ if (POLICY_ORDER[collection.policy] < POLICY_ORDER[policy]) {
299
+ policy = collection.policy;
300
+ }
301
+ }
302
+
303
+ const restrictiveSources = new Set(
304
+ collections
305
+ .filter((collection) => collection.policy === policy)
306
+ .map((collection) => collection.source)
307
+ );
308
+ const source =
309
+ restrictiveSources.size === 1
310
+ ? (restrictiveSources.values().next().value ?? "mixed")
311
+ : "mixed";
312
+ return { policy, source };
313
+ }
314
+
315
+ function actionAllowsDestination(
316
+ action: EgressAction,
317
+ destination: EgressDestinationZone
318
+ ): boolean {
319
+ switch (action) {
320
+ case "publish":
321
+ return destination === "remote";
322
+ case "remote_inference":
323
+ return destination !== "local_process";
324
+ case "serve":
325
+ return destination !== "local_process";
326
+ case "export":
327
+ return destination !== "loopback";
328
+ case "retrieve":
329
+ case "clip_write":
330
+ return true;
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Evaluate one content-transfer boundary. Runtime guards intentionally accept
336
+ * malformed JavaScript callers fail-closed even though TypeScript callers get
337
+ * the narrower contract.
338
+ */
339
+ function evaluateSnapshot(snapshot: EgressEvaluationSnapshot): EgressDecision {
340
+ const audit = snapshotAudit(snapshot);
341
+ if (snapshot.collections.length === 0) {
342
+ return decision(false, "NO_COLLECTION_POLICY", audit);
343
+ }
344
+ if (!isAction(snapshot.action)) {
345
+ return decision(false, "UNKNOWN_ACTION", audit);
346
+ }
347
+ if (!isDestinationZone(snapshot.destinationZone)) {
348
+ return decision(false, "UNKNOWN_DESTINATION", audit);
349
+ }
350
+ if (!actionAllowsDestination(snapshot.action, snapshot.destinationZone)) {
351
+ return decision(false, "ACTION_DESTINATION_MISMATCH", audit);
352
+ }
353
+ if (!isContentClass(snapshot.contentClass)) {
354
+ return decision(false, "UNKNOWN_CONTENT_CLASS", audit);
355
+ }
356
+ if (
357
+ typeof snapshot.callerAuthenticated !== "boolean" ||
358
+ typeof snapshot.callerOperationAuthorized !== "boolean"
359
+ ) {
360
+ return decision(false, "INVALID_CALLER", audit);
361
+ }
362
+
363
+ const collections: CollectionEgressState[] = [];
364
+ const collectionNames = new Set<string>();
365
+ for (const collection of snapshot.collections) {
366
+ if (
367
+ typeof collection.collection !== "string" ||
368
+ !COLLECTION_NAME_PATTERN.test(collection.collection)
369
+ ) {
370
+ return decision(false, "INVALID_COLLECTION", audit);
371
+ }
372
+ if (!isPolicy(collection.policy)) {
373
+ return decision(false, "UNKNOWN_POLICY", audit);
374
+ }
375
+ if (!isPolicySource(collection.source)) {
376
+ return decision(false, "UNKNOWN_POLICY_SOURCE", audit);
377
+ }
378
+ if (
379
+ collection.source !== "explicit" &&
380
+ collection.policy !== "local_only"
381
+ ) {
382
+ return decision(false, "INVALID_POLICY_SOURCE_PAIR", audit);
383
+ }
384
+ if (collectionNames.has(collection.collection)) {
385
+ return decision(false, "INVALID_COLLECTION", audit);
386
+ }
387
+ collectionNames.add(collection.collection);
388
+ collections.push({
389
+ collection: collection.collection,
390
+ policy: collection.policy,
391
+ source: collection.source,
392
+ });
393
+ }
394
+
395
+ const effective = effectiveCollectionPolicy(collections);
396
+ audit.effectivePolicy = effective.policy;
397
+ audit.effectivePolicySource = effective.source;
398
+
399
+ if (!snapshot.callerOperationAuthorized) {
400
+ return decision(false, "CALLER_NOT_AUTHORIZED", audit);
401
+ }
402
+ if (
403
+ snapshot.destinationZone === "local_process" ||
404
+ snapshot.destinationZone === "loopback"
405
+ ) {
406
+ return decision(true, "LOCAL_DESTINATION", audit);
407
+ }
408
+ if (effective.policy === "local_only") {
409
+ return decision(false, "POLICY_LOCAL_ONLY", audit);
410
+ }
411
+ if (snapshot.destinationZone === "remote" && effective.policy === "lan") {
412
+ return decision(false, "POLICY_LAN_ONLY", audit);
413
+ }
414
+ if (!snapshot.callerAuthenticated) {
415
+ return decision(false, "AUTHENTICATION_REQUIRED", audit);
416
+ }
417
+ if (snapshot.destinationZone === "lan") {
418
+ return decision(true, "LAN_POLICY_AUTHENTICATED", audit);
419
+ }
420
+ return decision(true, "REMOTE_POLICY_AUTHENTICATED", audit);
421
+ }
422
+
423
+ export function evaluateEgressPolicy(input: unknown): EgressDecision {
424
+ try {
425
+ const result = snapshotInput(input);
426
+ if (result.kind === "invalid") {
427
+ return decision(false, "INVALID_INPUT", invalidInputAudit());
428
+ }
429
+ if (result.kind === "limit_exceeded") {
430
+ return decision(false, "COLLECTION_LIMIT_EXCEEDED", limitExceededAudit());
431
+ }
432
+ return evaluateSnapshot(result.value);
433
+ } catch {
434
+ return decision(false, "INVALID_INPUT", invalidInputAudit());
435
+ }
436
+ }
437
+
438
+ export const defaultEgressPolicyPort: EgressPolicyPort = Object.freeze({
439
+ evaluate: evaluateEgressPolicy,
440
+ });