@gmickel/gno 1.27.1 → 1.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/README.md +46 -20
  2. package/assets/skill/SKILL.md +59 -1
  3. package/browser-extension/artifacts/gno-browser-clipper-v1.29.0.zip +0 -0
  4. package/browser-extension/artifacts/gno-browser-clipper-v1.29.0.zip.sha256 +1 -0
  5. package/browser-extension/dist/chunk-b2zm0jjd.js +50 -0
  6. package/browser-extension/dist/manifest.json +1 -1
  7. package/browser-extension/dist/preview.html +1 -1
  8. package/browser-extension/dist/service-worker.js +6 -6
  9. package/package.json +2 -2
  10. package/spec/cli.md +82 -5
  11. package/spec/db/schema.sql +31 -0
  12. package/spec/mcp.md +71 -0
  13. package/spec/output-schemas/ask.schema.json +156 -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 +183 -3
  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/get.schema.json +94 -0
  22. package/spec/output-schemas/mcp-http-error.schema.json +113 -2
  23. package/spec/output-schemas/mcp-job-status.schema.json +28 -0
  24. package/spec/output-schemas/multi-get.schema.json +128 -0
  25. package/spec/output-schemas/publish-artifact.schema.json +32 -0
  26. package/spec/output-schemas/record-import.schema.json +223 -0
  27. package/spec/output-schemas/retrieval-trace-common.schema.json +31 -0
  28. package/spec/output-schemas/retrieval-trace-export.schema.json +3 -0
  29. package/spec/output-schemas/retrieval-trace-qrels.schema.json +3 -0
  30. package/spec/output-schemas/retrieval-trace-summary.schema.json +4 -0
  31. package/spec/output-schemas/search-result.schema.json +94 -0
  32. package/spec/output-schemas/search-results.schema.json +155 -0
  33. package/spec/project-profile.schema.json +66 -0
  34. package/src/app/context-format.ts +1 -1
  35. package/src/app/context-runtime-contract.ts +16 -7
  36. package/src/cli/commands/ask.ts +7 -0
  37. package/src/cli/commands/collection/index.ts +5 -0
  38. package/src/cli/commands/collection/policy.ts +126 -0
  39. package/src/cli/commands/context-build.ts +5 -0
  40. package/src/cli/commands/daemon.ts +45 -3
  41. package/src/cli/commands/egress-audit.ts +68 -0
  42. package/src/cli/commands/embed.ts +2 -0
  43. package/src/cli/commands/get.ts +12 -2
  44. package/src/cli/commands/index-cmd.ts +13 -0
  45. package/src/cli/commands/multi-get.ts +9 -2
  46. package/src/cli/commands/query.ts +12 -0
  47. package/src/cli/commands/replay.ts +13 -3
  48. package/src/cli/commands/shared.ts +28 -0
  49. package/src/cli/commands/update.ts +5 -0
  50. package/src/cli/commands/vsearch.ts +3 -1
  51. package/src/cli/program.ts +127 -0
  52. package/src/config/index.ts +9 -0
  53. package/src/config/project-profile.ts +2 -0
  54. package/src/config/types.ts +66 -0
  55. package/src/converters/adapters/browser-export/adapter.ts +199 -0
  56. package/src/converters/adapters/browser-export/formats.ts +358 -0
  57. package/src/converters/adapters/email/adapter.ts +429 -0
  58. package/src/converters/adapters/email/html.ts +162 -0
  59. package/src/converters/adapters/email/mime.ts +454 -0
  60. package/src/converters/adapters/email/parameters.ts +77 -0
  61. package/src/converters/adapters/ical/adapter.ts +475 -0
  62. package/src/converters/adapters/ical/recurrence.ts +186 -0
  63. package/src/converters/adapters/jsonl/adapter.ts +238 -0
  64. package/src/converters/adapters/jsonl/config.ts +105 -0
  65. package/src/converters/adapters/shared/html-text.ts +165 -0
  66. package/src/converters/adapters/shared/record-utils.ts +79 -0
  67. package/src/converters/adapters/shared/utf8-lines.ts +141 -0
  68. package/src/converters/adapters/transcript/adapter.ts +295 -0
  69. package/src/converters/adapters/transcript/json.ts +184 -0
  70. package/src/converters/adapters/transcript/model.ts +171 -0
  71. package/src/converters/adapters/transcript/text.ts +58 -0
  72. package/src/converters/adapters/transcript/timed.ts +152 -0
  73. package/src/converters/index.ts +11 -1
  74. package/src/converters/mime.ts +8 -0
  75. package/src/converters/pipeline.ts +15 -1
  76. package/src/converters/registry.ts +37 -1
  77. package/src/converters/types.ts +136 -0
  78. package/src/core/browser-clip.ts +16 -1
  79. package/src/core/collection-egress-policy-projection.ts +28 -0
  80. package/src/core/collection-egress-policy-service.ts +443 -0
  81. package/src/core/collection-egress-policy-validation.ts +242 -0
  82. package/src/core/config-mutation.ts +31 -19
  83. package/src/core/context-capsule-schema.ts +198 -1
  84. package/src/core/context-capsule-validation.ts +4 -4
  85. package/src/core/context-capsule-verification.ts +5 -1
  86. package/src/core/context-capsule.ts +177 -112
  87. package/src/core/context-evidence.ts +93 -15
  88. package/src/core/destination-classifier.ts +402 -0
  89. package/src/core/document-capabilities.ts +12 -0
  90. package/src/core/egress-audit.ts +239 -0
  91. package/src/core/egress-authorization.ts +50 -0
  92. package/src/core/egress-enforcement.ts +264 -0
  93. package/src/core/egress-policy.ts +440 -0
  94. package/src/core/egress-provenance.ts +336 -0
  95. package/src/core/job-manager.ts +37 -0
  96. package/src/core/network-boundary-inventory.ts +261 -0
  97. package/src/core/project-profile-apply-state.ts +5 -0
  98. package/src/core/project-profile.ts +4 -0
  99. package/src/core/record-metadata.ts +49 -0
  100. package/src/core/retrieval-qrels.ts +6 -0
  101. package/src/core/retrieval-replay.ts +18 -4
  102. package/src/core/retrieval-trace-export.ts +23 -4
  103. package/src/core/retrieval-trace-management-helpers.ts +1 -0
  104. package/src/core/retrieval-trace-management-types.ts +3 -0
  105. package/src/core/retrieval-trace-management.ts +9 -1
  106. package/src/core/retrieval-trace-session.ts +94 -0
  107. package/src/core/retrieval-trace.ts +22 -0
  108. package/src/ingestion/record-adapter-canonical.ts +433 -0
  109. package/src/ingestion/record-adapter.ts +448 -0
  110. package/src/ingestion/record-container.ts +704 -0
  111. package/src/ingestion/record-path.ts +20 -0
  112. package/src/ingestion/record-sync.ts +70 -0
  113. package/src/ingestion/sync.ts +243 -36
  114. package/src/ingestion/types.ts +71 -1
  115. package/src/ingestion/walker.ts +31 -11
  116. package/src/llm/errors.ts +10 -0
  117. package/src/llm/http-inference.ts +175 -0
  118. package/src/llm/http-policy.ts +537 -0
  119. package/src/llm/httpEmbedding.ts +47 -28
  120. package/src/llm/httpGeneration.ts +31 -18
  121. package/src/llm/httpRerank.ts +30 -18
  122. package/src/llm/index.ts +1 -0
  123. package/src/llm/nodeLlamaCpp/adapter.ts +55 -20
  124. package/src/llm/pinned-http-connection.ts +177 -0
  125. package/src/mcp/context.ts +53 -1
  126. package/src/mcp/http-egress.ts +220 -0
  127. package/src/mcp/http-security.ts +32 -21
  128. package/src/mcp/http-session.ts +13 -2
  129. package/src/mcp/http-transport.ts +96 -6
  130. package/src/mcp/sync-egress.ts +24 -0
  131. package/src/mcp/tools/add-collection.ts +4 -0
  132. package/src/mcp/tools/ask.ts +1 -1
  133. package/src/mcp/tools/context.ts +9 -2
  134. package/src/mcp/tools/egress.ts +247 -0
  135. package/src/mcp/tools/embed.ts +1 -0
  136. package/src/mcp/tools/get.ts +10 -2
  137. package/src/mcp/tools/index-cmd.ts +7 -0
  138. package/src/mcp/tools/index.ts +98 -0
  139. package/src/mcp/tools/multi-get.ts +9 -2
  140. package/src/mcp/tools/query.ts +10 -0
  141. package/src/mcp/tools/remove-collection.ts +4 -0
  142. package/src/mcp/tools/sync.ts +6 -0
  143. package/src/mcp/tools/trace.ts +7 -1
  144. package/src/mcp/tools/vsearch.ts +1 -0
  145. package/src/mcp/tools/workspace-write.ts +2 -0
  146. package/src/pipeline/egress-lineage.ts +124 -0
  147. package/src/pipeline/filters.ts +1 -1
  148. package/src/pipeline/graph-retrieval.ts +7 -4
  149. package/src/pipeline/hybrid.ts +21 -4
  150. package/src/pipeline/result-context.ts +12 -4
  151. package/src/pipeline/search.ts +23 -4
  152. package/src/pipeline/types.ts +6 -0
  153. package/src/pipeline/vsearch.ts +67 -28
  154. package/src/publish/artifact.ts +31 -3
  155. package/src/publish/export-service.ts +25 -0
  156. package/src/sdk/client.ts +119 -2
  157. package/src/sdk/documents.ts +13 -5
  158. package/src/sdk/embed.ts +1 -0
  159. package/src/sdk/types.ts +33 -0
  160. package/src/serve/browse-tree.ts +4 -2
  161. package/src/serve/clipper-capture.ts +5 -1
  162. package/src/serve/closed-json.ts +61 -0
  163. package/src/serve/config-sync.ts +4 -1
  164. package/src/serve/context.ts +1 -0
  165. package/src/serve/doc-events.ts +110 -39
  166. package/src/serve/public/components/CollectionEgressCheckPanel.tsx +411 -0
  167. package/src/serve/public/components/CollectionEgressPolicyEditor.tsx +78 -0
  168. package/src/serve/public/components/CollectionModelDialog.tsx +93 -9
  169. package/src/serve/public/components/EgressAuditPanel.tsx +278 -0
  170. package/src/serve/public/globals.built.css +1 -1
  171. package/src/serve/public/pages/Collections.tsx +3 -0
  172. package/src/serve/resident-request.ts +80 -3
  173. package/src/serve/resident-runtime.ts +63 -1
  174. package/src/serve/routes/api.ts +237 -65
  175. package/src/serve/routes/clipper.ts +64 -1
  176. package/src/serve/routes/mcp.ts +6 -0
  177. package/src/serve/routes/traces.ts +27 -4
  178. package/src/serve/server.ts +118 -7
  179. package/src/store/migrations/022-record-export-lineage.ts +42 -0
  180. package/src/store/migrations/023-collection-egress-policy.ts +61 -0
  181. package/src/store/migrations/024-egress-derived-lineage.ts +121 -0
  182. package/src/store/migrations/025-collection-egress-policy-revision.ts +40 -0
  183. package/src/store/migrations/index.ts +8 -0
  184. package/src/store/retrieval-trace-codec.ts +15 -0
  185. package/src/store/sqlite/adapter.ts +284 -14
  186. package/src/store/sqlite/change-journal-store.ts +41 -2
  187. package/src/store/sqlite/egress-audit-store.ts +485 -0
  188. package/src/store/sqlite/retrieval-trace-management-store.ts +47 -6
  189. package/src/store/sqlite/retrieval-trace-retention.ts +2 -0
  190. package/src/store/sqlite/retrieval-trace-rows.ts +8 -1
  191. package/src/store/sqlite/retrieval-trace-store.ts +104 -5
  192. package/src/store/types.ts +166 -1
  193. package/browser-extension/artifacts/gno-browser-clipper-v1.27.1.zip +0 -0
  194. package/browser-extension/artifacts/gno-browser-clipper-v1.27.1.zip.sha256 +0 -1
  195. package/browser-extension/dist/chunk-vn5f663b.js +0 -50
