@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
@@ -91,6 +91,7 @@ interface CollectionStats {
91
91
  "override" | "preset" | "default"
92
92
  >;
93
93
  pattern?: string;
94
+ egressPolicy?: CollectionModelDetails["egressPolicy"];
94
95
  }
95
96
 
96
97
  interface StatusResponse {
@@ -411,6 +412,7 @@ export default function Collections({ navigate }: PageProps) {
411
412
  models: config?.models,
412
413
  modelSources: config?.modelSources,
413
414
  pattern: config?.pattern,
415
+ egressPolicy: config?.egressPolicy,
414
416
  };
415
417
  });
416
418
 
@@ -722,6 +724,7 @@ export default function Collections({ navigate }: PageProps) {
722
724
  />
723
725
 
724
726
  <CollectionModelDialog
727
+ availableCollections={collections.map(({ name }) => name)}
725
728
  collection={modelDialogCollection}
726
729
  onOpenChange={(open) => {
727
730
  if (!open) {
@@ -18,6 +18,69 @@ function saturatedResponse(): Response {
18
18
  );
19
19
  }
20
20
 
21
+ function policyChangedResponse(): Response {
22
+ return Response.json(
23
+ {
24
+ error: {
25
+ code: "EGRESS_POLICY_CHANGED",
26
+ message: "Collection policy changed; retry",
27
+ },
28
+ },
29
+ { status: 409 }
30
+ );
31
+ }
32
+
33
+ function wrapResidentStream(
34
+ response: Response,
35
+ isCurrent: () => boolean,
36
+ finish: () => void
37
+ ): Response {
38
+ if (!response.body) {
39
+ finish();
40
+ return response;
41
+ }
42
+ const reader = response.body.getReader();
43
+ let finished = false;
44
+ const finishOnce = (): void => {
45
+ if (finished) return;
46
+ finished = true;
47
+ finish();
48
+ };
49
+ const body = new ReadableStream<Uint8Array>({
50
+ async pull(controller) {
51
+ try {
52
+ if (!isCurrent()) {
53
+ await reader.cancel("EGRESS_POLICY_CHANGED");
54
+ controller.close();
55
+ finishOnce();
56
+ return;
57
+ }
58
+ const result = await reader.read();
59
+ if (!isCurrent()) {
60
+ await reader.cancel("EGRESS_POLICY_CHANGED");
61
+ controller.close();
62
+ finishOnce();
63
+ return;
64
+ }
65
+ if (result.done) {
66
+ controller.close();
67
+ finishOnce();
68
+ return;
69
+ }
70
+ controller.enqueue(result.value);
71
+ } catch (error) {
72
+ controller.error(error);
73
+ finishOnce();
74
+ }
75
+ },
76
+ async cancel(reason) {
77
+ finishOnce();
78
+ await reader.cancel(reason);
79
+ },
80
+ });
81
+ return new Response(body, response);
82
+ }
83
+
21
84
  export async function handleResidentRead(
22
85
  runtime: ResidentRuntime,
23
86
  request: Request | undefined,
@@ -27,13 +90,28 @@ export async function handleResidentRead(
27
90
  if (!admitted) return unavailableResponse();
28
91
 
29
92
  let releaseReader: (() => void) | undefined;
93
+ let deferredFinish = false;
94
+ const isAuthorizationEpochCurrent = (): boolean =>
95
+ admitted.isAuthorizationEpochCurrent?.() ?? true;
96
+ const finish = (): void => {
97
+ releaseReader?.();
98
+ admitted.finish();
99
+ };
30
100
  try {
31
101
  releaseReader = await runtime.readerGate.acquire(admitted.signal);
32
102
  if (admitted.signal.aborted) return unavailableResponse();
33
103
  const response = await runtime.withModelLease(() =>
34
104
  Promise.resolve(operation(admitted.signal))
35
105
  );
36
- return admitted.signal.aborted ? unavailableResponse() : response;
106
+ if (admitted.signal.aborted) return unavailableResponse();
107
+ if (!isAuthorizationEpochCurrent()) {
108
+ return policyChangedResponse();
109
+ }
110
+ if (response.headers.get("content-type")?.includes("text/event-stream")) {
111
+ deferredFinish = true;
112
+ return wrapResidentStream(response, isAuthorizationEpochCurrent, finish);
113
+ }
114
+ return response;
37
115
  } catch (error) {
38
116
  if (
39
117
  admitted.signal.aborted ||
@@ -49,7 +127,6 @@ export async function handleResidentRead(
49
127
  }
50
128
  throw error;
51
129
  } finally {
52
- releaseReader?.();
53
- admitted.finish();
130
+ if (!deferredFinish) finish();
54
131
  }
55
132
  }
@@ -32,6 +32,8 @@ import {
32
32
  loadConfig,
33
33
  } from "../config";
34
34
  import { SavedCapsuleReverificationScheduler } from "../core/capsule-reverification-scheduler";
35
+ import { collectionEgressPolicyEpoch } from "../core/collection-egress-policy-service";
36
+ import { authorizeCurrentEgress } from "../core/egress-authorization";
35
37
  import { acquireWriteLock } from "../core/file-lock";
36
38
  import { JobManager } from "../core/job-manager";
37
39
  import { recordContentMutation } from "../core/mutation-generations";
@@ -78,8 +80,10 @@ export interface ResidentGeneration {
78
80
  }
79
81
 
80
82
  export interface ResidentRequestHandle {
83
+ authorizationEpoch?: string;
81
84
  id: string;
82
85
  signal: AbortSignal;
86
+ isAuthorizationEpochCurrent?(): boolean;
83
87
  finish(): void;
84
88
  }
85
89
 
@@ -101,12 +105,14 @@ export interface ResidentRuntime {
101
105
  readonly generations: ResidentGeneration;
102
106
  readonly activeRequests: number;
103
107
  readonly activeSessions: number;
108
+ readonly authorizationEpoch: string;
104
109
  readonly isShuttingDown: boolean;
105
110
  getStatus(): ResidentStatus;
106
111
  setListenerPort(port: number | null): void;
107
112
  setTransportStatusProvider(
108
113
  provider: (() => HttpMcpTransportStatus) | null
109
114
  ): void;
115
+ setPolicySessionInvalidator(invalidator: (() => Promise<void>) | null): void;
110
116
  admitRequest(signal?: AbortSignal): ResidentRequestHandle | null;
111
117
  withModelLease<T>(operation: () => Promise<T>): Promise<T>;
112
118
  markContentMutation(): void;
@@ -306,6 +312,8 @@ export async function startResidentRuntime(
306
312
  serverInstanceId,
307
313
  toolMutex,
308
314
  });
315
+ let authorizationEpoch = collectionEgressPolicyEpoch(initialConfig);
316
+ jobManager.setAuthorizationEpoch(authorizationEpoch);
309
317
  ctxHolder.jobManager = jobManager;
310
318
  const admission = new AdmissionController();
311
319
  const readerGate = new ReaderGate(
@@ -315,6 +323,7 @@ export async function startResidentRuntime(
315
323
  const startedAt = Date.now();
316
324
  let listenerPort: number | null = null;
317
325
  let transportStatusProvider: (() => HttpMcpTransportStatus) | null = null;
326
+ let policySessionInvalidator: (() => Promise<void>) | null = null;
318
327
  let shutdownState: ResidentStatus["shutdown"]["state"] = "none";
319
328
  let admissionState: ResidentStatus["admission"]["state"] = "accepting";
320
329
  let disposed = false;
@@ -359,7 +368,32 @@ export async function startResidentRuntime(
359
368
  markIndexMutation: () => {
360
369
  generations.index += 1;
361
370
  },
371
+ invalidateEgressPolicy: async () =>
372
+ (await ctxHolder.invalidateEgressPolicy?.()) ?? {
373
+ policyEpoch: collectionEgressPolicyEpoch(ctxHolder.config),
374
+ queuedJobsInvalidated: 0,
375
+ sessionsInvalidated: 0,
376
+ staleWorkMustRetry: true,
377
+ },
362
378
  });
379
+ mcpContext.authorizeTraceExport = async (lineage) => {
380
+ const egress = mcpContext.getEgressContext?.();
381
+ return authorizeCurrentEgress({
382
+ store,
383
+ config: ctxHolder.config,
384
+ lineage,
385
+ action: "export",
386
+ destinationZone:
387
+ egress?.destinationZone === "loopback"
388
+ ? "local_process"
389
+ : (egress?.destinationZone ?? "local_process"),
390
+ caller: egress?.caller ?? {
391
+ authenticated: true,
392
+ operationAuthorized: true,
393
+ },
394
+ contentClass: "retrieval_trace",
395
+ });
396
+ };
363
397
 
364
398
  const runtime: ResidentRuntime = {
365
399
  mode: options.mode ?? "serve",
@@ -385,10 +419,22 @@ export async function startResidentRuntime(
385
419
  get activeSessions() {
386
420
  return transportStatusProvider?.().activeSessions ?? 0;
387
421
  },
422
+ get authorizationEpoch() {
423
+ return authorizationEpoch;
424
+ },
388
425
  get isShuttingDown() {
389
426
  return disposed || !admission.accepting;
390
427
  },
391
- admitRequest: (signal) => admission.admit(signal),
428
+ admitRequest: (signal) => {
429
+ const admitted = admission.admit(signal);
430
+ if (!admitted) return null;
431
+ const requestEpoch = authorizationEpoch;
432
+ return {
433
+ ...admitted,
434
+ authorizationEpoch: requestEpoch,
435
+ isAuthorizationEpochCurrent: () => requestEpoch === authorizationEpoch,
436
+ };
437
+ },
392
438
  async withModelLease<T>(operation: () => Promise<T>): Promise<T> {
393
439
  const lease = modelManager.acquireLease();
394
440
  try {
@@ -450,6 +496,9 @@ export async function startResidentRuntime(
450
496
  setTransportStatusProvider(provider) {
451
497
  transportStatusProvider = provider;
452
498
  },
499
+ setPolicySessionInvalidator(invalidator) {
500
+ policySessionInvalidator = invalidator;
501
+ },
453
502
  async syncAll(syncOptions = {}) {
454
503
  const config = ctxHolder.config;
455
504
  const syncAllService = deps.syncAllService
@@ -508,6 +557,19 @@ export async function startResidentRuntime(
508
557
  };
509
558
  ctxHolder.startBackgroundWork = (operation) =>
510
559
  runtime.startBackgroundWork(operation);
560
+ ctxHolder.invalidateEgressPolicy = async () => {
561
+ const sessionsInvalidated = transportStatusProvider?.().activeSessions ?? 0;
562
+ const policyEpoch = collectionEgressPolicyEpoch(ctxHolder.config);
563
+ authorizationEpoch = policyEpoch;
564
+ const queuedJobsInvalidated = jobManager.setAuthorizationEpoch(policyEpoch);
565
+ await policySessionInvalidator?.();
566
+ return {
567
+ policyEpoch,
568
+ queuedJobsInvalidated,
569
+ sessionsInvalidated,
570
+ staleWorkMustRetry: true,
571
+ };
572
+ };
511
573
  mcpContext.getResidentStatus = () => runtime.getStatus();
512
574
  return { success: true, runtime };
513
575
  }
@@ -14,6 +14,7 @@ import type {
14
14
  Collection,
15
15
  CollectionModelOverrides,
16
16
  Config,
17
+ EgressPolicy,
17
18
  ModelPreset,
18
19
  } from "../../config/types";
19
20
  import type { JobManager } from "../../core/job-manager";
@@ -49,6 +50,13 @@ import {
49
50
  normalizeContentTypes,
50
51
  } from "../../config";
51
52
  import { type PublicCaptureInput } from "../../core/capture";
53
+ import { projectCollectionEgressPolicy } from "../../core/collection-egress-policy-projection";
54
+ import {
55
+ CollectionEgressPolicyService,
56
+ type CollectionEgressPolicyState,
57
+ type EgressRelaxationConfirmation,
58
+ } from "../../core/collection-egress-policy-service";
59
+ import { parsePolicySetBody } from "../../core/collection-egress-policy-validation";
52
60
  import {
53
61
  type ConnectorVerificationCode,
54
62
  getConnectorVerificationRemediation,
@@ -58,6 +66,7 @@ import {
58
66
  deriveEditableCopyRelPath,
59
67
  getDocumentCapabilities,
60
68
  } from "../../core/document-capabilities";
69
+ import { EgressAuditService } from "../../core/egress-audit";
61
70
  import {
62
71
  atomicWrite,
63
72
  copyFilePath,
@@ -144,6 +153,7 @@ import {
144
153
  executeResidentCapturePlan,
145
154
  planResidentCapture,
146
155
  } from "../capture-service";
156
+ import { parseClosedJson } from "../closed-json";
147
157
  import { applyConfigChange, applyConfigChangeTyped } from "../config-sync";
148
158
  import {
149
159
  getConnectorStatuses,
@@ -176,6 +186,12 @@ export interface ContextHolder {
176
186
  jobManager?: JobManager;
177
187
  markContentMutation?: () => void;
178
188
  markIndexMutation?: () => void;
189
+ invalidateEgressPolicy?: () => Promise<{
190
+ policyEpoch: string;
191
+ queuedJobsInvalidated: number;
192
+ sessionsInvalidated: number;
193
+ staleWorkMustRetry: true;
194
+ }>;
179
195
  startBackgroundWork?: (
180
196
  operation: (signal: AbortSignal) => Promise<void>
181
197
  ) => boolean;
@@ -361,6 +377,11 @@ export interface UpdateCollectionRequestBody {
361
377
  };
362
378
  }
363
379
 
380
+ export interface UpdateCollectionEgressPolicyRequestBody {
381
+ policy: EgressPolicy;
382
+ confirmation?: EgressRelaxationConfirmation;
383
+ }
384
+
364
385
  export interface ClearCollectionEmbeddingsRequestBody {
365
386
  mode?: "stale" | "all";
366
387
  }
@@ -387,6 +408,7 @@ export interface CollectionResponse {
387
408
  gen: "override" | "preset" | "default";
388
409
  };
389
410
  activePresetId: string;
411
+ egressPolicy: CollectionEgressPolicyState | null;
390
412
  }
391
413
 
392
414
  function serializeCollection(
@@ -406,6 +428,12 @@ function serializeCollection(
406
428
  effectiveModels: getCollectionEffectiveModels(config, collection.name),
407
429
  modelSources: getCollectionModelSources(config, collection.name),
408
430
  activePresetId: modelConfig.activePreset,
431
+ egressPolicy: (() => {
432
+ const result = new CollectionEgressPolicyService({
433
+ getConfig: () => config,
434
+ }).get(collection.name);
435
+ return result.ok ? result.value : null;
436
+ })(),
409
437
  };
410
438
  }
411
439
 
@@ -1001,6 +1029,150 @@ export async function handleCollections(config: Config): Promise<Response> {
1001
1029
  );
1002
1030
  }
1003
1031
 
1032
+ const collectionPolicyService = (
1033
+ ctxHolder: ContextHolder,
1034
+ store: SqliteAdapter,
1035
+ collection: string
1036
+ ): CollectionEgressPolicyService =>
1037
+ new CollectionEgressPolicyService({
1038
+ getConfig: () => ctxHolder.config,
1039
+ mutateConfig: (mutate) =>
1040
+ applyConfigChangeTyped(
1041
+ ctxHolder,
1042
+ store,
1043
+ (config) => mutate(config),
1044
+ undefined,
1045
+ {
1046
+ projectStore: (targetStore, config) =>
1047
+ projectCollectionEgressPolicy(targetStore, config, collection),
1048
+ }
1049
+ ),
1050
+ onPolicyChanged: async () =>
1051
+ (await ctxHolder.invalidateEgressPolicy?.()) ?? {
1052
+ policyEpoch: "egress-epoch-standalone",
1053
+ queuedJobsInvalidated: 0,
1054
+ sessionsInvalidated: 0,
1055
+ staleWorkMustRetry: true,
1056
+ },
1057
+ });
1058
+
1059
+ export function handleCollectionEgressPolicy(
1060
+ ctxHolder: ContextHolder,
1061
+ name: string
1062
+ ): Response {
1063
+ const state = new CollectionEgressPolicyService({
1064
+ getConfig: () => ctxHolder.config,
1065
+ }).get(name);
1066
+ if (state.ok) return jsonResponse(state.value);
1067
+ return errorResponse(
1068
+ state.code === "NOT_FOUND" ? "NOT_FOUND" : "VALIDATION",
1069
+ state.error,
1070
+ state.code === "NOT_FOUND" ? 404 : 400
1071
+ );
1072
+ }
1073
+
1074
+ export async function handleUpdateCollectionEgressPolicy(
1075
+ ctxHolder: ContextHolder,
1076
+ store: SqliteAdapter,
1077
+ name: string,
1078
+ req: Request
1079
+ ): Promise<Response> {
1080
+ const parsed = await parseClosedJson(req);
1081
+ if (!parsed.ok) return errorResponse("VALIDATION", parsed.error, 400);
1082
+ const input = parsePolicySetBody(parsed.value, name);
1083
+ if (!input.ok) return errorResponse("VALIDATION", input.error, 400);
1084
+ const result = await collectionPolicyService(ctxHolder, store, name).set(
1085
+ input.value
1086
+ );
1087
+ if (!result.ok) {
1088
+ const status =
1089
+ result.code === "NOT_FOUND"
1090
+ ? 404
1091
+ : result.code === "EGRESS_RELAXATION_CONFIRMATION_REQUIRED"
1092
+ ? 409
1093
+ : 400;
1094
+ return errorResponse(result.code, result.error, status);
1095
+ }
1096
+ return jsonResponse(result.value);
1097
+ }
1098
+
1099
+ export async function handleCollectionEgressCheck(
1100
+ ctxHolder: ContextHolder,
1101
+ req: Request
1102
+ ): Promise<Response> {
1103
+ const parsed = await parseClosedJson(req);
1104
+ if (!parsed.ok) return errorResponse("VALIDATION", parsed.error, 400);
1105
+ const result = new CollectionEgressPolicyService({
1106
+ getConfig: () => ctxHolder.config,
1107
+ }).check(parsed.value);
1108
+ return result.ok
1109
+ ? jsonResponse(result.value)
1110
+ : errorResponse("VALIDATION", result.error, 400);
1111
+ }
1112
+
1113
+ export async function handleEgressAuditList(
1114
+ store: SqliteAdapter,
1115
+ req: Request
1116
+ ): Promise<Response> {
1117
+ const url = new URL(req.url);
1118
+ const limit = url.searchParams.has("limit")
1119
+ ? Number(url.searchParams.get("limit"))
1120
+ : undefined;
1121
+ const result = await new EgressAuditService(store).list({
1122
+ limit,
1123
+ cursor: url.searchParams.get("cursor") ?? undefined,
1124
+ });
1125
+ return result.ok
1126
+ ? jsonResponse(result.value)
1127
+ : errorResponse(result.error.code, result.error.message);
1128
+ }
1129
+
1130
+ export async function handleEgressAuditShow(
1131
+ store: SqliteAdapter,
1132
+ auditId: string
1133
+ ): Promise<Response> {
1134
+ const result = await new EgressAuditService(store).show(auditId);
1135
+ return result.ok
1136
+ ? jsonResponse(result.value)
1137
+ : errorResponse(
1138
+ result.error.code,
1139
+ result.error.message,
1140
+ result.error.code === "NOT_FOUND" ? 404 : 400
1141
+ );
1142
+ }
1143
+
1144
+ export async function handleEgressAuditStatus(
1145
+ store: SqliteAdapter
1146
+ ): Promise<Response> {
1147
+ const result = await new EgressAuditService(store).status();
1148
+ return result.ok
1149
+ ? jsonResponse(result.value)
1150
+ : errorResponse(result.error.code, result.error.message);
1151
+ }
1152
+
1153
+ export async function handleEgressAuditDelete(
1154
+ store: SqliteAdapter,
1155
+ auditId: string
1156
+ ): Promise<Response> {
1157
+ const result = await new EgressAuditService(store).delete(auditId);
1158
+ return result.ok
1159
+ ? jsonResponse(result.value)
1160
+ : errorResponse(
1161
+ result.error.code,
1162
+ result.error.message,
1163
+ result.error.code === "NOT_FOUND" ? 404 : 400
1164
+ );
1165
+ }
1166
+
1167
+ export async function handleEgressAuditPurge(
1168
+ store: SqliteAdapter
1169
+ ): Promise<Response> {
1170
+ const result = await new EgressAuditService(store).purge();
1171
+ return result.ok
1172
+ ? jsonResponse(result.value)
1173
+ : errorResponse(result.error.code, result.error.message);
1174
+ }
1175
+
1004
1176
  /**
1005
1177
  * POST /api/publish/export
1006
1178
  * Build a gno.sh-compatible publish artifact for a collection or single doc.
@@ -1166,6 +1338,7 @@ export async function handleCreateCollection(
1166
1338
  if (!collection) {
1167
1339
  return errorResponse("RUNTIME", "Collection not found after add", 500);
1168
1340
  }
1341
+ await ctxHolder.invalidateEgressPolicy?.();
1169
1342
  const jobResult = await startJob(
1170
1343
  "add",
1171
1344
  async (): Promise<SyncResult> => {
@@ -1281,6 +1454,7 @@ export async function handleDeleteCollection(
1281
1454
  const status = statusMap[syncResult.code] ?? 500;
1282
1455
  return errorResponse(syncResult.code, syncResult.error, status);
1283
1456
  }
1457
+ await ctxHolder.invalidateEgressPolicy?.();
1284
1458
  ctxHolder.markContentMutation?.();
1285
1459
  ctxHolder.markIndexMutation?.();
1286
1460
  return jsonResponse({
@@ -1,10 +1,16 @@
1
1
  /** Dedicated loopback browser-clipper HTTP gateway. */
2
2
 
3
+ import type { EgressLineage } from "../../core/egress-provenance";
3
4
  import type { HttpMcpPeerServer } from "../../mcp/http-security";
4
5
  import type { SqliteAdapter } from "../../store/sqlite/adapter";
5
6
  import type { ContextHolder } from "./api";
6
7
 
7
8
  import { prepareBrowserClip } from "../../core/browser-clip";
9
+ import {
10
+ enforceCollectionEgressWithAudit,
11
+ EGRESS_DENIED_MESSAGE,
12
+ EgressDeniedError,
13
+ } from "../../core/egress-enforcement";
8
14
  import {
9
15
  planResidentCapture,
10
16
  type ResidentCapturePlanResult,
@@ -83,6 +89,40 @@ const pollStatusCode = (result: ClipperPairPollResult): number => {
83
89
  return 200;
84
90
  };
85
91
 
92
+ const enforceClipWrite = async (
93
+ ctxHolder: ContextHolder,
94
+ store: SqliteAdapter,
95
+ body: unknown
96
+ ): Promise<EgressLineage | undefined> => {
97
+ const record =
98
+ typeof body === "object" && body !== null
99
+ ? (body as Record<string, unknown>)
100
+ : null;
101
+ const payload =
102
+ typeof record?.payload === "object" && record.payload !== null
103
+ ? (record.payload as Record<string, unknown>)
104
+ : record;
105
+ const destination =
106
+ typeof payload?.destination === "object" && payload.destination !== null
107
+ ? (payload.destination as Record<string, unknown>)
108
+ : null;
109
+ const collection =
110
+ typeof destination?.collection === "string"
111
+ ? destination.collection
112
+ : undefined;
113
+ if (!collection) return undefined;
114
+ const { lineage } = await enforceCollectionEgressWithAudit({
115
+ collections: ctxHolder.config.collections,
116
+ collectionNames: [collection],
117
+ action: "clip_write",
118
+ destinationZone: "loopback",
119
+ caller: { authenticated: true, operationAuthorized: true },
120
+ contentClass: "source",
121
+ store,
122
+ });
123
+ return lineage;
124
+ };
125
+
86
126
  export function createClipperRouteGateway(
87
127
  ctxHolder: ContextHolder,
88
128
  store: SqliteAdapter,
@@ -279,7 +319,12 @@ export function createClipperRouteGateway(
279
319
  if (!authenticated.ok) return authenticated.response;
280
320
  const { admission, grant } = authenticated.value;
281
321
  try {
282
- const prepared = prepareBrowserClip(admission.body);
322
+ const egressLineage = await enforceClipWrite(
323
+ ctxHolder,
324
+ store,
325
+ admission.body
326
+ );
327
+ const prepared = prepareBrowserClip(admission.body, { egressLineage });
283
328
  const planned = await planResidentCapture(
284
329
  ctxHolder,
285
330
  store,
@@ -303,6 +348,12 @@ export function createClipperRouteGateway(
303
348
  admission.origin
304
349
  );
305
350
  } catch (error) {
351
+ if (error instanceof EgressDeniedError) {
352
+ return withClipperCors(
353
+ clipperErrorResponse("EGRESS_DENIED", EGRESS_DENIED_MESSAGE, 403),
354
+ admission.origin
355
+ );
356
+ }
306
357
  return withClipperCors(
307
358
  clipperErrorResponse(
308
359
  "CLIPPER_INVALID_REQUEST",
@@ -321,6 +372,11 @@ export function createClipperRouteGateway(
321
372
  if (!authenticated.ok) return authenticated.response;
322
373
  const { admission, grant } = authenticated.value;
323
374
  try {
375
+ const egressLineage = await enforceClipWrite(
376
+ ctxHolder,
377
+ store,
378
+ admission.body
379
+ );
324
380
  return withClipperCors(
325
381
  await executeClipperCapture({
326
382
  request,
@@ -330,10 +386,17 @@ export function createClipperRouteGateway(
330
386
  context: ctxHolder,
331
387
  store,
332
388
  pairing,
389
+ egressLineage,
333
390
  }),
334
391
  admission.origin
335
392
  );
336
393
  } catch (error) {
394
+ if (error instanceof EgressDeniedError) {
395
+ return withClipperCors(
396
+ clipperErrorResponse("EGRESS_DENIED", EGRESS_DENIED_MESSAGE, 403),
397
+ admission.origin
398
+ );
399
+ }
337
400
  return withClipperCors(
338
401
  clipperErrorResponse(
339
402
  "CLIPPER_CAPTURE_FAILED",
@@ -44,6 +44,9 @@ export async function createMcpHttpGateway(
44
44
  (runtime as Partial<ResidentRuntime>).setTransportStatusProvider?.(() =>
45
45
  transport.getStatus()
46
46
  );
47
+ (runtime as Partial<ResidentRuntime>).setPolicySessionInvalidator?.(() =>
48
+ transport.invalidateAuthenticatedSessions()
49
+ );
47
50
 
48
51
  const route: McpHttpRoute = async (request, server) => {
49
52
  const authorization = await security.authorize(request, server);
@@ -52,8 +55,10 @@ export async function createMcpHttpGateway(
52
55
  // POST responses and GET streams may both be long-lived SSE responses.
53
56
  server.timeout(request, 0);
54
57
  return transport.handleRequest(authorization.value.request, {
58
+ authenticated: authorization.value.authenticated,
55
59
  identity: authorization.value.identity,
56
60
  parsedBody: authorization.value.parsedBody,
61
+ peerClassification: authorization.value.peerClassification,
57
62
  });
58
63
  };
59
64
 
@@ -64,6 +69,7 @@ export async function createMcpHttpGateway(
64
69
  close: async () => {
65
70
  await transport.close();
66
71
  (runtime as Partial<ResidentRuntime>).setTransportStatusProvider?.(null);
72
+ (runtime as Partial<ResidentRuntime>).setPolicySessionInvalidator?.(null);
67
73
  },
68
74
  };
69
75
  }