@gmickel/gno 2.12.2 → 2.14.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 +1 -1
- package/assets/skill/SKILL.md +36 -5
- package/assets/skill/cli-reference.md +62 -11
- package/assets/skill/examples.md +30 -4
- package/assets/skill/mcp-reference.md +16 -0
- package/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v2.12.2.zip → gno-browser-clipper-v2.14.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.14.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +1 -1
- package/spec/cli.md +139 -16
- package/spec/mcp.md +111 -3
- package/spec/output-schemas/collection-list.schema.json +28 -0
- package/spec/output-schemas/convert.schema.json +97 -0
- package/spec/output-schemas/ls.schema.json +65 -0
- package/spec/output-schemas/mcp-job-status.schema.json +17 -1
- package/spec/output-schemas/mcp-sync-result.schema.json +9 -2
- package/spec/output-schemas/search-results.schema.json +7 -0
- package/src/cli/commands/index-cmd.ts +50 -12
- package/src/cli/commands/ls.ts +20 -3
- package/src/cli/commands/profile-apply.ts +2 -1
- package/src/cli/commands/query.ts +21 -8
- package/src/cli/commands/search.ts +11 -0
- package/src/cli/commands/vsearch.ts +17 -4
- package/src/cli/options.ts +19 -0
- package/src/cli/program.ts +90 -10
- package/src/config/loader.ts +11 -1
- package/src/config/types.ts +90 -1
- package/src/core/audit-collection-rules.ts +331 -0
- package/src/core/audit-links.ts +3 -1
- package/src/core/audit-workspace.ts +69 -5
- package/src/core/collection-egress-policy-service.ts +4 -1
- package/src/core/collection-scope.ts +105 -0
- package/src/core/egress-enforcement.ts +20 -4
- package/src/core/egress-policy.ts +4 -0
- package/src/core/egress-provenance.ts +33 -11
- package/src/core/errors.ts +5 -0
- package/src/core/job-manager.ts +238 -19
- package/src/core/project-profile-apply-state.ts +6 -15
- package/src/core/project-profile-apply.ts +32 -1
- package/src/core/reindex-paths.ts +167 -0
- package/src/core/retrieval-trace-request.ts +1 -0
- package/src/ingestion/convert-file.ts +181 -0
- package/src/llm/registry.ts +20 -0
- package/src/mcp/http-egress.ts +21 -2
- package/src/mcp/tools/collection-scope.ts +25 -0
- package/src/mcp/tools/index-cmd.ts +66 -48
- package/src/mcp/tools/index.ts +74 -10
- package/src/mcp/tools/job-status.ts +7 -0
- package/src/mcp/tools/query.ts +28 -7
- package/src/mcp/tools/search.ts +4 -0
- package/src/mcp/tools/sync.ts +68 -60
- package/src/mcp/tools/vsearch.ts +9 -3
- package/src/pipeline/filters.ts +48 -5
- package/src/pipeline/graph-retrieval.ts +1 -0
- package/src/pipeline/hybrid.ts +15 -2
- package/src/pipeline/hydration.ts +7 -2
- package/src/pipeline/owner-fusion.ts +1 -0
- package/src/pipeline/search.ts +2 -0
- package/src/pipeline/types.ts +7 -0
- package/src/pipeline/vsearch.ts +10 -5
- package/src/sdk/client.ts +45 -12
- package/src/sdk/convert.ts +27 -0
- package/src/sdk/documents.ts +15 -2
- package/src/sdk/index.ts +2 -0
- package/src/sdk/types.ts +4 -1
- package/src/serve/jobs.ts +4 -1
- package/src/serve/public/components/IndexingProgress.tsx +25 -3
- package/src/serve/routes/api.ts +164 -24
- package/src/store/sqlite/adapter.ts +97 -27
- package/src/store/sqlite/eligibility.ts +4 -0
- package/src/store/types.ts +7 -0
- package/src/store/vector/sqlite-vec.ts +67 -15
- package/src/store/vector/variant-search.ts +41 -4
- package/browser-extension/artifacts/gno-browser-clipper-v2.12.2.zip.sha256 +0 -1
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in, report-only collection rules for the `links` audit category:
|
|
3
|
+
* `links.collection-policy`, `links.external-url` and `links.path-layout`.
|
|
4
|
+
* A rule is emitted only when an audited collection configures it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Collection, CollectionAuditConfig } from "../config/types";
|
|
8
|
+
import type { AuditLinkSnapshot } from "../store/sqlite/graph-link-resolver";
|
|
9
|
+
import type { DocumentRow } from "../store/types";
|
|
10
|
+
import type { AuditFindingDraft, AuditRuleContribution } from "./audit";
|
|
11
|
+
|
|
12
|
+
import { buildLineOffsets, offsetToPosition } from "../ingestion/position";
|
|
13
|
+
import { getExcludedRanges, rangeIntersectsExcluded } from "../ingestion/strip";
|
|
14
|
+
import { isTraversableResolution } from "../store/sqlite/graph-link-resolver";
|
|
15
|
+
import {
|
|
16
|
+
AUDIT_MAX_EVIDENCE_DETAIL_CHARS,
|
|
17
|
+
compareAuditCodeUnits,
|
|
18
|
+
compareAuditFindingDrafts,
|
|
19
|
+
} from "./audit";
|
|
20
|
+
|
|
21
|
+
export interface CollectionAuditRuleInput {
|
|
22
|
+
links: AuditLinkSnapshot;
|
|
23
|
+
/** Audited documents (the requested scope). */
|
|
24
|
+
documents: readonly DocumentRow[];
|
|
25
|
+
/** Configured collections. */
|
|
26
|
+
collections: readonly Collection[];
|
|
27
|
+
/** Collections the run audits; rules are emitted only for these. */
|
|
28
|
+
auditedCollections: readonly string[];
|
|
29
|
+
/** Indexed text by mirror hash, loaded only for `externalUrls: report`. */
|
|
30
|
+
contents: ReadonlyMap<string, string>;
|
|
31
|
+
truncated: boolean;
|
|
32
|
+
maxFindingsPerRule: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `scheme://` in prose or link syntax; the start must not continue a word. */
|
|
36
|
+
const URL_PATTERN = /(?<![a-z0-9+.-])([a-z][a-z0-9+.-]*):\/\/[^\s<>"'`]+/gi;
|
|
37
|
+
/** Local schemes are not external URLs. */
|
|
38
|
+
const LOCAL_URL_SCHEMES = new Set(["gno", "file"]);
|
|
39
|
+
const TRAILING_PUNCTUATION = /[.,;:!?*_~]$/;
|
|
40
|
+
const MAX_URL_EVIDENCE_CHARS = 400;
|
|
41
|
+
|
|
42
|
+
const clip = (value: string, max: number): string =>
|
|
43
|
+
value.length > max ? `${value.slice(0, max - 1)}…` : value;
|
|
44
|
+
|
|
45
|
+
const lineLocation = (line: number, column: number): string =>
|
|
46
|
+
`L${line}:C${column}`;
|
|
47
|
+
|
|
48
|
+
/** Trim sentence punctuation and closers that do not belong to the URL. */
|
|
49
|
+
const trimUrl = (raw: string): string => {
|
|
50
|
+
let url = raw;
|
|
51
|
+
for (;;) {
|
|
52
|
+
const last = url.at(-1);
|
|
53
|
+
if (last === undefined) return url;
|
|
54
|
+
if (TRAILING_PUNCTUATION.test(last)) {
|
|
55
|
+
url = url.slice(0, -1);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const pairs: Record<string, string> = { ")": "(", "]": "[", "}": "{" };
|
|
59
|
+
const opener = pairs[last];
|
|
60
|
+
if (
|
|
61
|
+
opener !== undefined &&
|
|
62
|
+
url.split(last).length > url.split(opener).length
|
|
63
|
+
) {
|
|
64
|
+
url = url.slice(0, -1);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
return url;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export interface ExternalUrlMatch {
|
|
72
|
+
url: string;
|
|
73
|
+
line: number;
|
|
74
|
+
column: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** External URLs in indexed text, outside frontmatter, code and comments. */
|
|
78
|
+
export const findExternalUrls = (markdown: string): ExternalUrlMatch[] => {
|
|
79
|
+
const excluded = getExcludedRanges(markdown);
|
|
80
|
+
const lineOffsets = buildLineOffsets(markdown);
|
|
81
|
+
const matches: ExternalUrlMatch[] = [];
|
|
82
|
+
for (const match of markdown.matchAll(URL_PATTERN)) {
|
|
83
|
+
const scheme = match[1]?.toLowerCase() ?? "";
|
|
84
|
+
if (LOCAL_URL_SCHEMES.has(scheme)) continue;
|
|
85
|
+
const url = trimUrl(match[0]);
|
|
86
|
+
if (url.length <= scheme.length + 3) continue;
|
|
87
|
+
const start = match.index;
|
|
88
|
+
if (rangeIntersectsExcluded(start, start + url.length, excluded)) continue;
|
|
89
|
+
const position = offsetToPosition(start, lineOffsets);
|
|
90
|
+
matches.push({ url, line: position.line, column: position.col });
|
|
91
|
+
}
|
|
92
|
+
return matches;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const configuredFor = (
|
|
96
|
+
input: CollectionAuditRuleInput
|
|
97
|
+
): Map<string, CollectionAuditConfig> => {
|
|
98
|
+
const audited = new Set(input.auditedCollections);
|
|
99
|
+
return new Map(
|
|
100
|
+
input.collections
|
|
101
|
+
.filter(({ name, audit }) => audit !== undefined && audited.has(name))
|
|
102
|
+
.map(({ name, audit }) => [name, audit as CollectionAuditConfig])
|
|
103
|
+
);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const documentPath = (document: DocumentRow): string =>
|
|
107
|
+
document.recordSourcePath ?? document.relPath;
|
|
108
|
+
|
|
109
|
+
const contribution = (
|
|
110
|
+
ruleId: string,
|
|
111
|
+
findings: AuditFindingDraft[],
|
|
112
|
+
input: CollectionAuditRuleInput,
|
|
113
|
+
examinedCount: number,
|
|
114
|
+
messages: { partial: string; done: string }
|
|
115
|
+
): AuditRuleContribution => {
|
|
116
|
+
const partial = input.truncated;
|
|
117
|
+
return {
|
|
118
|
+
ruleId,
|
|
119
|
+
category: "links",
|
|
120
|
+
status: partial ? "inconclusive" : findings.length > 0 ? "fail" : "pass",
|
|
121
|
+
message: partial ? messages.partial : messages.done,
|
|
122
|
+
findings: [...findings]
|
|
123
|
+
.sort(compareAuditFindingDrafts)
|
|
124
|
+
.slice(0, input.maxFindingsPerRule),
|
|
125
|
+
findingCount: findings.length,
|
|
126
|
+
examinedCount,
|
|
127
|
+
durationMs: 0,
|
|
128
|
+
skipReason: partial ? "snapshot_truncated" : null,
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const collectionPolicy = (
|
|
133
|
+
input: CollectionAuditRuleInput,
|
|
134
|
+
configured: ReadonlyMap<string, CollectionAuditConfig>
|
|
135
|
+
): AuditRuleContribution | null => {
|
|
136
|
+
const allowedBySource = new Map<string, Set<string>>();
|
|
137
|
+
for (const [name, audit] of configured) {
|
|
138
|
+
if (audit.linkCollections === undefined) continue;
|
|
139
|
+
allowedBySource.set(name, new Set([name, ...audit.linkCollections]));
|
|
140
|
+
}
|
|
141
|
+
if (allowedBySource.size === 0) return null;
|
|
142
|
+
const audited = new Set(
|
|
143
|
+
input.links.auditedDocumentIds ?? input.links.documents.map(({ id }) => id)
|
|
144
|
+
);
|
|
145
|
+
const documents = new Map(input.links.documents.map((doc) => [doc.id, doc]));
|
|
146
|
+
const findings: AuditFindingDraft[] = [];
|
|
147
|
+
let examined = 0;
|
|
148
|
+
for (const link of input.links.links) {
|
|
149
|
+
const allowed = allowedBySource.get(link.sourceCollection);
|
|
150
|
+
if (!allowed || !audited.has(link.sourceId)) continue;
|
|
151
|
+
examined += 1;
|
|
152
|
+
// Unresolved and ambiguous links belong to the broken-link rules.
|
|
153
|
+
if (!link.resolved || !isTraversableResolution(link.resolved)) continue;
|
|
154
|
+
if (link.resolved.matchCount > 1) continue;
|
|
155
|
+
const target = documents.get(link.resolved.targetId);
|
|
156
|
+
if (!target || allowed.has(target.collection)) continue;
|
|
157
|
+
findings.push({
|
|
158
|
+
subject: link.sourceUri,
|
|
159
|
+
location: lineLocation(link.startLine, link.startCol),
|
|
160
|
+
severity: "warning",
|
|
161
|
+
message: `Link into collection "${target.collection}", which audit.linkCollections does not list: ${target.uri}`,
|
|
162
|
+
evidence: [
|
|
163
|
+
{
|
|
164
|
+
kind: "collection-policy",
|
|
165
|
+
summary: `${target.collection}:${target.uri}`,
|
|
166
|
+
uri: link.sourceUri,
|
|
167
|
+
path: link.sourceRelPath,
|
|
168
|
+
detail: clip(
|
|
169
|
+
JSON.stringify({
|
|
170
|
+
sourceCollection: link.sourceCollection,
|
|
171
|
+
targetCollection: target.collection,
|
|
172
|
+
target: target.uri,
|
|
173
|
+
linkType: link.linkType,
|
|
174
|
+
allowed: [...allowed].sort(compareAuditCodeUnits),
|
|
175
|
+
}),
|
|
176
|
+
AUDIT_MAX_EVIDENCE_DETAIL_CHARS
|
|
177
|
+
),
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
guidance: [
|
|
181
|
+
`Remove or change the link, or add "${target.collection}" to audit.linkCollections`,
|
|
182
|
+
],
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return contribution("links.collection-policy", findings, input, examined, {
|
|
186
|
+
partial: "Collection link policy scan was truncated",
|
|
187
|
+
done: `${findings.length} links into collections outside audit.linkCollections`,
|
|
188
|
+
});
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const externalUrls = (
|
|
192
|
+
input: CollectionAuditRuleInput,
|
|
193
|
+
configured: ReadonlyMap<string, CollectionAuditConfig>
|
|
194
|
+
): AuditRuleContribution | null => {
|
|
195
|
+
const reporting = new Set(
|
|
196
|
+
[...configured]
|
|
197
|
+
.filter(([, audit]) => audit.externalUrls === "report")
|
|
198
|
+
.map(([name]) => name)
|
|
199
|
+
);
|
|
200
|
+
if (reporting.size === 0) return null;
|
|
201
|
+
const findings: AuditFindingDraft[] = [];
|
|
202
|
+
let examined = 0;
|
|
203
|
+
let unchecked = 0;
|
|
204
|
+
for (const document of input.documents) {
|
|
205
|
+
if (!reporting.has(document.collection)) continue;
|
|
206
|
+
examined += 1;
|
|
207
|
+
const text = document.mirrorHash
|
|
208
|
+
? input.contents.get(document.mirrorHash)
|
|
209
|
+
: undefined;
|
|
210
|
+
if (text === undefined) {
|
|
211
|
+
unchecked += 1;
|
|
212
|
+
findings.push({
|
|
213
|
+
subject: document.uri,
|
|
214
|
+
location: null,
|
|
215
|
+
severity: "warning",
|
|
216
|
+
message: "Indexed text is unavailable; external URLs were not checked",
|
|
217
|
+
evidence: [
|
|
218
|
+
{
|
|
219
|
+
kind: "external-url-unchecked",
|
|
220
|
+
summary: "unchecked",
|
|
221
|
+
uri: document.uri,
|
|
222
|
+
path: documentPath(document),
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
guidance: ["Run gno update so the document has indexed text"],
|
|
226
|
+
});
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
for (const match of findExternalUrls(text)) {
|
|
230
|
+
findings.push({
|
|
231
|
+
subject: document.uri,
|
|
232
|
+
location: lineLocation(match.line, match.column),
|
|
233
|
+
severity: "warning",
|
|
234
|
+
message: `External URL in document text: ${clip(match.url, MAX_URL_EVIDENCE_CHARS)}`,
|
|
235
|
+
evidence: [
|
|
236
|
+
{
|
|
237
|
+
kind: "external-url",
|
|
238
|
+
summary: clip(match.url, MAX_URL_EVIDENCE_CHARS),
|
|
239
|
+
uri: document.uri,
|
|
240
|
+
path: documentPath(document),
|
|
241
|
+
detail: JSON.stringify({ line: match.line, column: match.column }),
|
|
242
|
+
},
|
|
243
|
+
],
|
|
244
|
+
guidance: [
|
|
245
|
+
"Remove the URL, or set audit.externalUrls: ignore for this collection",
|
|
246
|
+
],
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const urls = findings.length - unchecked;
|
|
251
|
+
return contribution("links.external-url", findings, input, examined, {
|
|
252
|
+
partial: "External URL scan was truncated",
|
|
253
|
+
done:
|
|
254
|
+
unchecked > 0
|
|
255
|
+
? `${urls} external URLs; ${unchecked} documents unchecked`
|
|
256
|
+
: `${urls} external URLs`,
|
|
257
|
+
});
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const pathLayout = (
|
|
261
|
+
input: CollectionAuditRuleInput,
|
|
262
|
+
configured: ReadonlyMap<string, CollectionAuditConfig>
|
|
263
|
+
): AuditRuleContribution | null => {
|
|
264
|
+
const globsByCollection = new Map<
|
|
265
|
+
string,
|
|
266
|
+
{ patterns: string[]; globs: Bun.Glob[] }
|
|
267
|
+
>();
|
|
268
|
+
for (const [name, audit] of configured) {
|
|
269
|
+
if (audit.paths === undefined) continue;
|
|
270
|
+
globsByCollection.set(name, {
|
|
271
|
+
patterns: audit.paths,
|
|
272
|
+
globs: audit.paths.map((pattern) => new Bun.Glob(pattern)),
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
if (globsByCollection.size === 0) return null;
|
|
276
|
+
const findings: AuditFindingDraft[] = [];
|
|
277
|
+
let examined = 0;
|
|
278
|
+
for (const document of input.documents) {
|
|
279
|
+
const expected = globsByCollection.get(document.collection);
|
|
280
|
+
if (!expected) continue;
|
|
281
|
+
examined += 1;
|
|
282
|
+
const path = documentPath(document);
|
|
283
|
+
if (expected.globs.some((glob) => glob.match(path))) continue;
|
|
284
|
+
findings.push({
|
|
285
|
+
subject: document.uri,
|
|
286
|
+
location: null,
|
|
287
|
+
severity: "warning",
|
|
288
|
+
message: `Document path matches no audit.paths glob: ${path}`,
|
|
289
|
+
evidence: [
|
|
290
|
+
{
|
|
291
|
+
kind: "path-layout",
|
|
292
|
+
summary: path,
|
|
293
|
+
uri: document.uri,
|
|
294
|
+
path,
|
|
295
|
+
detail: clip(
|
|
296
|
+
JSON.stringify({ paths: expected.patterns }),
|
|
297
|
+
AUDIT_MAX_EVIDENCE_DETAIL_CHARS
|
|
298
|
+
),
|
|
299
|
+
},
|
|
300
|
+
],
|
|
301
|
+
guidance: ["Move the document, or add a matching glob to audit.paths"],
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
return contribution("links.path-layout", findings, input, examined, {
|
|
305
|
+
partial: "Path layout scan was truncated",
|
|
306
|
+
done: `${findings.length} documents outside the audit.paths layout`,
|
|
307
|
+
});
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
/** True when any audited collection reports external URLs. */
|
|
311
|
+
export const needsAuditContents = (
|
|
312
|
+
collections: readonly Collection[],
|
|
313
|
+
auditedCollections: readonly string[]
|
|
314
|
+
): boolean => {
|
|
315
|
+
const audited = new Set(auditedCollections);
|
|
316
|
+
return collections.some(
|
|
317
|
+
({ name, audit }) => audited.has(name) && audit?.externalUrls === "report"
|
|
318
|
+
);
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
export const evaluateCollectionAuditRules = (
|
|
322
|
+
input: CollectionAuditRuleInput
|
|
323
|
+
): AuditRuleContribution[] => {
|
|
324
|
+
const configured = configuredFor(input);
|
|
325
|
+
if (configured.size === 0) return [];
|
|
326
|
+
return [
|
|
327
|
+
collectionPolicy(input, configured),
|
|
328
|
+
externalUrls(input, configured),
|
|
329
|
+
pathLayout(input, configured),
|
|
330
|
+
].filter((rule): rule is AuditRuleContribution => rule !== null);
|
|
331
|
+
};
|
package/src/core/audit-links.ts
CHANGED
|
@@ -17,8 +17,10 @@ import {
|
|
|
17
17
|
* 1.1: workspace-wide resolution and reference/resolution/scope evidence.
|
|
18
18
|
* 1.2: `links.outside-index` for workspace files that are not indexed.
|
|
19
19
|
* 1.3: relative Markdown links to such files are `outside-index` too.
|
|
20
|
+
* 1.4: opt-in collection rules from `audit` config (collection-policy,
|
|
21
|
+
* external-url, path-layout); unchanged without that config.
|
|
20
22
|
*/
|
|
21
|
-
export const LINK_AUDIT_RULE_VERSION = "1.
|
|
23
|
+
export const LINK_AUDIT_RULE_VERSION = "1.4" as const;
|
|
22
24
|
/** Default per-rule cap; runs pass the effective `maxFindings` instead. */
|
|
23
25
|
export const LINK_AUDIT_MAX_FINDINGS_PER_RULE = 1000;
|
|
24
26
|
|
|
@@ -30,6 +30,10 @@ import {
|
|
|
30
30
|
resolveAuditMaxFindings,
|
|
31
31
|
runAudit,
|
|
32
32
|
} from "./audit";
|
|
33
|
+
import {
|
|
34
|
+
evaluateCollectionAuditRules,
|
|
35
|
+
needsAuditContents,
|
|
36
|
+
} from "./audit-collection-rules";
|
|
33
37
|
import { evaluateFreshnessAudit } from "./audit-freshness";
|
|
34
38
|
import { evaluateLinkAudit } from "./audit-links";
|
|
35
39
|
import { markOutsideIndexLinks } from "./audit-outside-index";
|
|
@@ -81,10 +85,23 @@ interface WorkspaceDocumentSnapshot {
|
|
|
81
85
|
|
|
82
86
|
interface WorkspaceSnapshot {
|
|
83
87
|
documents: WorkspaceDocumentSnapshot[];
|
|
88
|
+
/** Every selected (audited) document row. */
|
|
89
|
+
selected: DocumentRow[];
|
|
90
|
+
/** Indexed text for collections with `audit.externalUrls: report`. */
|
|
91
|
+
contents: Map<string, string>;
|
|
84
92
|
links: AuditLinkSnapshot;
|
|
85
93
|
truncated: boolean;
|
|
86
94
|
}
|
|
87
95
|
|
|
96
|
+
/** Collections an audit run covers: the collection filter, else all. */
|
|
97
|
+
const auditedCollectionNames = (
|
|
98
|
+
options: WorkspaceAuditOptions,
|
|
99
|
+
filters: { collections: string[] }
|
|
100
|
+
): string[] =>
|
|
101
|
+
filters.collections.length > 0
|
|
102
|
+
? filters.collections
|
|
103
|
+
: options.collections.map(({ name }) => name);
|
|
104
|
+
|
|
88
105
|
const normalizeValues = (values: readonly string[] | undefined): string[] =>
|
|
89
106
|
[...new Set((values ?? []).map((value) => value.normalize("NFC").trim()))]
|
|
90
107
|
.filter(Boolean)
|
|
@@ -391,6 +408,26 @@ const emptyLinkSnapshot = (): AuditLinkSnapshot => ({
|
|
|
391
408
|
},
|
|
392
409
|
});
|
|
393
410
|
|
|
411
|
+
/** Indexed text for documents in collections that report external URLs. */
|
|
412
|
+
const loadAuditContents = async (
|
|
413
|
+
options: WorkspaceAuditOptions,
|
|
414
|
+
documents: readonly DocumentRow[]
|
|
415
|
+
): Promise<Map<string, string>> => {
|
|
416
|
+
const reporting = new Set(
|
|
417
|
+
options.collections
|
|
418
|
+
.filter(({ audit }) => audit?.externalUrls === "report")
|
|
419
|
+
.map(({ name }) => name)
|
|
420
|
+
);
|
|
421
|
+
const hashes = documents
|
|
422
|
+
.filter(
|
|
423
|
+
({ collection, mirrorHash }) => reporting.has(collection) && mirrorHash
|
|
424
|
+
)
|
|
425
|
+
.map(({ mirrorHash }) => mirrorHash as string);
|
|
426
|
+
const loaded = await options.store.getContentBatch(hashes);
|
|
427
|
+
if (!loaded.ok) throw new Error(loaded.error.message);
|
|
428
|
+
return loaded.value;
|
|
429
|
+
};
|
|
430
|
+
|
|
394
431
|
const loadWorkspaceSnapshot = async (
|
|
395
432
|
options: WorkspaceAuditOptions,
|
|
396
433
|
filters: { collections: string[]; paths: string[]; tags: string[] }
|
|
@@ -437,8 +474,18 @@ const loadWorkspaceSnapshot = async (
|
|
|
437
474
|
selected.truncated,
|
|
438
475
|
filters.collections
|
|
439
476
|
);
|
|
477
|
+
const contents =
|
|
478
|
+
options.categories.includes("links") &&
|
|
479
|
+
needsAuditContents(
|
|
480
|
+
options.collections,
|
|
481
|
+
auditedCollectionNames(options, filters)
|
|
482
|
+
)
|
|
483
|
+
? await loadAuditContents(options, selected.documents)
|
|
484
|
+
: new Map<string, string>();
|
|
440
485
|
return {
|
|
441
486
|
documents: observed,
|
|
487
|
+
selected: selected.documents,
|
|
488
|
+
contents,
|
|
442
489
|
links: options.categories.includes("links")
|
|
443
490
|
? await markOutsideIndexLinks(options.store.getRawDb(), links, {
|
|
444
491
|
signal: options.signal,
|
|
@@ -514,13 +561,19 @@ const captureWorkspaceFingerprints = async (
|
|
|
514
561
|
options.categories.includes("links") && selected.documents.length > 0
|
|
515
562
|
? captureAuditLinkSnapshot(options.store.getRawDb())
|
|
516
563
|
: null;
|
|
564
|
+
// `audit` is covered only where present on an audited collection, the only
|
|
565
|
+
// place it changes the run, so other fingerprints stay unchanged.
|
|
566
|
+
const audited = new Set(auditedCollectionNames(options, filters));
|
|
517
567
|
return {
|
|
518
568
|
config: hashAuditCanonical({
|
|
519
|
-
collections: options.collections.map(
|
|
520
|
-
name,
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
569
|
+
collections: options.collections.map(
|
|
570
|
+
({ name, path, pattern, audit }) => ({
|
|
571
|
+
name,
|
|
572
|
+
path,
|
|
573
|
+
pattern,
|
|
574
|
+
...(audit === undefined || !audited.has(name) ? {} : { audit }),
|
|
575
|
+
})
|
|
576
|
+
),
|
|
524
577
|
filters,
|
|
525
578
|
}),
|
|
526
579
|
source: hashAuditCanonical({ sourceStats, total: selected.total }),
|
|
@@ -665,6 +718,17 @@ export const runWorkspaceAudit = async (
|
|
|
665
718
|
rootUris: options.orphanRoots ?? [],
|
|
666
719
|
ignorePathPrefixes: options.orphanIgnorePrefixes ?? [],
|
|
667
720
|
maxFindingsPerRule,
|
|
721
|
+
}),
|
|
722
|
+
...evaluateCollectionAuditRules({
|
|
723
|
+
links: snapshot.links,
|
|
724
|
+
documents: snapshot.selected,
|
|
725
|
+
collections: options.collections,
|
|
726
|
+
auditedCollections: auditedCollectionNames(options, filters),
|
|
727
|
+
contents: snapshot.contents,
|
|
728
|
+
truncated:
|
|
729
|
+
snapshot.links.truncated.documents ||
|
|
730
|
+
snapshot.links.truncated.links,
|
|
731
|
+
maxFindingsPerRule,
|
|
668
732
|
})
|
|
669
733
|
);
|
|
670
734
|
await options.onProgress?.({
|
|
@@ -410,7 +410,10 @@ export class CollectionEgressPolicyService {
|
|
|
410
410
|
return {
|
|
411
411
|
ok: false,
|
|
412
412
|
code: "VALIDATION",
|
|
413
|
-
error:
|
|
413
|
+
error:
|
|
414
|
+
error.code === "EGRESS_SCOPE_LIMIT_EXCEEDED"
|
|
415
|
+
? "Collection egress scope exceeds 128 collections; name the collections to check"
|
|
416
|
+
: "Invalid collection egress scope",
|
|
414
417
|
};
|
|
415
418
|
}
|
|
416
419
|
if (!(error instanceof EgressDeniedError)) throw error;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request-level collection allowlist shared by search, vsearch and query on
|
|
3
|
+
* every surface (CLI, MCP, REST, SDK).
|
|
4
|
+
*
|
|
5
|
+
* @module src/core/collection-scope
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Most collections one request may list; matches the audit scope bound. */
|
|
9
|
+
export const MAX_REQUEST_COLLECTIONS = 256;
|
|
10
|
+
|
|
11
|
+
export type CollectionListResult =
|
|
12
|
+
| { ok: true; value: string[] }
|
|
13
|
+
| { ok: false; message: string };
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Normalize a `collections` allowlist: trim, lowercase, de-duplicate, sort.
|
|
17
|
+
* Rejects non-arrays, non-string or blank members, an empty list, and lists
|
|
18
|
+
* longer than {@link MAX_REQUEST_COLLECTIONS} after normalization.
|
|
19
|
+
*/
|
|
20
|
+
export function normalizeCollectionList(input: unknown): CollectionListResult {
|
|
21
|
+
if (!Array.isArray(input)) {
|
|
22
|
+
return { ok: false, message: "collections must be an array of names" };
|
|
23
|
+
}
|
|
24
|
+
const names = new Set<string>();
|
|
25
|
+
for (const value of input) {
|
|
26
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
27
|
+
return {
|
|
28
|
+
ok: false,
|
|
29
|
+
message: "collections must contain only non-empty collection names",
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
names.add(value.trim().toLowerCase());
|
|
33
|
+
}
|
|
34
|
+
if (names.size === 0) {
|
|
35
|
+
return { ok: false, message: "collections must list at least one name" };
|
|
36
|
+
}
|
|
37
|
+
if (names.size > MAX_REQUEST_COLLECTIONS) {
|
|
38
|
+
return {
|
|
39
|
+
ok: false,
|
|
40
|
+
message: `collections must list at most ${MAX_REQUEST_COLLECTIONS} names`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
ok: true,
|
|
45
|
+
value: [...names].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Error text when a request names both `collection` and `collections`. */
|
|
50
|
+
export const COLLECTION_AND_COLLECTIONS_MESSAGE =
|
|
51
|
+
"Pass either collection or collections, not both";
|
|
52
|
+
|
|
53
|
+
/** Names in `requested` that are not configured, in request order. */
|
|
54
|
+
export function unknownCollections(
|
|
55
|
+
requested: readonly string[],
|
|
56
|
+
configured: readonly { name: string }[]
|
|
57
|
+
): string[] {
|
|
58
|
+
const known = new Set(configured.map(({ name }) => name.toLowerCase()));
|
|
59
|
+
return requested.filter((name) => !known.has(name));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The existing unknown-collection message, naming every unknown name. */
|
|
63
|
+
export function unknownCollectionsMessage(names: readonly string[]): string {
|
|
64
|
+
return names.length === 1
|
|
65
|
+
? `Collection not found: ${names[0]}`
|
|
66
|
+
: `Collections not found: ${names.join(", ")}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type CollectionScopeResult =
|
|
70
|
+
| { ok: true; collections: string[] | undefined }
|
|
71
|
+
| { ok: false; kind: "validation" | "not_found"; message: string };
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Resolve a request's `collections` allowlist against the configured
|
|
75
|
+
* collections. Absent `collections` resolves to undefined (unchanged
|
|
76
|
+
* single-collection behaviour). Both inputs together, a malformed list, or
|
|
77
|
+
* any unknown name is refused.
|
|
78
|
+
*/
|
|
79
|
+
export function resolveCollectionScope(
|
|
80
|
+
input: { collection?: unknown; collections?: unknown },
|
|
81
|
+
configured: readonly { name: string }[]
|
|
82
|
+
): CollectionScopeResult {
|
|
83
|
+
if (input.collections === undefined)
|
|
84
|
+
return { ok: true, collections: undefined };
|
|
85
|
+
if (input.collection !== undefined) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
kind: "validation",
|
|
89
|
+
message: COLLECTION_AND_COLLECTIONS_MESSAGE,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const normalized = normalizeCollectionList(input.collections);
|
|
93
|
+
if (!normalized.ok) {
|
|
94
|
+
return { ok: false, kind: "validation", message: normalized.message };
|
|
95
|
+
}
|
|
96
|
+
const unknown = unknownCollections(normalized.value, configured);
|
|
97
|
+
if (unknown.length > 0) {
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
kind: "not_found",
|
|
101
|
+
message: unknownCollectionsMessage(unknown),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return { ok: true, collections: normalized.value };
|
|
105
|
+
}
|
|
@@ -13,7 +13,10 @@ import type { EgressLineage, EgressLineageInput } from "./egress-provenance";
|
|
|
13
13
|
|
|
14
14
|
import { resolveConfiguredEgressPolicy } from "../config/types";
|
|
15
15
|
import { EgressAuditService } from "./egress-audit";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
collectionLimitExceededDecision,
|
|
18
|
+
evaluateEgressPolicy,
|
|
19
|
+
} from "./egress-policy";
|
|
17
20
|
import {
|
|
18
21
|
EgressProvenanceError,
|
|
19
22
|
resolveEgressLineage,
|
|
@@ -58,8 +61,9 @@ const collectionLineageInputs = (
|
|
|
58
61
|
"Collection policy scope must be a readable dense bounded array"
|
|
59
62
|
);
|
|
60
63
|
}
|
|
64
|
+
// The configuration is not bounded here; the resolved scope is.
|
|
61
65
|
const { length } = collections;
|
|
62
|
-
if (!Number.isSafeInteger(length)
|
|
66
|
+
if (!Number.isSafeInteger(length)) {
|
|
63
67
|
throw new EgressProvenanceError(
|
|
64
68
|
"INVALID_EGRESS_LINEAGE",
|
|
65
69
|
"Collection policy scope must be a readable dense bounded array"
|
|
@@ -100,11 +104,23 @@ const collectionLineageInputs = (
|
|
|
100
104
|
}
|
|
101
105
|
};
|
|
102
106
|
|
|
107
|
+
/** A scope over the lineage bound is a policy denial, never an internal error. */
|
|
103
108
|
const resolveCollectionEgressLineage = (
|
|
104
109
|
collections: readonly Collection[],
|
|
105
110
|
names?: readonly string[]
|
|
106
|
-
): EgressLineage =>
|
|
107
|
-
|
|
111
|
+
): EgressLineage => {
|
|
112
|
+
try {
|
|
113
|
+
return resolveEgressLineage(collectionLineageInputs(collections), names);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (
|
|
116
|
+
error instanceof EgressProvenanceError &&
|
|
117
|
+
error.code === "EGRESS_SCOPE_LIMIT_EXCEEDED"
|
|
118
|
+
) {
|
|
119
|
+
throw new EgressDeniedError(collectionLimitExceededDecision());
|
|
120
|
+
}
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
108
124
|
|
|
109
125
|
export const collectionEgressStates = (
|
|
110
126
|
collections: readonly Collection[],
|
|
@@ -420,6 +420,10 @@ function evaluateSnapshot(snapshot: EgressEvaluationSnapshot): EgressDecision {
|
|
|
420
420
|
return decision(true, "REMOTE_POLICY_AUTHENTICATED", audit);
|
|
421
421
|
}
|
|
422
422
|
|
|
423
|
+
/** The denial for a scope over the collection bound, naming no collection. */
|
|
424
|
+
export const collectionLimitExceededDecision = (): EgressDecision =>
|
|
425
|
+
decision(false, "COLLECTION_LIMIT_EXCEEDED", limitExceededAudit());
|
|
426
|
+
|
|
423
427
|
export function evaluateEgressPolicy(input: unknown): EgressDecision {
|
|
424
428
|
try {
|
|
425
429
|
const result = snapshotInput(input);
|