@@ -25,46 +25,87 @@ export interface DocumentEventBusState {
25
25
  retryMs: number;
26
26
  }
27
27
 
28
+ export interface DocumentEventStreamAuthorization {
29
+ authorizationEpoch: string;
30
+ isAuthorizationEpochCurrent: () => boolean;
31
+ onClose?: () => void;
32
+ signal?: AbortSignal;
33
+ }
34
+
35
+ interface DocumentEventSubscriber {
36
+ authorization: DocumentEventStreamAuthorization;
37
+ closed: boolean;
38
+ controller: ReadableStreamDefaultController<Uint8Array>;
39
+ keepaliveTimer: ReturnType<typeof setInterval> | null;
40
+ removeAbortListener: (() => void) | null;
41
+ }
42
+
28
43
  const encoder = new TextEncoder();
29
44
  const EVENT_RETRY_MS = 2_000;
30
45
  const KEEPALIVE_MS = 15_000;
46
+ const POLICY_CHANGED_FRAME = encoder.encode(
47
+ `retry: ${EVENT_RETRY_MS}\nevent: egress-policy-changed\ndata: {"error":{"code":"EGRESS_POLICY_CHANGED","message":"Collection policy changed; retry"}}\n\n`
48
+ );
31
49
 
32
50
  export class DocumentEventBus {
33
- readonly #controllers = new Set<
34
- ReadableStreamDefaultController<Uint8Array>
35
- >();
36
-
37
- createResponse(): Response {
38
- const controllers = this.#controllers;
39
- let streamController: ReadableStreamDefaultController<Uint8Array> | null =
40
- null;
41
- let keepaliveTimer: ReturnType<typeof setInterval> | null = null;
51
+ readonly #subscribers = new Set<DocumentEventSubscriber>();
52
+ readonly #keepaliveMs: number;
53
+
54
+ constructor(options: { keepaliveMs?: number } = {}) {
55
+ this.#keepaliveMs = options.keepaliveMs ?? KEEPALIVE_MS;
56
+ }
57
+
58
+ createResponse(
59
+ authorization: DocumentEventStreamAuthorization = {
60
+ authorizationEpoch: "unrestricted",
61
+ isAuthorizationEpochCurrent: () => true,
62
+ }
63
+ ): Response {
64
+ let subscriber: DocumentEventSubscriber | null = null;
42
65
  const stream = new ReadableStream<Uint8Array>({
43
- start(controller) {
44
- streamController = controller;
45
- controllers.add(controller);
66
+ start: (controller) => {
67
+ subscriber = {
68
+ authorization,
69
+ closed: false,
70
+ controller,
71
+ keepaliveTimer: null,
72
+ removeAbortListener: null,
73
+ };
74
+ this.#subscribers.add(subscriber);
75
+ if (!authorization.isAuthorizationEpochCurrent()) {
76
+ this.#invalidateSubscriber(subscriber);
77
+ return;
78
+ }
46
79
  controller.enqueue(
47
80
  encoder.encode(`retry: ${EVENT_RETRY_MS}\n: connected\n\n`)
48
81
  );
49
- keepaliveTimer = setInterval(() => {
50
- try {
51
- controller.enqueue(encoder.encode(": keepalive\n\n"));
52
- } catch {
53
- if (keepaliveTimer) {
54
- clearInterval(keepaliveTimer);
82
+ subscriber.keepaliveTimer = setInterval(
83
+ () => {
84
+ if (!authorization.isAuthorizationEpochCurrent()) {
85
+ this.#invalidateSubscriber(subscriber);
86
+ return;
55
87
  }
56
- controllers.delete(controller);
57
- }
58
- }, KEEPALIVE_MS);
59
- },
60
- cancel() {
61
- if (keepaliveTimer) {
62
- clearInterval(keepaliveTimer);
63
- }
64
- if (streamController) {
65
- controllers.delete(streamController);
88
+ try {
89
+ controller.enqueue(encoder.encode(": keepalive\n\n"));
90
+ } catch {
91
+ this.#closeSubscriber(subscriber);
92
+ }
93
+ },
94
+ this.#keepaliveMs
95
+ );
96
+ if (authorization.signal) {
97
+ const onAbort = (): void => this.#closeSubscriber(subscriber);
98
+ authorization.signal.addEventListener("abort", onAbort, {
99
+ once: true,
100
+ });
101
+ subscriber.removeAbortListener = () =>
102
+ authorization.signal?.removeEventListener("abort", onAbort);
103
+ if (authorization.signal.aborted) onAbort();
66
104
  }
67
105
  },
106
+ cancel: () => {
107
+ this.#closeSubscriber(subscriber, false);
108
+ },
68
109
  });
69
110
 
70
111
  return new Response(stream, {
@@ -81,30 +122,60 @@ export class DocumentEventBus {
81
122
  `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`
82
123
  );
83
124
 
84
- for (const controller of this.#controllers) {
125
+ for (const subscriber of this.#subscribers) {
126
+ if (!subscriber.authorization.isAuthorizationEpochCurrent()) {
127
+ this.#invalidateSubscriber(subscriber);
128
+ continue;
129
+ }
85
130
  try {
86
- controller.enqueue(payload);
131
+ subscriber.controller.enqueue(payload);
87
132
  } catch {
88
- this.#controllers.delete(controller);
133
+ this.#closeSubscriber(subscriber);
89
134
  }
90
135
  }
91
136
  }
92
137
 
93
138
  close(): void {
94
- for (const controller of this.#controllers) {
95
- try {
96
- controller.close();
97
- } catch {
98
- // Best-effort shutdown.
99
- }
139
+ for (const subscriber of this.#subscribers) {
140
+ this.#closeSubscriber(subscriber);
100
141
  }
101
- this.#controllers.clear();
102
142
  }
103
143
 
104
144
  getState(): DocumentEventBusState {
105
145
  return {
106
- connectedClients: this.#controllers.size,
146
+ connectedClients: this.#subscribers.size,
107
147
  retryMs: EVENT_RETRY_MS,
108
148
  };
109
149
  }
150
+
151
+ #invalidateSubscriber(subscriber: DocumentEventSubscriber | null): void {
152
+ if (!subscriber || subscriber.closed) return;
153
+ try {
154
+ subscriber.controller.enqueue(POLICY_CHANGED_FRAME);
155
+ } catch {
156
+ // The stream may already be cancelled.
157
+ }
158
+ this.#closeSubscriber(subscriber);
159
+ }
160
+
161
+ #closeSubscriber(
162
+ subscriber: DocumentEventSubscriber | null,
163
+ closeController = true
164
+ ): void {
165
+ if (!subscriber || subscriber.closed) return;
166
+ subscriber.closed = true;
167
+ if (subscriber.keepaliveTimer) {
168
+ clearInterval(subscriber.keepaliveTimer);
169
+ }
170
+ subscriber.removeAbortListener?.();
171
+ this.#subscribers.delete(subscriber);
172
+ if (closeController) {
173
+ try {
174
+ subscriber.controller.close();
175
+ } catch {
176
+ // Best-effort stream shutdown.
177
+ }
178
+ }
179
+ subscriber.authorization.onClose?.();
180
+ }
110
181
  }
@@ -0,0 +1,411 @@
1
+ import { Loader2Icon, ScanSearchIcon } from "lucide-react";
2
+ import { useEffect, useMemo, useState } from "react";
3
+
4
+ import { apiFetch } from "../hooks/use-api";
5
+ import { Button } from "./ui/button";
6
+
7
+ type Action =
8
+ | "clip_write"
9
+ | "export"
10
+ | "publish"
11
+ | "remote_inference"
12
+ | "retrieve"
13
+ | "serve";
14
+ type Destination = "lan" | "local_process" | "loopback" | "remote";
15
+ type ContentClass =
16
+ | "attachment"
17
+ | "audit_log"
18
+ | "capsule"
19
+ | "embedding"
20
+ | "metadata"
21
+ | "retrieval_trace"
22
+ | "snippet"
23
+ | "source";
24
+
25
+ interface CheckResult {
26
+ allowedCollections: string[];
27
+ mode: "complete" | "denied" | "partial";
28
+ decision: {
29
+ allowed: boolean;
30
+ reason: string;
31
+ };
32
+ disclosure: null | {
33
+ code: "EGRESS_PARTIAL_RESULT";
34
+ omittedCount: number;
35
+ omittedCollections: string[];
36
+ };
37
+ omittedCollections: Array<{ collection: string; reason: string }>;
38
+ remediation: null | { message: string };
39
+ }
40
+
41
+ interface CollectionEgressCheckPanelProps {
42
+ availableCollections: readonly string[];
43
+ collection: string;
44
+ effectivePolicy: "lan" | "local_only" | "remote";
45
+ source: "config_default" | "explicit";
46
+ }
47
+
48
+ const selectClass =
49
+ "h-8 rounded-md border border-border/20 bg-muted/10 px-2 font-mono text-[11px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-secondary";
50
+
51
+ export function CollectionEgressCheckPanel({
52
+ availableCollections,
53
+ collection,
54
+ effectivePolicy,
55
+ source,
56
+ }: CollectionEgressCheckPanelProps) {
57
+ const [action, setAction] = useState<Action>("export");
58
+ const [destinationZone, setDestinationZone] = useState<Destination>("remote");
59
+ const [contentClass, setContentClass] =
60
+ useState<ContentClass>("retrieval_trace");
61
+ const [authenticated, setAuthenticated] = useState(true);
62
+ const [authorized, setAuthorized] = useState(true);
63
+ const [partialResults, setPartialResults] = useState<"deny" | "explicit">(
64
+ "deny"
65
+ );
66
+ const [collectionSearch, setCollectionSearch] = useState("");
67
+ const [selectedCollections, setSelectedCollections] = useState<string[]>([
68
+ collection,
69
+ ]);
70
+ const [checking, setChecking] = useState(false);
71
+ const [error, setError] = useState<string | null>(null);
72
+ const [result, setResult] = useState<CheckResult | null>(null);
73
+ const collectionOptions = useMemo(
74
+ () =>
75
+ [...new Set([collection, ...availableCollections])].sort((left, right) =>
76
+ left.localeCompare(right)
77
+ ),
78
+ [availableCollections, collection]
79
+ );
80
+ const visibleCollections = useMemo(() => {
81
+ const query = collectionSearch.trim().toLowerCase();
82
+ return query
83
+ ? collectionOptions.filter((name) => name.toLowerCase().includes(query))
84
+ : collectionOptions;
85
+ }, [collectionOptions, collectionSearch]);
86
+ const partialAvailable = selectedCollections.length > 1;
87
+
88
+ useEffect(() => {
89
+ setSelectedCollections([collection]);
90
+ setCollectionSearch("");
91
+ setPartialResults("deny");
92
+ setResult(null);
93
+ setError(null);
94
+ }, [collection]);
95
+
96
+ useEffect(() => {
97
+ if (!partialAvailable && partialResults === "explicit") {
98
+ setPartialResults("deny");
99
+ }
100
+ }, [partialAvailable, partialResults]);
101
+
102
+ const toggleCollection = (name: string, checked: boolean): void => {
103
+ setSelectedCollections((current) => {
104
+ if (checked) {
105
+ return current.includes(name) || current.length >= 64
106
+ ? current
107
+ : [...current, name];
108
+ }
109
+ return current.length === 1
110
+ ? current
111
+ : current.filter((candidate) => candidate !== name);
112
+ });
113
+ setResult(null);
114
+ };
115
+
116
+ const check = async () => {
117
+ setChecking(true);
118
+ setError(null);
119
+ const response = await apiFetch<CheckResult>("/api/egress/check", {
120
+ method: "POST",
121
+ body: JSON.stringify({
122
+ action,
123
+ caller: {
124
+ authenticated,
125
+ operationAuthorized: authorized,
126
+ },
127
+ collections: selectedCollections,
128
+ contentClass,
129
+ destinationZone,
130
+ partialResults:
131
+ selectedCollections.length > 1 ? partialResults : "deny",
132
+ }),
133
+ });
134
+ setChecking(false);
135
+ if (response.error || !response.data) {
136
+ setResult(null);
137
+ setError(response.error ?? "Policy check failed");
138
+ return;
139
+ }
140
+ setResult(response.data);
141
+ };
142
+
143
+ return (
144
+ <section
145
+ aria-labelledby="egress-check-title"
146
+ className="space-y-3 border-border/15 border-t px-6 py-5"
147
+ >
148
+ <div className="flex items-start justify-between gap-4">
149
+ <div>
150
+ <h3
151
+ className="flex items-center gap-2 font-medium text-[13px]"
152
+ id="egress-check-title"
153
+ >
154
+ <ScanSearchIcon className="size-3.5 text-secondary/70" />
155
+ Check a boundary
156
+ </h3>
157
+ <p className="mt-1 text-muted-foreground/50 text-xs">
158
+ Exact, content-free explanation before an action runs.
159
+ </p>
160
+ </div>
161
+ <p className="text-right font-mono text-[10px] text-muted-foreground/50">
162
+ {effectivePolicy} / {source}
163
+ </p>
164
+ </div>
165
+
166
+ <div className="grid gap-2 md:grid-cols-3">
167
+ <label className="space-y-1 text-[10px] text-muted-foreground/60 uppercase">
168
+ Action
169
+ <select
170
+ className={`${selectClass} w-full`}
171
+ disabled={checking}
172
+ onChange={(event) => setAction(event.target.value as Action)}
173
+ value={action}
174
+ >
175
+ {[
176
+ "retrieve",
177
+ "serve",
178
+ "publish",
179
+ "remote_inference",
180
+ "export",
181
+ "clip_write",
182
+ ].map((value) => (
183
+ <option key={value} value={value}>
184
+ {value}
185
+ </option>
186
+ ))}
187
+ </select>
188
+ </label>
189
+ <label className="space-y-1 text-[10px] text-muted-foreground/60 uppercase">
190
+ Destination
191
+ <select
192
+ className={`${selectClass} w-full`}
193
+ disabled={checking}
194
+ onChange={(event) =>
195
+ setDestinationZone(event.target.value as Destination)
196
+ }
197
+ value={destinationZone}
198
+ >
199
+ {["local_process", "loopback", "lan", "remote"].map((value) => (
200
+ <option key={value} value={value}>
201
+ {value}
202
+ </option>
203
+ ))}
204
+ </select>
205
+ </label>
206
+ <label className="space-y-1 text-[10px] text-muted-foreground/60 uppercase">
207
+ Content class
208
+ <select
209
+ className={`${selectClass} w-full`}
210
+ disabled={checking}
211
+ onChange={(event) =>
212
+ setContentClass(event.target.value as ContentClass)
213
+ }
214
+ value={contentClass}
215
+ >
216
+ {[
217
+ "source",
218
+ "snippet",
219
+ "metadata",
220
+ "attachment",
221
+ "embedding",
222
+ "capsule",
223
+ "audit_log",
224
+ "retrieval_trace",
225
+ ].map((value) => (
226
+ <option key={value} value={value}>
227
+ {value}
228
+ </option>
229
+ ))}
230
+ </select>
231
+ </label>
232
+ </div>
233
+
234
+ <fieldset className="space-y-2 rounded-md border border-border/20 bg-muted/5 p-3">
235
+ <legend className="px-1 font-mono text-[10px] text-muted-foreground/60 uppercase">
236
+ Collection scope
237
+ </legend>
238
+ <label
239
+ className="block space-y-1 text-[10px] text-muted-foreground/60 uppercase"
240
+ htmlFor="egress-collection-search"
241
+ >
242
+ Search collections
243
+ <input
244
+ className={`${selectClass} block w-full normal-case`}
245
+ id="egress-collection-search"
246
+ disabled={checking}
247
+ onChange={(event) => setCollectionSearch(event.target.value)}
248
+ placeholder="Filter existing collections"
249
+ type="search"
250
+ value={collectionSearch}
251
+ />
252
+ </label>
253
+ <div
254
+ aria-label="Collections included in the check"
255
+ className="grid max-h-28 gap-1 overflow-y-auto sm:grid-cols-2"
256
+ role="group"
257
+ >
258
+ {visibleCollections.map((name) => {
259
+ const checked = selectedCollections.includes(name);
260
+ return (
261
+ <label
262
+ className="flex min-w-0 items-center gap-2 rounded px-1.5 py-1 text-xs hover:bg-muted/20"
263
+ key={name}
264
+ >
265
+ <input
266
+ checked={checked}
267
+ disabled={
268
+ checking || (checked && selectedCollections.length === 1)
269
+ }
270
+ onChange={(event) =>
271
+ toggleCollection(name, event.target.checked)
272
+ }
273
+ type="checkbox"
274
+ />
275
+ <span className="truncate font-mono" title={name}>
276
+ {name}
277
+ </span>
278
+ {name === collection ? (
279
+ <span className="ml-auto text-[9px] text-muted-foreground/45 uppercase">
280
+ current
281
+ </span>
282
+ ) : null}
283
+ </label>
284
+ );
285
+ })}
286
+ {visibleCollections.length === 0 ? (
287
+ <p className="text-muted-foreground/50 text-xs">
288
+ No matching collections.
289
+ </p>
290
+ ) : null}
291
+ </div>
292
+ <p className="text-[10px] text-muted-foreground/50">
293
+ {selectedCollections.length} selected · maximum 64
294
+ </p>
295
+ </fieldset>
296
+
297
+ <div className="flex flex-wrap items-center gap-x-5 gap-y-2 text-xs">
298
+ <label className="flex items-center gap-2">
299
+ <input
300
+ checked={authenticated}
301
+ disabled={checking}
302
+ onChange={(event) => setAuthenticated(event.target.checked)}
303
+ type="checkbox"
304
+ />
305
+ Authenticated
306
+ </label>
307
+ <label className="flex items-center gap-2">
308
+ <input
309
+ checked={authorized}
310
+ disabled={checking}
311
+ onChange={(event) => setAuthorized(event.target.checked)}
312
+ type="checkbox"
313
+ />
314
+ Operation authorized
315
+ </label>
316
+ <label className="flex items-center gap-2">
317
+ Partial mode
318
+ <select
319
+ className={selectClass}
320
+ disabled={checking || !partialAvailable}
321
+ onChange={(event) =>
322
+ setPartialResults(event.target.value as "deny" | "explicit")
323
+ }
324
+ value={partialResults}
325
+ >
326
+ <option value="deny">deny</option>
327
+ <option value="explicit">explicit</option>
328
+ </select>
329
+ </label>
330
+ {!partialAvailable ? (
331
+ <span className="text-[10px] text-muted-foreground/50">
332
+ Explicit partial results require at least two collections.
333
+ </span>
334
+ ) : null}
335
+ <Button
336
+ className="ml-auto text-xs"
337
+ disabled={checking}
338
+ onClick={() => void check()}
339
+ size="sm"
340
+ type="button"
341
+ variant="outline"
342
+ >
343
+ {checking ? <Loader2Icon className="size-3 animate-spin" /> : null}
344
+ Explain
345
+ </Button>
346
+ </div>
347
+
348
+ {error ? (
349
+ <p className="text-destructive text-xs" role="alert">
350
+ {error}
351
+ </p>
352
+ ) : null}
353
+ {result ? (
354
+ <output
355
+ aria-live="polite"
356
+ className="block rounded-md border border-border/20 bg-muted/10 p-3 text-xs"
357
+ >
358
+ <p className="font-mono">
359
+ {result.mode} · {result.decision.reason}
360
+ </p>
361
+ <p className="mt-1 text-muted-foreground/60">
362
+ Omitted: {result.disclosure?.omittedCount ?? 0}
363
+ </p>
364
+ <div className="mt-2 grid gap-2 sm:grid-cols-2">
365
+ <div>
366
+ <p className="font-mono text-[10px] text-muted-foreground/50 uppercase">
367
+ Allowed collections
368
+ </p>
369
+ {result.allowedCollections.length > 0 ? (
370
+ <ul className="mt-1 space-y-0.5 font-mono">
371
+ {result.allowedCollections.map((name) => (
372
+ <li key={name}>{name}</li>
373
+ ))}
374
+ </ul>
375
+ ) : (
376
+ <p className="mt-1 text-muted-foreground/50">None</p>
377
+ )}
378
+ </div>
379
+ <div>
380
+ <p className="font-mono text-[10px] text-muted-foreground/50 uppercase">
381
+ Omitted collections
382
+ </p>
383
+ {result.omittedCollections.length > 0 ? (
384
+ <ul className="mt-1 space-y-0.5 font-mono">
385
+ {result.omittedCollections.map((item) => (
386
+ <li key={item.collection}>
387
+ {item.collection} · {item.reason}
388
+ </li>
389
+ ))}
390
+ </ul>
391
+ ) : (
392
+ <p className="mt-1 text-muted-foreground/50">None</p>
393
+ )}
394
+ </div>
395
+ </div>
396
+ {result.disclosure ? (
397
+ <p className="mt-2 font-mono text-[10px] text-muted-foreground/60">
398
+ {result.disclosure.code}:{" "}
399
+ {result.disclosure.omittedCollections.join(", ")}
400
+ </p>
401
+ ) : null}
402
+ {result.remediation ? (
403
+ <p className="mt-1 text-muted-foreground/70">
404
+ {result.remediation.message}
405
+ </p>
406
+ ) : null}
407
+ </output>
408
+ ) : null}
409
+ </section>
410
+ );
411
+ }
@@ -0,0 +1,78 @@
1
+ import { ShieldCheckIcon } from "lucide-react";
2
+
3
+ export type CollectionEgressPolicy = "local_only" | "lan" | "remote";
4
+
5
+ interface CollectionEgressPolicyEditorProps {
6
+ confirmed: boolean;
7
+ onConfirmedChange: (confirmed: boolean) => void;
8
+ onPolicyChange: (policy: CollectionEgressPolicy) => void;
9
+ policy: CollectionEgressPolicy;
10
+ relaxed: boolean;
11
+ revision: number;
12
+ source: "explicit" | "config_default";
13
+ }
14
+
15
+ export function CollectionEgressPolicyEditor({
16
+ confirmed,
17
+ onConfirmedChange,
18
+ onPolicyChange,
19
+ policy,
20
+ relaxed,
21
+ revision,
22
+ source,
23
+ }: CollectionEgressPolicyEditorProps) {
24
+ return (
25
+ <section className="grid gap-x-5 gap-y-3 px-6 py-5 lg:grid-cols-[180px_minmax(0,1fr)]">
26
+ <div className="space-y-1">
27
+ <div className="flex items-center gap-2">
28
+ <ShieldCheckIcon className="size-3.5 text-secondary/70" />
29
+ <h3 className="font-medium text-[13px]">Data boundary</h3>
30
+ </div>
31
+ <p className="text-muted-foreground/50 text-xs leading-relaxed">
32
+ Controls where source and derived content may travel.
33
+ </p>
34
+ </div>
35
+ <div className="space-y-3">
36
+ <label
37
+ className="block font-mono text-[10px] text-muted-foreground/60 uppercase tracking-[0.1em]"
38
+ htmlFor="collection-egress-policy"
39
+ >
40
+ Collection policy
41
+ </label>
42
+ <select
43
+ className="h-9 w-full rounded-md border border-border/20 bg-muted/10 px-3 font-mono text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-secondary"
44
+ id="collection-egress-policy"
45
+ onChange={(event) =>
46
+ onPolicyChange(event.target.value as CollectionEgressPolicy)
47
+ }
48
+ value={policy}
49
+ >
50
+ <option value="local_only">Local only</option>
51
+ <option value="lan">Authenticated LAN</option>
52
+ <option value="remote">Authenticated remote</option>
53
+ </select>
54
+ <p className="text-muted-foreground/45 text-xs">
55
+ Source: <span className="font-mono">{source}</span> · revision{" "}
56
+ <span className="font-mono">{revision}</span>
57
+ </p>
58
+ {relaxed ? (
59
+ <div className="rounded-md border border-secondary/25 bg-secondary/6 p-3">
60
+ <label className="flex cursor-pointer items-start gap-2.5 text-xs">
61
+ <input
62
+ checked={confirmed}
63
+ className="mt-0.5 size-4 accent-[var(--secondary)]"
64
+ onChange={(event) => onConfirmedChange(event.target.checked)}
65
+ type="checkbox"
66
+ />
67
+ <span>
68
+ I confirm this expands where collection content may travel. The
69
+ confirmation applies only to revision {revision} and target{" "}
70
+ {policy}.
71
+ </span>
72
+ </label>
73
+ </div>
74
+ ) : null}
75
+ </div>
76
+ </section>
77
+ );
78
+ }