@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.
Files changed (75) hide show
  1. package/README.md +1 -1
  2. package/assets/skill/SKILL.md +36 -5
  3. package/assets/skill/cli-reference.md +62 -11
  4. package/assets/skill/examples.md +30 -4
  5. package/assets/skill/mcp-reference.md +16 -0
  6. package/assets/spa-production.json.gz +0 -0
  7. package/browser-extension/artifacts/{gno-browser-clipper-v2.12.2.zip → gno-browser-clipper-v2.14.0.zip} +0 -0
  8. package/browser-extension/artifacts/gno-browser-clipper-v2.14.0.zip.sha256 +1 -0
  9. package/browser-extension/dist/manifest.json +1 -1
  10. package/package.json +1 -1
  11. package/spec/cli.md +139 -16
  12. package/spec/mcp.md +111 -3
  13. package/spec/output-schemas/collection-list.schema.json +28 -0
  14. package/spec/output-schemas/convert.schema.json +97 -0
  15. package/spec/output-schemas/ls.schema.json +65 -0
  16. package/spec/output-schemas/mcp-job-status.schema.json +17 -1
  17. package/spec/output-schemas/mcp-sync-result.schema.json +9 -2
  18. package/spec/output-schemas/search-results.schema.json +7 -0
  19. package/src/cli/commands/index-cmd.ts +50 -12
  20. package/src/cli/commands/ls.ts +20 -3
  21. package/src/cli/commands/profile-apply.ts +2 -1
  22. package/src/cli/commands/query.ts +21 -8
  23. package/src/cli/commands/search.ts +11 -0
  24. package/src/cli/commands/vsearch.ts +17 -4
  25. package/src/cli/options.ts +19 -0
  26. package/src/cli/program.ts +90 -10
  27. package/src/config/loader.ts +11 -1
  28. package/src/config/types.ts +90 -1
  29. package/src/core/audit-collection-rules.ts +331 -0
  30. package/src/core/audit-links.ts +3 -1
  31. package/src/core/audit-workspace.ts +69 -5
  32. package/src/core/collection-egress-policy-service.ts +4 -1
  33. package/src/core/collection-scope.ts +105 -0
  34. package/src/core/egress-enforcement.ts +20 -4
  35. package/src/core/egress-policy.ts +4 -0
  36. package/src/core/egress-provenance.ts +33 -11
  37. package/src/core/errors.ts +5 -0
  38. package/src/core/job-manager.ts +238 -19
  39. package/src/core/project-profile-apply-state.ts +6 -15
  40. package/src/core/project-profile-apply.ts +32 -1
  41. package/src/core/reindex-paths.ts +167 -0
  42. package/src/core/retrieval-trace-request.ts +1 -0
  43. package/src/ingestion/convert-file.ts +181 -0
  44. package/src/llm/registry.ts +20 -0
  45. package/src/mcp/http-egress.ts +21 -2
  46. package/src/mcp/tools/collection-scope.ts +25 -0
  47. package/src/mcp/tools/index-cmd.ts +66 -48
  48. package/src/mcp/tools/index.ts +74 -10
  49. package/src/mcp/tools/job-status.ts +7 -0
  50. package/src/mcp/tools/query.ts +28 -7
  51. package/src/mcp/tools/search.ts +4 -0
  52. package/src/mcp/tools/sync.ts +68 -60
  53. package/src/mcp/tools/vsearch.ts +9 -3
  54. package/src/pipeline/filters.ts +48 -5
  55. package/src/pipeline/graph-retrieval.ts +1 -0
  56. package/src/pipeline/hybrid.ts +15 -2
  57. package/src/pipeline/hydration.ts +7 -2
  58. package/src/pipeline/owner-fusion.ts +1 -0
  59. package/src/pipeline/search.ts +2 -0
  60. package/src/pipeline/types.ts +7 -0
  61. package/src/pipeline/vsearch.ts +10 -5
  62. package/src/sdk/client.ts +45 -12
  63. package/src/sdk/convert.ts +27 -0
  64. package/src/sdk/documents.ts +15 -2
  65. package/src/sdk/index.ts +2 -0
  66. package/src/sdk/types.ts +4 -1
  67. package/src/serve/jobs.ts +4 -1
  68. package/src/serve/public/components/IndexingProgress.tsx +25 -3
  69. package/src/serve/routes/api.ts +164 -24
  70. package/src/store/sqlite/adapter.ts +97 -27
  71. package/src/store/sqlite/eligibility.ts +4 -0
  72. package/src/store/types.ts +7 -0
  73. package/src/store/vector/sqlite-vec.ts +67 -15
  74. package/src/store/vector/variant-search.ts +41 -4
  75. 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
+ };
@@ -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.3" as const;
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(({ name, path, pattern }) => ({
520
- name,
521
- path,
522
- pattern,
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: "Invalid collection egress scope",
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 { evaluateEgressPolicy } from "./egress-policy";
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) || length > 128) {
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
- resolveEgressLineage(collectionLineageInputs(collections), names);
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);