@gmickel/gno 2.13.0 → 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 +35 -4
- package/assets/skill/cli-reference.md +39 -1
- package/assets/skill/examples.md +30 -4
- package/assets/skill/mcp-reference.md +14 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v2.13.0.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 +73 -11
- package/spec/mcp.md +15 -2
- package/spec/output-schemas/collection-list.schema.json +28 -0
- package/spec/output-schemas/ls.schema.json +65 -0
- package/src/cli/commands/ls.ts +20 -3
- package/src/cli/commands/profile-apply.ts +2 -1
- package/src/cli/program.ts +11 -0
- 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/egress-enforcement.ts +20 -4
- package/src/core/egress-policy.ts +4 -0
- package/src/core/egress-provenance.ts +33 -11
- package/src/core/project-profile-apply-state.ts +6 -15
- package/src/core/project-profile-apply.ts +32 -1
- package/src/pipeline/filters.ts +30 -2
- package/src/sdk/client.ts +6 -1
- package/src/sdk/documents.ts +15 -2
- package/src/sdk/types.ts +3 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.13.0.zip.sha256 +0 -1
package/src/config/loader.ts
CHANGED
|
@@ -126,11 +126,21 @@ export async function loadConfigFromPath(
|
|
|
126
126
|
const result = ConfigSchema.safeParse(parsed);
|
|
127
127
|
|
|
128
128
|
if (!result.success) {
|
|
129
|
+
// Collection `audit` mistakes name the setting; other issues keep the
|
|
130
|
+
// generic message.
|
|
131
|
+
const auditIssues = result.error.issues.filter((issue) =>
|
|
132
|
+
issue.path.includes("audit")
|
|
133
|
+
);
|
|
129
134
|
return {
|
|
130
135
|
ok: false,
|
|
131
136
|
error: {
|
|
132
137
|
code: "VALIDATION_ERROR",
|
|
133
|
-
message:
|
|
138
|
+
message:
|
|
139
|
+
auditIssues.length > 0
|
|
140
|
+
? `Config validation failed: ${auditIssues
|
|
141
|
+
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
|
|
142
|
+
.join("; ")}`
|
|
143
|
+
: "Config validation failed",
|
|
134
144
|
issues: result.error.issues,
|
|
135
145
|
},
|
|
136
146
|
};
|
package/src/config/types.ts
CHANGED
|
@@ -114,6 +114,74 @@ const COLLECTION_NAME_REGEX = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
|
114
114
|
/** Collection scope key pattern: name (1-64 chars) followed by colon */
|
|
115
115
|
const COLLECTION_SCOPE_REGEX = /^[a-z0-9][a-z0-9_-]{0,63}:$/;
|
|
116
116
|
|
|
117
|
+
/** Most globs one collection's `audit.paths` may list. */
|
|
118
|
+
export const COLLECTION_AUDIT_MAX_PATHS = 64;
|
|
119
|
+
const COLLECTION_AUDIT_MAX_GLOB_CHARS = 1024;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A path glob in `pattern` syntax: relative, no `..` segment, balanced
|
|
123
|
+
* `{}` / `[]`. Bun.Glob accepts nearly any string, so the shape is checked.
|
|
124
|
+
*/
|
|
125
|
+
export function isValidAuditPathGlob(glob: string): boolean {
|
|
126
|
+
if (glob.trim() !== glob || glob.length === 0) return false;
|
|
127
|
+
if (glob.includes("\0") || glob.startsWith("/")) return false;
|
|
128
|
+
if (glob.split("/").includes("..")) return false;
|
|
129
|
+
let braces = 0;
|
|
130
|
+
let inClass = false;
|
|
131
|
+
for (const char of glob) {
|
|
132
|
+
if (inClass) {
|
|
133
|
+
if (char === "]") inClass = false;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (char === "[") inClass = true;
|
|
137
|
+
else if (char === "{") braces += 1;
|
|
138
|
+
else if (char === "}") {
|
|
139
|
+
braces -= 1;
|
|
140
|
+
if (braces < 0) return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return braces === 0 && !inClass;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Opt-in, report-only audit rules for one collection (`gno audit links`).
|
|
148
|
+
* They never block indexing, retrieval or editing.
|
|
149
|
+
*/
|
|
150
|
+
export const CollectionAuditSchema = z
|
|
151
|
+
.object({
|
|
152
|
+
/** Other collections this one may link to; its own is always allowed. */
|
|
153
|
+
linkCollections: z
|
|
154
|
+
.array(
|
|
155
|
+
z
|
|
156
|
+
.string()
|
|
157
|
+
.regex(
|
|
158
|
+
COLLECTION_NAME_REGEX,
|
|
159
|
+
"audit.linkCollections entries must be collection names"
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
.max(256)
|
|
163
|
+
.optional(),
|
|
164
|
+
/** `report` lists external URLs written in documents. Not egress. */
|
|
165
|
+
externalUrls: z.enum(["report", "ignore"]).optional(),
|
|
166
|
+
/** Each document path must match at least one glob. */
|
|
167
|
+
paths: z
|
|
168
|
+
.array(
|
|
169
|
+
z
|
|
170
|
+
.string()
|
|
171
|
+
.max(COLLECTION_AUDIT_MAX_GLOB_CHARS)
|
|
172
|
+
.refine(isValidAuditPathGlob, {
|
|
173
|
+
message:
|
|
174
|
+
"audit.paths entries must be relative globs without '..' and with balanced {} and []",
|
|
175
|
+
})
|
|
176
|
+
)
|
|
177
|
+
.min(1, "audit.paths must list at least one glob")
|
|
178
|
+
.max(COLLECTION_AUDIT_MAX_PATHS)
|
|
179
|
+
.optional(),
|
|
180
|
+
})
|
|
181
|
+
.strict();
|
|
182
|
+
|
|
183
|
+
export type CollectionAuditConfig = z.infer<typeof CollectionAuditSchema>;
|
|
184
|
+
|
|
117
185
|
export const CollectionSchema = z.object({
|
|
118
186
|
/** Unique collection identifier (lowercase) */
|
|
119
187
|
name: z
|
|
@@ -184,6 +252,9 @@ export const CollectionSchema = z.object({
|
|
|
184
252
|
*/
|
|
185
253
|
memoryManaged: z.boolean().optional(),
|
|
186
254
|
|
|
255
|
+
/** Opt-in report-only link and path rules for `gno audit links`. */
|
|
256
|
+
audit: CollectionAuditSchema.optional(),
|
|
257
|
+
|
|
187
258
|
/** Optional per-collection model overrides */
|
|
188
259
|
models: z
|
|
189
260
|
.object({
|
|
@@ -616,7 +687,25 @@ export const ConfigSchema = z.object({
|
|
|
616
687
|
editorUriTemplate: z.string().min(1).optional(),
|
|
617
688
|
|
|
618
689
|
/** Collection definitions */
|
|
619
|
-
collections: z
|
|
690
|
+
collections: z
|
|
691
|
+
.array(CollectionSchema)
|
|
692
|
+
.superRefine((collections, ctx) => {
|
|
693
|
+
const names = new Set(collections.map(({ name }) => name));
|
|
694
|
+
for (const [index, collection] of collections.entries()) {
|
|
695
|
+
for (const [
|
|
696
|
+
position,
|
|
697
|
+
name,
|
|
698
|
+
] of collection.audit?.linkCollections?.entries() ?? []) {
|
|
699
|
+
if (names.has(name)) continue;
|
|
700
|
+
ctx.addIssue({
|
|
701
|
+
code: "custom",
|
|
702
|
+
message: `Collection "${collection.name}" audit.linkCollections names unknown collection "${name}"`,
|
|
703
|
+
path: [index, "audit", "linkCollections", position],
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
})
|
|
708
|
+
.default([]),
|
|
620
709
|
|
|
621
710
|
/** Context metadata */
|
|
622
711
|
contexts: z.array(ContextSchema).default([]),
|
|
@@ -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;
|
|
@@ -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);
|