@gmickel/gno 1.28.0 → 1.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.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 +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,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
+ }
@@ -8,6 +8,12 @@ import {
8
8
  import { useEffect, useMemo, useState } from "react";
9
9
 
10
10
  import { apiFetch } from "../hooks/use-api";
11
+ import { CollectionEgressCheckPanel } from "./CollectionEgressCheckPanel";
12
+ import {
13
+ CollectionEgressPolicyEditor,
14
+ type CollectionEgressPolicy,
15
+ } from "./CollectionEgressPolicyEditor";
16
+ import { EgressAuditPanel } from "./EgressAuditPanel";
11
17
  import { Button } from "./ui/button";
12
18
  import {
13
19
  Dialog,
@@ -50,6 +56,15 @@ export interface CollectionModelDetails {
50
56
  name: string;
51
57
  path: string;
52
58
  pattern?: string;
59
+ egressPolicy?: {
60
+ schemaVersion: "1.0";
61
+ collection: string;
62
+ configuredPolicy: "local_only" | "lan" | "remote" | null;
63
+ effectivePolicy: "local_only" | "lan" | "remote";
64
+ source: "explicit" | "config_default";
65
+ revision: number;
66
+ version: string;
67
+ } | null;
53
68
  }
54
69
 
55
70
  interface UpdateCollectionResponse {
@@ -58,6 +73,7 @@ interface UpdateCollectionResponse {
58
73
  }
59
74
 
60
75
  interface CollectionModelDialogProps {
76
+ availableCollections?: readonly string[];
61
77
  collection: CollectionModelDetails | null;
62
78
  onOpenChange: (open: boolean) => void;
63
79
  onSaved: () => void;
@@ -119,6 +135,7 @@ function collectionLooksCodeHeavy(collection: CollectionModelDetails): boolean {
119
135
  }
120
136
 
121
137
  export function CollectionModelDialog({
138
+ availableCollections = [],
122
139
  collection,
123
140
  onOpenChange,
124
141
  onSaved,
@@ -132,6 +149,9 @@ export function CollectionModelDialog({
132
149
  });
133
150
  const [error, setError] = useState<string | null>(null);
134
151
  const [saving, setSaving] = useState(false);
152
+ const [policyDraft, setPolicyDraft] =
153
+ useState<CollectionEgressPolicy>("local_only");
154
+ const [relaxationConfirmed, setRelaxationConfirmed] = useState(false);
135
155
  const showCodeRecommendation =
136
156
  collection !== null &&
137
157
  collectionLooksCodeHeavy(collection) &&
@@ -150,6 +170,8 @@ export function CollectionModelDialog({
150
170
  });
151
171
  setError(null);
152
172
  setSaving(false);
173
+ setPolicyDraft(collection.egressPolicy?.effectivePolicy ?? "local_only");
174
+ setRelaxationConfirmed(false);
153
175
  }, [collection, open]);
154
176
 
155
177
  const patch = useMemo(() => {
@@ -170,7 +192,13 @@ export function CollectionModelDialog({
170
192
  return nextPatch;
171
193
  }, [collection, draft]);
172
194
 
173
- const hasChanges = Object.keys(patch).length > 0;
195
+ const originalPolicy =
196
+ collection?.egressPolicy?.effectivePolicy ?? "local_only";
197
+ const policyChanged = policyDraft !== originalPolicy;
198
+ const policyOrder = { local_only: 0, lan: 1, remote: 2 } as const;
199
+ const policyRelaxed =
200
+ policyChanged && policyOrder[policyDraft] > policyOrder[originalPolicy];
201
+ const hasChanges = Object.keys(patch).length > 0 || policyChanged;
174
202
  const embedChanged = Object.hasOwn(patch, "embed");
175
203
 
176
204
  const handleSave = async () => {
@@ -181,13 +209,46 @@ export function CollectionModelDialog({
181
209
  setSaving(true);
182
210
  setError(null);
183
211
 
184
- const { error: requestError } = await apiFetch<UpdateCollectionResponse>(
185
- `/api/collections/${encodeURIComponent(collection.name)}`,
186
- {
187
- method: "PATCH",
188
- body: JSON.stringify({ models: patch }),
212
+ if (policyChanged) {
213
+ const state = collection.egressPolicy;
214
+ const { error: policyError } = await apiFetch(
215
+ `/api/collections/${encodeURIComponent(collection.name)}/egress-policy`,
216
+ {
217
+ method: "PUT",
218
+ body: JSON.stringify({
219
+ policy: policyDraft,
220
+ confirmation:
221
+ policyRelaxed && state && relaxationConfirmed
222
+ ? {
223
+ collection: state.collection,
224
+ currentPolicy: state.effectivePolicy,
225
+ currentRevision: state.revision,
226
+ targetPolicy: policyDraft,
227
+ acknowledged: true,
228
+ }
229
+ : undefined,
230
+ }),
231
+ }
232
+ );
233
+ if (policyError) {
234
+ setSaving(false);
235
+ setError(policyError);
236
+ return;
189
237
  }
190
- );
238
+ }
239
+
240
+ const requestError =
241
+ Object.keys(patch).length > 0
242
+ ? (
243
+ await apiFetch<UpdateCollectionResponse>(
244
+ `/api/collections/${encodeURIComponent(collection.name)}`,
245
+ {
246
+ method: "PATCH",
247
+ body: JSON.stringify({ models: patch }),
248
+ }
249
+ )
250
+ ).error
251
+ : null;
191
252
 
192
253
  setSaving(false);
193
254
 
@@ -240,6 +301,27 @@ export function CollectionModelDialog({
240
301
  {/* Scrollable model roles */}
241
302
  <div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
242
303
  <div className="divide-y divide-border/15">
304
+ <CollectionEgressPolicyEditor
305
+ confirmed={relaxationConfirmed}
306
+ onConfirmedChange={setRelaxationConfirmed}
307
+ onPolicyChange={(policy) => {
308
+ setPolicyDraft(policy);
309
+ setRelaxationConfirmed(false);
310
+ }}
311
+ policy={policyDraft}
312
+ relaxed={policyRelaxed}
313
+ revision={collection?.egressPolicy?.revision ?? 0}
314
+ source={collection?.egressPolicy?.source ?? "config_default"}
315
+ />
316
+ {collection ? (
317
+ <CollectionEgressCheckPanel
318
+ availableCollections={availableCollections}
319
+ collection={collection.name}
320
+ effectivePolicy={originalPolicy}
321
+ source={collection.egressPolicy?.source ?? "config_default"}
322
+ />
323
+ ) : null}
324
+ <EgressAuditPanel />
243
325
  {MODEL_ROLES.map((role) => {
244
326
  const source = collection?.modelSources?.[role] ?? "preset";
245
327
  const effectiveValue = collection?.effectiveModels?.[role] ?? "";
@@ -385,12 +467,14 @@ export function CollectionModelDialog({
385
467
  </Button>
386
468
  <Button
387
469
  className="text-xs"
388
- disabled={!hasChanges || saving}
470
+ disabled={
471
+ !hasChanges || saving || (policyRelaxed && !relaxationConfirmed)
472
+ }
389
473
  onClick={() => void handleSave()}
390
474
  size="sm"
391
475
  >
392
476
  {saving ? <Loader2Icon className="size-3.5 animate-spin" /> : null}
393
- Save model settings
477
+ Save collection settings
394
478
  </Button>
395
479
  </DialogFooter>
396
480
  </DialogContent>