@gmickel/gno 1.32.0 → 1.34.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 +17 -3
- package/assets/skill/SKILL.md +30 -0
- package/assets/skill/cli-reference.md +10 -2
- package/browser-extension/artifacts/{gno-browser-clipper-v1.32.0.zip → gno-browser-clipper-v1.34.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.34.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +5 -1
- package/spec/cli.md +60 -1
- package/spec/mcp.md +21 -0
- package/spec/output-schemas/audit-report.schema.json +284 -0
- package/spec/output-schemas/publish-artifact.schema.json +76 -1
- package/src/cli/commands/audit.ts +231 -0
- package/src/cli/commands/publish.ts +43 -7
- package/src/cli/errors.ts +9 -2
- package/src/cli/program.ts +112 -0
- package/src/core/audit-contract.ts +296 -0
- package/src/core/audit-freshness.ts +233 -0
- package/src/core/audit-links.ts +222 -0
- package/src/core/audit-provenance.ts +154 -0
- package/src/core/audit-report.ts +318 -0
- package/src/core/audit-workspace.ts +678 -0
- package/src/core/audit.ts +569 -0
- package/src/core/capture.ts +196 -3
- package/src/core/document-capabilities.ts +9 -8
- package/src/core/record-metadata.ts +33 -0
- package/src/ingestion/strip.ts +152 -26
- package/src/mcp/http-egress.ts +8 -0
- package/src/mcp/tools/audit.ts +97 -0
- package/src/mcp/tools/index.ts +13 -0
- package/src/publish/artifact-asset-codec.ts +75 -0
- package/src/publish/artifact-asset-contract.ts +152 -0
- package/src/publish/artifact-asset-parse.ts +401 -0
- package/src/publish/artifact-asset-sniff.ts +108 -0
- package/src/publish/artifact-asset-validate.ts +209 -0
- package/src/publish/artifact-assets.ts +58 -0
- package/src/publish/artifact-validation.ts +32 -6
- package/src/publish/artifact.ts +50 -3
- package/src/publish/attachment-bundle.ts +145 -0
- package/src/publish/attachment-discover.ts +203 -0
- package/src/publish/attachment-load.ts +133 -0
- package/src/publish/attachment-obsidian.ts +45 -0
- package/src/publish/attachment-path.ts +334 -0
- package/src/publish/attachment-raster.ts +852 -0
- package/src/publish/attachment-resolver.ts +280 -0
- package/src/publish/attachment-types.ts +54 -0
- package/src/publish/encrypted-export.ts +121 -44
- package/src/publish/export-attachments.ts +224 -0
- package/src/publish/export-service.ts +142 -80
- package/src/publish/obsidian-sanitize.ts +121 -13
- package/src/serve/routes/api.ts +2 -1
- package/src/store/sqlite/adapter.ts +82 -0
- package/src/store/sqlite/graph-link-bulk-resolver.ts +191 -0
- package/src/store/sqlite/graph-link-resolver.ts +241 -2
- package/browser-extension/artifacts/gno-browser-clipper-v1.32.0.zip.sha256 +0 -1
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
/** Deterministic read-only knowledge integrity audit runner. */
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
AuditCapabilitySnapshot,
|
|
5
|
+
AuditCategory,
|
|
6
|
+
AuditFinding,
|
|
7
|
+
AuditFingerprints,
|
|
8
|
+
AuditForbiddenStoreMethod,
|
|
9
|
+
AuditReport,
|
|
10
|
+
AuditReportStatus,
|
|
11
|
+
AuditRuleContribution,
|
|
12
|
+
AuditRuleContext,
|
|
13
|
+
AuditRuleEvaluator,
|
|
14
|
+
AuditRuleResult,
|
|
15
|
+
AuditRunInput,
|
|
16
|
+
AuditRunResult,
|
|
17
|
+
AuditScope,
|
|
18
|
+
AuditVersions,
|
|
19
|
+
} from "./audit-contract";
|
|
20
|
+
|
|
21
|
+
import { VERSION } from "../app/constants";
|
|
22
|
+
import {
|
|
23
|
+
AUDIT_CATEGORIES,
|
|
24
|
+
AUDIT_DEFAULT_MAX_FINDINGS,
|
|
25
|
+
AUDIT_FORBIDDEN_STORE_METHODS,
|
|
26
|
+
AUDIT_MAX_CODE_CHARS,
|
|
27
|
+
AUDIT_MAX_FINDINGS_LIMIT,
|
|
28
|
+
AUDIT_MAX_IDENTIFIER_CHARS,
|
|
29
|
+
AUDIT_MAX_MESSAGE_CHARS,
|
|
30
|
+
AUDIT_MAX_SCOPE_ITEMS,
|
|
31
|
+
AUDIT_MAX_SCOPE_VALUE_CHARS,
|
|
32
|
+
AUDIT_MAX_SNAPSHOT_ATTEMPTS,
|
|
33
|
+
AUDIT_RULE_SET_VERSION,
|
|
34
|
+
AUDIT_SCHEMA_VERSION,
|
|
35
|
+
} from "./audit-contract";
|
|
36
|
+
import {
|
|
37
|
+
auditCategoryRank,
|
|
38
|
+
boundAuditText,
|
|
39
|
+
compareAuditCodeUnits,
|
|
40
|
+
compareAuditFindings,
|
|
41
|
+
compareAuditRules,
|
|
42
|
+
deriveAuditExitKind,
|
|
43
|
+
deriveAuditReportStatus,
|
|
44
|
+
materializeAuditFinding,
|
|
45
|
+
normalizeAuditText,
|
|
46
|
+
tallyAuditRuleCounts,
|
|
47
|
+
} from "./audit-report";
|
|
48
|
+
|
|
49
|
+
export * from "./audit-contract";
|
|
50
|
+
export * from "./audit-report";
|
|
51
|
+
|
|
52
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
53
|
+
// Scope / input validation
|
|
54
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
const isAuditCategory = (value: string): value is AuditCategory =>
|
|
57
|
+
(AUDIT_CATEGORIES as readonly string[]).includes(value);
|
|
58
|
+
|
|
59
|
+
export const normalizeAuditScope = (
|
|
60
|
+
scope: AuditScope
|
|
61
|
+
): { ok: true; scope: AuditScope } | { ok: false; error: string } => {
|
|
62
|
+
const indexName = normalizeAuditText(scope.indexName);
|
|
63
|
+
if (indexName.length < 1) {
|
|
64
|
+
return { ok: false, error: "indexName is required" };
|
|
65
|
+
}
|
|
66
|
+
if (Array.from(indexName).length > 64) {
|
|
67
|
+
return { ok: false, error: "indexName must be at most 64 characters" };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const categories =
|
|
71
|
+
scope.categories.length === 0
|
|
72
|
+
? [...AUDIT_CATEGORIES]
|
|
73
|
+
: [...new Set(scope.categories.map((item) => normalizeAuditText(item)))];
|
|
74
|
+
for (const category of categories) {
|
|
75
|
+
if (!isAuditCategory(category)) {
|
|
76
|
+
return { ok: false, error: `unknown audit category: ${category}` };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
categories.sort(
|
|
80
|
+
(left, right) =>
|
|
81
|
+
auditCategoryRank(left as AuditCategory) -
|
|
82
|
+
auditCategoryRank(right as AuditCategory)
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const collections = [
|
|
86
|
+
...new Set(scope.collections.map((item) => normalizeAuditText(item))),
|
|
87
|
+
]
|
|
88
|
+
.filter((item) => item.length > 0)
|
|
89
|
+
.sort(compareAuditCodeUnits);
|
|
90
|
+
const paths = [
|
|
91
|
+
...new Set(scope.paths.map((item) => normalizeAuditText(item))),
|
|
92
|
+
]
|
|
93
|
+
.filter((item) => item.length > 0)
|
|
94
|
+
.sort(compareAuditCodeUnits);
|
|
95
|
+
const tags = [...new Set(scope.tags.map((item) => normalizeAuditText(item)))]
|
|
96
|
+
.filter((item) => item.length > 0)
|
|
97
|
+
.sort(compareAuditCodeUnits);
|
|
98
|
+
|
|
99
|
+
for (const [name, values, maxChars] of [
|
|
100
|
+
["collections", collections, AUDIT_MAX_SCOPE_VALUE_CHARS],
|
|
101
|
+
["paths", paths, AUDIT_MAX_IDENTIFIER_CHARS],
|
|
102
|
+
["tags", tags, AUDIT_MAX_SCOPE_VALUE_CHARS],
|
|
103
|
+
] as const) {
|
|
104
|
+
if (values.length > AUDIT_MAX_SCOPE_ITEMS) {
|
|
105
|
+
return {
|
|
106
|
+
ok: false,
|
|
107
|
+
error: `${name} must contain at most ${AUDIT_MAX_SCOPE_ITEMS} values`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
if (values.some((value) => Array.from(value).length > maxChars)) {
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
error: `${name} entries must be at most ${maxChars} characters`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
ok: true,
|
|
120
|
+
scope: {
|
|
121
|
+
categories: categories as AuditCategory[],
|
|
122
|
+
collections,
|
|
123
|
+
paths,
|
|
124
|
+
tags,
|
|
125
|
+
indexName,
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const resolveMaxFindings = (
|
|
131
|
+
value: number | undefined
|
|
132
|
+
): { ok: true; maxFindings: number } | { ok: false; error: string } => {
|
|
133
|
+
const maxFindings = value ?? AUDIT_DEFAULT_MAX_FINDINGS;
|
|
134
|
+
if (
|
|
135
|
+
!Number.isSafeInteger(maxFindings) ||
|
|
136
|
+
maxFindings < 1 ||
|
|
137
|
+
maxFindings > AUDIT_MAX_FINDINGS_LIMIT
|
|
138
|
+
) {
|
|
139
|
+
return {
|
|
140
|
+
ok: false,
|
|
141
|
+
error: `maxFindings must be an integer between 1 and ${AUDIT_MAX_FINDINGS_LIMIT}`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
return { ok: true, maxFindings };
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const fingerprintsEqual = (
|
|
148
|
+
left: AuditFingerprints,
|
|
149
|
+
right: AuditFingerprints
|
|
150
|
+
): boolean =>
|
|
151
|
+
left.config === right.config &&
|
|
152
|
+
left.source === right.source &&
|
|
153
|
+
left.index === right.index &&
|
|
154
|
+
left.rules === right.rules;
|
|
155
|
+
|
|
156
|
+
const normalizeFingerprints = (
|
|
157
|
+
fingerprints: AuditFingerprints
|
|
158
|
+
): AuditFingerprints => ({
|
|
159
|
+
config: normalizeAuditText(fingerprints.config),
|
|
160
|
+
source: normalizeAuditText(fingerprints.source),
|
|
161
|
+
index: normalizeAuditText(fingerprints.index),
|
|
162
|
+
rules: normalizeAuditText(fingerprints.rules),
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const defaultCapabilities = (
|
|
166
|
+
capabilities: AuditCapabilitySnapshot
|
|
167
|
+
): AuditCapabilitySnapshot => ({
|
|
168
|
+
...capabilities,
|
|
169
|
+
offline: true,
|
|
170
|
+
llmDisabled: true,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
174
|
+
// Runner
|
|
175
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
const materializeRule = (
|
|
178
|
+
contribution: AuditRuleContribution
|
|
179
|
+
): AuditRuleResult => {
|
|
180
|
+
const ruleId = boundAuditText(contribution.ruleId, AUDIT_MAX_CODE_CHARS);
|
|
181
|
+
const findings = (contribution.findings ?? [])
|
|
182
|
+
.map((draft) =>
|
|
183
|
+
materializeAuditFinding(ruleId, contribution.category, draft)
|
|
184
|
+
)
|
|
185
|
+
.sort(compareAuditFindings);
|
|
186
|
+
|
|
187
|
+
let status = contribution.status;
|
|
188
|
+
if (findings.length > 0 && status === "pass") {
|
|
189
|
+
status = "fail";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
ruleId,
|
|
194
|
+
category: contribution.category,
|
|
195
|
+
status,
|
|
196
|
+
message: boundAuditText(contribution.message, AUDIT_MAX_MESSAGE_CHARS),
|
|
197
|
+
findings,
|
|
198
|
+
findingCount: Math.max(
|
|
199
|
+
findings.length,
|
|
200
|
+
contribution.findingCount ?? findings.length
|
|
201
|
+
),
|
|
202
|
+
examinedCount: Math.max(0, contribution.examinedCount ?? 0),
|
|
203
|
+
durationMs: Math.max(0, Math.round(contribution.durationMs ?? 0)),
|
|
204
|
+
skipReason:
|
|
205
|
+
contribution.skipReason === undefined || contribution.skipReason === null
|
|
206
|
+
? null
|
|
207
|
+
: boundAuditText(contribution.skipReason, AUDIT_MAX_MESSAGE_CHARS),
|
|
208
|
+
};
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const collectContributions = async (
|
|
212
|
+
evaluators: readonly AuditRuleEvaluator[],
|
|
213
|
+
ctx: AuditRuleContext
|
|
214
|
+
): Promise<AuditRuleContribution[]> => {
|
|
215
|
+
const collected: AuditRuleContribution[] = [];
|
|
216
|
+
for (const evaluator of evaluators) {
|
|
217
|
+
const result = await evaluator(ctx);
|
|
218
|
+
if (Array.isArray(result)) {
|
|
219
|
+
collected.push(...result);
|
|
220
|
+
} else {
|
|
221
|
+
collected.push(result);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return collected;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const buildReportFromRules = (input: {
|
|
228
|
+
scope: AuditScope;
|
|
229
|
+
capabilities: AuditCapabilitySnapshot;
|
|
230
|
+
fingerprints: AuditFingerprints;
|
|
231
|
+
versions: AuditVersions;
|
|
232
|
+
rules: AuditRuleResult[];
|
|
233
|
+
status: AuditReportStatus;
|
|
234
|
+
maxFindings: number;
|
|
235
|
+
startedAt: string;
|
|
236
|
+
completedAt: string;
|
|
237
|
+
snapshotMs: number;
|
|
238
|
+
rulesMs: number;
|
|
239
|
+
totalMs: number;
|
|
240
|
+
}): AuditReport => {
|
|
241
|
+
const materializedRules = [...input.rules]
|
|
242
|
+
.sort(compareAuditRules)
|
|
243
|
+
.map((rule) => ({
|
|
244
|
+
...rule,
|
|
245
|
+
findings: [...rule.findings].sort(compareAuditFindings),
|
|
246
|
+
}));
|
|
247
|
+
|
|
248
|
+
const allFindings = materializedRules
|
|
249
|
+
.flatMap((rule) => rule.findings)
|
|
250
|
+
.sort(compareAuditFindings);
|
|
251
|
+
// Deduplicate by stable id while preserving canonical order.
|
|
252
|
+
const seen = new Set<string>();
|
|
253
|
+
const uniqueFindings: AuditFinding[] = [];
|
|
254
|
+
for (const finding of allFindings) {
|
|
255
|
+
if (seen.has(finding.id)) continue;
|
|
256
|
+
seen.add(finding.id);
|
|
257
|
+
uniqueFindings.push(finding);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const exactFindingCount = materializedRules.reduce(
|
|
261
|
+
(sum, rule) => sum + rule.findingCount,
|
|
262
|
+
0
|
|
263
|
+
);
|
|
264
|
+
const truncated = exactFindingCount > input.maxFindings;
|
|
265
|
+
const returnedFindings = truncated
|
|
266
|
+
? uniqueFindings.slice(0, input.maxFindings)
|
|
267
|
+
: uniqueFindings;
|
|
268
|
+
const returnedFindingIds = new Set(
|
|
269
|
+
returnedFindings.map((finding) => finding.id)
|
|
270
|
+
);
|
|
271
|
+
// Rule details and the top-level list share one global payload budget. Exact
|
|
272
|
+
// per-rule totals remain in findingCount, so truncation never hides scale.
|
|
273
|
+
const rules = materializedRules.map((rule) => ({
|
|
274
|
+
...rule,
|
|
275
|
+
findings: rule.findings.filter((finding) =>
|
|
276
|
+
returnedFindingIds.has(finding.id)
|
|
277
|
+
),
|
|
278
|
+
}));
|
|
279
|
+
|
|
280
|
+
const examinedDocuments = rules.reduce(
|
|
281
|
+
(sum, rule) => sum + rule.examinedCount,
|
|
282
|
+
0
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
schemaVersion: AUDIT_SCHEMA_VERSION,
|
|
287
|
+
ruleSetVersion: AUDIT_RULE_SET_VERSION,
|
|
288
|
+
status: input.status,
|
|
289
|
+
scope: input.scope,
|
|
290
|
+
capabilities: input.capabilities,
|
|
291
|
+
fingerprints: input.fingerprints,
|
|
292
|
+
versions: input.versions,
|
|
293
|
+
startedAt: input.startedAt,
|
|
294
|
+
completedAt: input.completedAt,
|
|
295
|
+
durationMs: Math.max(0, Math.round(input.totalMs)),
|
|
296
|
+
rules,
|
|
297
|
+
findings: returnedFindings,
|
|
298
|
+
counts: {
|
|
299
|
+
rules: tallyAuditRuleCounts(rules),
|
|
300
|
+
findings: {
|
|
301
|
+
total: exactFindingCount,
|
|
302
|
+
returned: returnedFindings.length,
|
|
303
|
+
truncated,
|
|
304
|
+
},
|
|
305
|
+
examined: {
|
|
306
|
+
documents: examinedDocuments,
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
truncation: {
|
|
310
|
+
findingsTruncated: truncated,
|
|
311
|
+
maxFindings: input.maxFindings,
|
|
312
|
+
},
|
|
313
|
+
timing: {
|
|
314
|
+
snapshotMs: Math.max(0, Math.round(input.snapshotMs)),
|
|
315
|
+
rulesMs: Math.max(0, Math.round(input.rulesMs)),
|
|
316
|
+
totalMs: Math.max(0, Math.round(input.totalMs)),
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Run the read-only audit runner against injected rule evaluators.
|
|
323
|
+
* Snapshots fingerprints before and after rule evaluation; mid-run changes
|
|
324
|
+
* retry up to `maxAttempts` and otherwise yield `changed_during_audit` —
|
|
325
|
+
* never a clean report.
|
|
326
|
+
*/
|
|
327
|
+
export async function runAudit(input: AuditRunInput): Promise<AuditRunResult> {
|
|
328
|
+
const scopeResult = normalizeAuditScope(input.scope);
|
|
329
|
+
if (!scopeResult.ok) {
|
|
330
|
+
return { ok: false, exit: "invalid", error: scopeResult.error };
|
|
331
|
+
}
|
|
332
|
+
const maxFindingsResult = resolveMaxFindings(input.maxFindings);
|
|
333
|
+
if (!maxFindingsResult.ok) {
|
|
334
|
+
return { ok: false, exit: "invalid", error: maxFindingsResult.error };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const maxAttempts = Math.max(
|
|
338
|
+
1,
|
|
339
|
+
Math.min(
|
|
340
|
+
input.maxAttempts ?? AUDIT_MAX_SNAPSHOT_ATTEMPTS,
|
|
341
|
+
AUDIT_MAX_SNAPSHOT_ATTEMPTS
|
|
342
|
+
)
|
|
343
|
+
);
|
|
344
|
+
const clock = input.clock ?? (() => new Date());
|
|
345
|
+
const monotonicNow = input.monotonicNow ?? (() => performance.now());
|
|
346
|
+
const capabilities = defaultCapabilities(input.capabilities);
|
|
347
|
+
const versions: AuditVersions = {
|
|
348
|
+
gno: input.gnoVersion ?? VERSION,
|
|
349
|
+
schema: AUDIT_SCHEMA_VERSION,
|
|
350
|
+
ruleSet: AUDIT_RULE_SET_VERSION,
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const runStartedAt = clock();
|
|
354
|
+
const runStartedMs = monotonicNow();
|
|
355
|
+
let snapshotMs = 0;
|
|
356
|
+
let rulesMs = 0;
|
|
357
|
+
let lastRules: AuditRuleResult[] = [];
|
|
358
|
+
let lastFingerprints: AuditFingerprints | null = null;
|
|
359
|
+
let snapshotChanged = false;
|
|
360
|
+
let failed = false;
|
|
361
|
+
let failureMessage = "Audit failed";
|
|
362
|
+
const cancellationRule = (): AuditRuleResult =>
|
|
363
|
+
materializeRule({
|
|
364
|
+
ruleId: "audit.cancelled",
|
|
365
|
+
category: scopeResult.scope.categories[0] ?? "links",
|
|
366
|
+
status: "inconclusive",
|
|
367
|
+
message: "Audit was cancelled",
|
|
368
|
+
findings: [],
|
|
369
|
+
findingCount: 0,
|
|
370
|
+
skipReason: "cancelled",
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
374
|
+
if (input.signal?.aborted) {
|
|
375
|
+
lastRules = [cancellationRule()];
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
const snapshotStarted = monotonicNow();
|
|
379
|
+
let before: AuditFingerprints;
|
|
380
|
+
try {
|
|
381
|
+
before = normalizeFingerprints(await input.captureFingerprints());
|
|
382
|
+
} catch (cause) {
|
|
383
|
+
if (input.signal?.aborted) {
|
|
384
|
+
lastRules = [cancellationRule()];
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
failed = true;
|
|
388
|
+
failureMessage =
|
|
389
|
+
cause instanceof Error
|
|
390
|
+
? cause.message
|
|
391
|
+
: "Failed to capture audit fingerprints";
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
snapshotMs += Math.max(0, monotonicNow() - snapshotStarted);
|
|
395
|
+
lastFingerprints = before;
|
|
396
|
+
|
|
397
|
+
const ruleCtx: AuditRuleContext = {
|
|
398
|
+
scope: scopeResult.scope,
|
|
399
|
+
capabilities,
|
|
400
|
+
fingerprints: before,
|
|
401
|
+
attempt,
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
const rulesStarted = monotonicNow();
|
|
405
|
+
try {
|
|
406
|
+
const contributions = await collectContributions(input.rules, ruleCtx);
|
|
407
|
+
lastRules = contributions.map(materializeRule);
|
|
408
|
+
} catch (cause) {
|
|
409
|
+
if (input.signal?.aborted) {
|
|
410
|
+
lastRules = [cancellationRule()];
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
failed = true;
|
|
414
|
+
failureMessage =
|
|
415
|
+
cause instanceof Error ? cause.message : "Audit rule evaluation failed";
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
rulesMs += Math.max(0, monotonicNow() - rulesStarted);
|
|
419
|
+
|
|
420
|
+
if (input.signal?.aborted) {
|
|
421
|
+
lastRules = [cancellationRule()];
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const afterStarted = monotonicNow();
|
|
426
|
+
let after: AuditFingerprints;
|
|
427
|
+
try {
|
|
428
|
+
after = normalizeFingerprints(await input.captureFingerprints());
|
|
429
|
+
} catch (cause) {
|
|
430
|
+
if (input.signal?.aborted) {
|
|
431
|
+
lastRules = [cancellationRule()];
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
failed = true;
|
|
435
|
+
failureMessage =
|
|
436
|
+
cause instanceof Error
|
|
437
|
+
? cause.message
|
|
438
|
+
: "Failed to re-capture audit fingerprints";
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
snapshotMs += Math.max(0, monotonicNow() - afterStarted);
|
|
442
|
+
|
|
443
|
+
if (fingerprintsEqual(before, after)) {
|
|
444
|
+
snapshotChanged = false;
|
|
445
|
+
lastFingerprints = after;
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
snapshotChanged = true;
|
|
450
|
+
lastFingerprints = after;
|
|
451
|
+
if (attempt === maxAttempts) {
|
|
452
|
+
break;
|
|
453
|
+
}
|
|
454
|
+
// Discard rule results from a drifted attempt; retry with a fresh snapshot.
|
|
455
|
+
lastRules = [];
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const completedAt = clock();
|
|
459
|
+
const totalMs = Math.max(0, monotonicNow() - runStartedMs);
|
|
460
|
+
const fingerprints = lastFingerprints ?? {
|
|
461
|
+
config: "",
|
|
462
|
+
source: "",
|
|
463
|
+
index: "",
|
|
464
|
+
rules: "",
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
if (failed) {
|
|
468
|
+
const report = buildReportFromRules({
|
|
469
|
+
scope: scopeResult.scope,
|
|
470
|
+
capabilities,
|
|
471
|
+
fingerprints,
|
|
472
|
+
versions,
|
|
473
|
+
rules: [
|
|
474
|
+
{
|
|
475
|
+
ruleId: "audit.runner",
|
|
476
|
+
category: scopeResult.scope.categories[0] ?? "links",
|
|
477
|
+
status: "unavailable",
|
|
478
|
+
message: failureMessage,
|
|
479
|
+
findings: [],
|
|
480
|
+
findingCount: 0,
|
|
481
|
+
examinedCount: 0,
|
|
482
|
+
durationMs: 0,
|
|
483
|
+
skipReason: failureMessage,
|
|
484
|
+
},
|
|
485
|
+
],
|
|
486
|
+
status: "failed",
|
|
487
|
+
maxFindings: maxFindingsResult.maxFindings,
|
|
488
|
+
startedAt: runStartedAt.toISOString(),
|
|
489
|
+
completedAt: completedAt.toISOString(),
|
|
490
|
+
snapshotMs,
|
|
491
|
+
rulesMs,
|
|
492
|
+
totalMs,
|
|
493
|
+
});
|
|
494
|
+
return { ok: true, report, exit: deriveAuditExitKind(report) };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const status = deriveAuditReportStatus({
|
|
498
|
+
rules: lastRules,
|
|
499
|
+
snapshotChanged,
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// Mid-run drift must never report clean, even if evaluators emitted no findings.
|
|
503
|
+
const rulesForReport =
|
|
504
|
+
status === "changed_during_audit" && lastRules.length === 0
|
|
505
|
+
? [
|
|
506
|
+
{
|
|
507
|
+
ruleId: "audit.snapshot",
|
|
508
|
+
category: scopeResult.scope.categories[0] ?? "links",
|
|
509
|
+
status: "inconclusive" as const,
|
|
510
|
+
message:
|
|
511
|
+
"Source or index fingerprints changed during audit; results are not authoritative",
|
|
512
|
+
findings: [] as AuditFinding[],
|
|
513
|
+
findingCount: 0,
|
|
514
|
+
examinedCount: 0,
|
|
515
|
+
durationMs: 0,
|
|
516
|
+
skipReason: "changed_during_audit",
|
|
517
|
+
},
|
|
518
|
+
]
|
|
519
|
+
: lastRules;
|
|
520
|
+
|
|
521
|
+
const report = buildReportFromRules({
|
|
522
|
+
scope: scopeResult.scope,
|
|
523
|
+
capabilities,
|
|
524
|
+
fingerprints,
|
|
525
|
+
versions,
|
|
526
|
+
rules: rulesForReport,
|
|
527
|
+
status:
|
|
528
|
+
status === "changed_during_audit"
|
|
529
|
+
? "changed_during_audit"
|
|
530
|
+
: deriveAuditReportStatus({
|
|
531
|
+
rules: rulesForReport,
|
|
532
|
+
snapshotChanged: false,
|
|
533
|
+
}),
|
|
534
|
+
maxFindings: maxFindingsResult.maxFindings,
|
|
535
|
+
startedAt: runStartedAt.toISOString(),
|
|
536
|
+
completedAt: completedAt.toISOString(),
|
|
537
|
+
snapshotMs,
|
|
538
|
+
rulesMs,
|
|
539
|
+
totalMs,
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
return { ok: true, report, exit: deriveAuditExitKind(report) };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Assert a store-like object does not expose callable mutating methods used
|
|
547
|
+
* by audits. Tests wrap ports to prove no writes occur during a run.
|
|
548
|
+
*/
|
|
549
|
+
export function createAuditWriteGuard<T extends object>(
|
|
550
|
+
port: T,
|
|
551
|
+
forbidden: readonly AuditForbiddenStoreMethod[] = AUDIT_FORBIDDEN_STORE_METHODS
|
|
552
|
+
): T & { readonly writeAttempts: readonly string[] } {
|
|
553
|
+
const writeAttempts: string[] = [];
|
|
554
|
+
const forbiddenMethods = new Set<string>(forbidden);
|
|
555
|
+
return new Proxy(port, {
|
|
556
|
+
get(target, property, receiver) {
|
|
557
|
+
if (property === "writeAttempts") return writeAttempts;
|
|
558
|
+
if (typeof property === "string" && forbiddenMethods.has(property)) {
|
|
559
|
+
return (..._args: unknown[]) => {
|
|
560
|
+
writeAttempts.push(property);
|
|
561
|
+
throw new Error(
|
|
562
|
+
`Audit must not call mutating store method ${property}`
|
|
563
|
+
);
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
return Reflect.get(target, property, receiver);
|
|
567
|
+
},
|
|
568
|
+
}) as T & { readonly writeAttempts: readonly string[] };
|
|
569
|
+
}
|