@gmickel/gno 1.28.0 → 1.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -20
- package/assets/skill/SKILL.md +33 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.29.0.zip +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.29.0.zip.sha256 +1 -0
- package/browser-extension/dist/chunk-b2zm0jjd.js +50 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/browser-extension/dist/preview.html +1 -1
- package/browser-extension/dist/service-worker.js +6 -6
- package/package.json +1 -1
- package/spec/cli.md +41 -1
- package/spec/db/schema.sql +19 -0
- package/spec/mcp.md +66 -0
- package/spec/output-schemas/ask.schema.json +31 -1
- package/spec/output-schemas/browser-clip-preview.schema.json +30 -0
- package/spec/output-schemas/collection-egress-check.schema.json +91 -0
- package/spec/output-schemas/collection-egress-policy-set.schema.json +56 -0
- package/spec/output-schemas/collection-egress-policy.schema.json +41 -0
- package/spec/output-schemas/context-capsule-v1.schema.json +89 -2
- package/spec/output-schemas/context-capsule-verification.schema.json +1 -1
- package/spec/output-schemas/egress-audit-management.schema.json +88 -0
- package/spec/output-schemas/mcp-http-error.schema.json +113 -2
- package/spec/output-schemas/publish-artifact.schema.json +32 -0
- package/spec/output-schemas/record-import.schema.json +30 -0
- package/spec/output-schemas/retrieval-trace-common.schema.json +31 -0
- package/spec/output-schemas/retrieval-trace-export.schema.json +3 -0
- package/spec/output-schemas/retrieval-trace-qrels.schema.json +3 -0
- package/spec/output-schemas/retrieval-trace-summary.schema.json +4 -0
- package/spec/output-schemas/search-results.schema.json +30 -0
- package/src/app/context-runtime-contract.ts +16 -7
- package/src/cli/commands/ask.ts +7 -0
- package/src/cli/commands/collection/index.ts +5 -0
- package/src/cli/commands/collection/policy.ts +126 -0
- package/src/cli/commands/context-build.ts +5 -0
- package/src/cli/commands/daemon.ts +45 -3
- package/src/cli/commands/egress-audit.ts +68 -0
- package/src/cli/commands/embed.ts +2 -0
- package/src/cli/commands/query.ts +12 -0
- package/src/cli/commands/replay.ts +13 -3
- package/src/cli/commands/vsearch.ts +3 -1
- package/src/cli/program.ts +123 -0
- package/src/config/index.ts +9 -0
- package/src/config/types.ts +50 -0
- package/src/core/browser-clip.ts +16 -1
- package/src/core/collection-egress-policy-projection.ts +28 -0
- package/src/core/collection-egress-policy-service.ts +443 -0
- package/src/core/collection-egress-policy-validation.ts +242 -0
- package/src/core/config-mutation.ts +31 -19
- package/src/core/context-capsule-schema.ts +111 -1
- package/src/core/context-capsule-validation.ts +4 -4
- package/src/core/context-capsule-verification.ts +5 -1
- package/src/core/context-capsule.ts +177 -132
- package/src/core/context-evidence.ts +86 -14
- package/src/core/destination-classifier.ts +402 -0
- package/src/core/egress-audit.ts +239 -0
- package/src/core/egress-authorization.ts +50 -0
- package/src/core/egress-enforcement.ts +264 -0
- package/src/core/egress-policy.ts +440 -0
- package/src/core/egress-provenance.ts +336 -0
- package/src/core/job-manager.ts +37 -0
- package/src/core/network-boundary-inventory.ts +261 -0
- package/src/core/retrieval-qrels.ts +6 -0
- package/src/core/retrieval-replay.ts +18 -4
- package/src/core/retrieval-trace-export.ts +23 -4
- package/src/core/retrieval-trace-management-helpers.ts +1 -0
- package/src/core/retrieval-trace-management-types.ts +3 -0
- package/src/core/retrieval-trace-management.ts +9 -1
- package/src/core/retrieval-trace-session.ts +94 -0
- package/src/core/retrieval-trace.ts +22 -0
- package/src/ingestion/record-adapter.ts +12 -1
- package/src/ingestion/record-container.ts +38 -22
- package/src/ingestion/sync.ts +15 -0
- package/src/ingestion/types.ts +2 -0
- package/src/llm/errors.ts +10 -0
- package/src/llm/http-inference.ts +175 -0
- package/src/llm/http-policy.ts +537 -0
- package/src/llm/httpEmbedding.ts +47 -28
- package/src/llm/httpGeneration.ts +31 -18
- package/src/llm/httpRerank.ts +30 -18
- package/src/llm/index.ts +1 -0
- package/src/llm/nodeLlamaCpp/adapter.ts +55 -20
- package/src/llm/pinned-http-connection.ts +177 -0
- package/src/mcp/context.ts +53 -1
- package/src/mcp/http-egress.ts +220 -0
- package/src/mcp/http-security.ts +32 -21
- package/src/mcp/http-session.ts +13 -2
- package/src/mcp/http-transport.ts +96 -6
- package/src/mcp/sync-egress.ts +24 -0
- package/src/mcp/tools/add-collection.ts +4 -0
- package/src/mcp/tools/ask.ts +1 -1
- package/src/mcp/tools/context.ts +9 -2
- package/src/mcp/tools/egress.ts +247 -0
- package/src/mcp/tools/embed.ts +1 -0
- package/src/mcp/tools/index-cmd.ts +7 -0
- package/src/mcp/tools/index.ts +98 -0
- package/src/mcp/tools/query.ts +10 -0
- package/src/mcp/tools/remove-collection.ts +4 -0
- package/src/mcp/tools/sync.ts +6 -0
- package/src/mcp/tools/trace.ts +7 -1
- package/src/mcp/tools/vsearch.ts +1 -0
- package/src/pipeline/egress-lineage.ts +124 -0
- package/src/pipeline/hybrid.ts +14 -0
- package/src/pipeline/search.ts +12 -0
- package/src/pipeline/types.ts +3 -0
- package/src/pipeline/vsearch.ts +41 -20
- package/src/publish/artifact.ts +31 -3
- package/src/publish/export-service.ts +25 -0
- package/src/sdk/client.ts +119 -2
- package/src/sdk/embed.ts +1 -0
- package/src/sdk/types.ts +33 -0
- package/src/serve/clipper-capture.ts +5 -1
- package/src/serve/closed-json.ts +61 -0
- package/src/serve/config-sync.ts +4 -1
- package/src/serve/context.ts +1 -0
- package/src/serve/doc-events.ts +110 -39
- package/src/serve/public/components/CollectionEgressCheckPanel.tsx +411 -0
- package/src/serve/public/components/CollectionEgressPolicyEditor.tsx +78 -0
- package/src/serve/public/components/CollectionModelDialog.tsx +93 -9
- package/src/serve/public/components/EgressAuditPanel.tsx +278 -0
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/pages/Collections.tsx +3 -0
- package/src/serve/resident-request.ts +80 -3
- package/src/serve/resident-runtime.ts +63 -1
- package/src/serve/routes/api.ts +174 -0
- package/src/serve/routes/clipper.ts +64 -1
- package/src/serve/routes/mcp.ts +6 -0
- package/src/serve/routes/traces.ts +27 -4
- package/src/serve/server.ts +118 -7
- package/src/store/migrations/023-collection-egress-policy.ts +61 -0
- package/src/store/migrations/024-egress-derived-lineage.ts +121 -0
- package/src/store/migrations/025-collection-egress-policy-revision.ts +40 -0
- package/src/store/migrations/index.ts +6 -0
- package/src/store/retrieval-trace-codec.ts +15 -0
- package/src/store/sqlite/adapter.ts +170 -7
- package/src/store/sqlite/change-journal-store.ts +41 -2
- package/src/store/sqlite/egress-audit-store.ts +485 -0
- package/src/store/sqlite/retrieval-trace-management-store.ts +47 -6
- package/src/store/sqlite/retrieval-trace-retention.ts +2 -0
- package/src/store/sqlite/retrieval-trace-rows.ts +8 -1
- package/src/store/sqlite/retrieval-trace-store.ts +104 -5
- package/src/store/types.ts +126 -1
- package/browser-extension/artifacts/gno-browser-clipper-v1.28.0.zip +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.28.0.zip.sha256 +0 -1
- package/browser-extension/dist/chunk-vn5f663b.js +0 -50
|
@@ -0,0 +1,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
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
{
|
|
187
|
-
|
|
188
|
-
|
|
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={
|
|
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
|
|
477
|
+
Save collection settings
|
|
394
478
|
</Button>
|
|
395
479
|
</DialogFooter>
|
|
396
480
|
</DialogContent>
|