@gmickel/gno 1.33.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 +7 -2
- package/assets/skill/SKILL.md +19 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v1.33.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 +1 -1
- package/spec/cli.md +44 -0
- package/spec/mcp.md +21 -0
- package/spec/output-schemas/audit-report.schema.json +284 -0
- package/src/cli/commands/audit.ts +231 -0
- 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/mcp/http-egress.ts +8 -0
- package/src/mcp/tools/audit.ts +97 -0
- package/src/mcp/tools/index.ts +13 -0
- 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.33.0.zip.sha256 +0 -1
package/src/cli/errors.ts
CHANGED
|
@@ -9,7 +9,12 @@
|
|
|
9
9
|
// Error Types
|
|
10
10
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
11
11
|
|
|
12
|
-
export type CliErrorCode =
|
|
12
|
+
export type CliErrorCode =
|
|
13
|
+
| "VALIDATION"
|
|
14
|
+
| "RUNTIME"
|
|
15
|
+
| "NOT_RUNNING"
|
|
16
|
+
| "AUDIT_FINDINGS"
|
|
17
|
+
| "AUDIT_PARTIAL";
|
|
13
18
|
|
|
14
19
|
export interface CliErrorOptions {
|
|
15
20
|
details?: Record<string, unknown>;
|
|
@@ -62,13 +67,15 @@ export class CliError extends Error {
|
|
|
62
67
|
// Exit Codes
|
|
63
68
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
64
69
|
|
|
65
|
-
export function exitCodeFor(err: CliError): 1 | 2 | 3 {
|
|
70
|
+
export function exitCodeFor(err: CliError): 1 | 2 | 3 | 4 | 5 {
|
|
66
71
|
if (err.code === "VALIDATION") {
|
|
67
72
|
return 1;
|
|
68
73
|
}
|
|
69
74
|
if (err.code === "NOT_RUNNING") {
|
|
70
75
|
return 3;
|
|
71
76
|
}
|
|
77
|
+
if (err.code === "AUDIT_FINDINGS") return 4;
|
|
78
|
+
if (err.code === "AUDIT_PARTIAL") return 5;
|
|
72
79
|
return 2;
|
|
73
80
|
}
|
|
74
81
|
|
package/src/cli/program.ts
CHANGED
|
@@ -1539,6 +1539,118 @@ function wireOnboardingCommands(program: Command): void {
|
|
|
1539
1539
|
}
|
|
1540
1540
|
);
|
|
1541
1541
|
|
|
1542
|
+
// audit - Read-only workspace integrity report
|
|
1543
|
+
program
|
|
1544
|
+
.command("audit [category]")
|
|
1545
|
+
.description("Run read-only workspace integrity audits")
|
|
1546
|
+
.option(
|
|
1547
|
+
"-c, --collection <name>",
|
|
1548
|
+
"collection scope (repeatable)",
|
|
1549
|
+
collectRepeatableValue,
|
|
1550
|
+
[]
|
|
1551
|
+
)
|
|
1552
|
+
.option(
|
|
1553
|
+
"--path <prefix>",
|
|
1554
|
+
"collection-relative path prefix (repeatable)",
|
|
1555
|
+
collectRepeatableValue,
|
|
1556
|
+
[]
|
|
1557
|
+
)
|
|
1558
|
+
.option(
|
|
1559
|
+
"--tag <tag>",
|
|
1560
|
+
"require tag (repeatable, AND semantics)",
|
|
1561
|
+
collectRepeatableValue,
|
|
1562
|
+
[]
|
|
1563
|
+
)
|
|
1564
|
+
.option("--max-findings <count>", "maximum returned findings", Number)
|
|
1565
|
+
.option("--max-age-days <days>", "explicit age review signal", Number)
|
|
1566
|
+
.option(
|
|
1567
|
+
"--orphan-root <uri>",
|
|
1568
|
+
"URI excluded from orphan findings (repeatable)",
|
|
1569
|
+
collectRepeatableValue,
|
|
1570
|
+
[]
|
|
1571
|
+
)
|
|
1572
|
+
.option(
|
|
1573
|
+
"--orphan-ignore-prefix <prefix>",
|
|
1574
|
+
"path prefix excluded from orphan findings (repeatable)",
|
|
1575
|
+
collectRepeatableValue,
|
|
1576
|
+
[]
|
|
1577
|
+
)
|
|
1578
|
+
.option("--output <path>", "also write the rendered report to a file")
|
|
1579
|
+
.option("--no-progress", "disable progress on stderr")
|
|
1580
|
+
.option("--json", "JSON output")
|
|
1581
|
+
.action(
|
|
1582
|
+
async (
|
|
1583
|
+
category: string | undefined,
|
|
1584
|
+
cmdOpts: Record<string, unknown>
|
|
1585
|
+
) => {
|
|
1586
|
+
const { audit, formatAuditReport, writeAuditReport } =
|
|
1587
|
+
await import("./commands/audit");
|
|
1588
|
+
const globals = getGlobals();
|
|
1589
|
+
const json = getFormat(cmdOpts) === "json";
|
|
1590
|
+
const showProgress =
|
|
1591
|
+
cmdOpts.progress !== false &&
|
|
1592
|
+
!globals.quiet &&
|
|
1593
|
+
!json &&
|
|
1594
|
+
process.stderr.isTTY;
|
|
1595
|
+
const controller = new AbortController();
|
|
1596
|
+
const abort = (): void => controller.abort();
|
|
1597
|
+
process.once("SIGINT", abort);
|
|
1598
|
+
try {
|
|
1599
|
+
const result = await audit({
|
|
1600
|
+
category,
|
|
1601
|
+
configPath: globals.config,
|
|
1602
|
+
indexName: globals.index,
|
|
1603
|
+
collections: cmdOpts.collection as string[],
|
|
1604
|
+
paths: cmdOpts.path as string[],
|
|
1605
|
+
tags: cmdOpts.tag as string[],
|
|
1606
|
+
maxFindings: cmdOpts.maxFindings as number | undefined,
|
|
1607
|
+
maxAgeDays: cmdOpts.maxAgeDays as number | undefined,
|
|
1608
|
+
orphanRoots: cmdOpts.orphanRoot as string[],
|
|
1609
|
+
orphanIgnorePrefixes: cmdOpts.orphanIgnorePrefix as string[],
|
|
1610
|
+
signal: controller.signal,
|
|
1611
|
+
onProgress: showProgress
|
|
1612
|
+
? ({ phase, completed, total }) => {
|
|
1613
|
+
process.stderr.write(
|
|
1614
|
+
`\rAudit ${phase}: ${completed}/${Math.max(total, 1)}`
|
|
1615
|
+
);
|
|
1616
|
+
}
|
|
1617
|
+
: undefined,
|
|
1618
|
+
});
|
|
1619
|
+
if (!result.success) {
|
|
1620
|
+
throw new CliError(
|
|
1621
|
+
result.invalid ? "VALIDATION" : "RUNTIME",
|
|
1622
|
+
result.error
|
|
1623
|
+
);
|
|
1624
|
+
}
|
|
1625
|
+
const rendered = formatAuditReport(result.report, { json });
|
|
1626
|
+
process.stdout.write(`${rendered}\n`);
|
|
1627
|
+
if (cmdOpts.output) {
|
|
1628
|
+
await writeAuditReport(cmdOpts.output as string, result.report, {
|
|
1629
|
+
json,
|
|
1630
|
+
});
|
|
1631
|
+
}
|
|
1632
|
+
if (result.exitCode === 2) {
|
|
1633
|
+
throw new CliError("RUNTIME", "Audit runtime failure", {
|
|
1634
|
+
silent: true,
|
|
1635
|
+
});
|
|
1636
|
+
}
|
|
1637
|
+
if (result.exitCode === 4) {
|
|
1638
|
+
throw new CliError("AUDIT_FINDINGS", "Audit findings present", {
|
|
1639
|
+
silent: true,
|
|
1640
|
+
});
|
|
1641
|
+
}
|
|
1642
|
+
if (result.exitCode === 5) {
|
|
1643
|
+
throw new CliError("AUDIT_PARTIAL", "Audit evidence is partial", {
|
|
1644
|
+
silent: true,
|
|
1645
|
+
});
|
|
1646
|
+
}
|
|
1647
|
+
} finally {
|
|
1648
|
+
if (showProgress) process.stderr.write("\n");
|
|
1649
|
+
process.removeListener("SIGINT", abort);
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
);
|
|
1653
|
+
|
|
1542
1654
|
// status - Show index status
|
|
1543
1655
|
program
|
|
1544
1656
|
.command("status")
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/** Versioned types and bounds for deterministic knowledge integrity audits. */
|
|
2
|
+
|
|
3
|
+
import type { StorePort } from "../store/types";
|
|
4
|
+
|
|
5
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
6
|
+
// Versions & bounds
|
|
7
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
export const AUDIT_SCHEMA_VERSION = "1.0" as const;
|
|
10
|
+
export const AUDIT_RULE_SET_VERSION = "1.0" as const;
|
|
11
|
+
|
|
12
|
+
/** Default returned finding cap; totals remain exact when truncated. */
|
|
13
|
+
export const AUDIT_DEFAULT_MAX_FINDINGS = 100;
|
|
14
|
+
/** Hard upper bound for `--max-findings` / MCP maxFindings. */
|
|
15
|
+
export const AUDIT_MAX_FINDINGS_LIMIT = 1000;
|
|
16
|
+
export const AUDIT_MAX_EVIDENCE_PER_FINDING = 8;
|
|
17
|
+
export const AUDIT_MAX_GUIDANCE_PER_FINDING = 4;
|
|
18
|
+
export const AUDIT_MAX_EVIDENCE_DETAIL_CHARS = 512;
|
|
19
|
+
export const AUDIT_MAX_MESSAGE_CHARS = 512;
|
|
20
|
+
export const AUDIT_MAX_GUIDANCE_CHARS = 256;
|
|
21
|
+
export const AUDIT_MAX_CODE_CHARS = 128;
|
|
22
|
+
export const AUDIT_MAX_IDENTIFIER_CHARS = 2048;
|
|
23
|
+
export const AUDIT_MAX_SCOPE_ITEMS = 256;
|
|
24
|
+
export const AUDIT_MAX_SCOPE_VALUE_CHARS = 256;
|
|
25
|
+
/** Bounded snapshot retries before `changed_during_audit`. */
|
|
26
|
+
export const AUDIT_MAX_SNAPSHOT_ATTEMPTS = 2;
|
|
27
|
+
|
|
28
|
+
export const AUDIT_CATEGORIES = ["links", "provenance", "freshness"] as const;
|
|
29
|
+
export type AuditCategory = (typeof AUDIT_CATEGORIES)[number];
|
|
30
|
+
|
|
31
|
+
export const AUDIT_RULE_STATUSES = [
|
|
32
|
+
"pass",
|
|
33
|
+
"fail",
|
|
34
|
+
"skip",
|
|
35
|
+
"unavailable",
|
|
36
|
+
"inconclusive",
|
|
37
|
+
] as const;
|
|
38
|
+
export type AuditRuleStatus = (typeof AUDIT_RULE_STATUSES)[number];
|
|
39
|
+
|
|
40
|
+
export const AUDIT_REPORT_STATUSES = [
|
|
41
|
+
"complete",
|
|
42
|
+
"partial",
|
|
43
|
+
"changed_during_audit",
|
|
44
|
+
"failed",
|
|
45
|
+
] as const;
|
|
46
|
+
export type AuditReportStatus = (typeof AUDIT_REPORT_STATUSES)[number];
|
|
47
|
+
|
|
48
|
+
export const AUDIT_EXIT_KINDS = [
|
|
49
|
+
"clean",
|
|
50
|
+
"findings",
|
|
51
|
+
"invalid",
|
|
52
|
+
"partial",
|
|
53
|
+
"runtime",
|
|
54
|
+
] as const;
|
|
55
|
+
export type AuditExitKind = (typeof AUDIT_EXIT_KINDS)[number];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Process exit codes for `gno audit` / MCP surfaces.
|
|
59
|
+
* 0/1/2 align with global SUCCESS/VALIDATION/RUNTIME; 4/5 are audit-specific.
|
|
60
|
+
*/
|
|
61
|
+
export const AUDIT_EXIT_CODES = {
|
|
62
|
+
clean: 0,
|
|
63
|
+
invalid: 1,
|
|
64
|
+
runtime: 2,
|
|
65
|
+
findings: 4,
|
|
66
|
+
partial: 5,
|
|
67
|
+
} as const satisfies Record<AuditExitKind, number>;
|
|
68
|
+
|
|
69
|
+
export type AuditFindingSeverity = "error" | "warning" | "info";
|
|
70
|
+
|
|
71
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
72
|
+
// No-write port boundary
|
|
73
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Read-only store methods permitted for audit snapshot/rule evaluation.
|
|
77
|
+
* Category tasks may narrow further; mutating StorePort methods are forbidden.
|
|
78
|
+
*/
|
|
79
|
+
export type AuditReadStorePort = Pick<
|
|
80
|
+
StorePort,
|
|
81
|
+
| "getActivationIndexSnapshot"
|
|
82
|
+
| "getCollections"
|
|
83
|
+
| "getStatus"
|
|
84
|
+
| "listDocuments"
|
|
85
|
+
| "listDocumentsPaginated"
|
|
86
|
+
| "getDocumentsByDocids"
|
|
87
|
+
| "getChunksBatch"
|
|
88
|
+
| "getContentBatch"
|
|
89
|
+
>;
|
|
90
|
+
|
|
91
|
+
/** Mutating StorePort method names that audits must never invoke. */
|
|
92
|
+
export const AUDIT_FORBIDDEN_STORE_METHODS = [
|
|
93
|
+
"upsertDocument",
|
|
94
|
+
"deactivateDocument",
|
|
95
|
+
"deleteDocument",
|
|
96
|
+
"upsertChunks",
|
|
97
|
+
"deleteChunks",
|
|
98
|
+
"recordError",
|
|
99
|
+
"upsertActivationReceipt",
|
|
100
|
+
"createRetrievalTrace",
|
|
101
|
+
"withTransaction",
|
|
102
|
+
"syncCollections",
|
|
103
|
+
"cleanup",
|
|
104
|
+
] as const;
|
|
105
|
+
|
|
106
|
+
export type AuditForbiddenStoreMethod =
|
|
107
|
+
(typeof AUDIT_FORBIDDEN_STORE_METHODS)[number];
|
|
108
|
+
|
|
109
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
110
|
+
// Report / finding shapes
|
|
111
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
export interface AuditScope {
|
|
114
|
+
/** Categories requested for this run. Empty means all categories. */
|
|
115
|
+
categories: AuditCategory[];
|
|
116
|
+
collections: string[];
|
|
117
|
+
/** Optional path/prefix filters (normalized, sorted). */
|
|
118
|
+
paths: string[];
|
|
119
|
+
/** Optional tag filters (normalized, sorted). */
|
|
120
|
+
tags: string[];
|
|
121
|
+
indexName: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface AuditCapabilitySnapshot {
|
|
125
|
+
indexReadable: boolean;
|
|
126
|
+
sourcesReadable: boolean;
|
|
127
|
+
linksGraphAvailable: boolean;
|
|
128
|
+
provenanceSchemaAvailable: boolean;
|
|
129
|
+
offline: true;
|
|
130
|
+
llmDisabled: true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface AuditFingerprints {
|
|
134
|
+
/** Canonical config / rule-input fingerprint. */
|
|
135
|
+
config: string;
|
|
136
|
+
/** Source tree revision evidence fingerprint. */
|
|
137
|
+
source: string;
|
|
138
|
+
/** Index revision evidence fingerprint. */
|
|
139
|
+
index: string;
|
|
140
|
+
/** Active rule-set identity fingerprint. */
|
|
141
|
+
rules: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface AuditEvidence {
|
|
145
|
+
kind: string;
|
|
146
|
+
summary: string;
|
|
147
|
+
uri?: string;
|
|
148
|
+
path?: string;
|
|
149
|
+
detail?: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface AuditFinding {
|
|
153
|
+
/** Stable SHA-256 identity; survives traversal order. */
|
|
154
|
+
id: string;
|
|
155
|
+
ruleId: string;
|
|
156
|
+
category: AuditCategory;
|
|
157
|
+
severity: AuditFindingSeverity;
|
|
158
|
+
/** Normalized subject (typically gno:// URI or collection-relative path). */
|
|
159
|
+
subject: string;
|
|
160
|
+
/** Normalized location within the subject, or null. */
|
|
161
|
+
location: string | null;
|
|
162
|
+
message: string;
|
|
163
|
+
evidence: AuditEvidence[];
|
|
164
|
+
guidance: string[];
|
|
165
|
+
evidenceFingerprint: string;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface AuditRuleResult {
|
|
169
|
+
ruleId: string;
|
|
170
|
+
category: AuditCategory;
|
|
171
|
+
status: AuditRuleStatus;
|
|
172
|
+
message: string;
|
|
173
|
+
findings: AuditFinding[];
|
|
174
|
+
/** Exact findings before the bounded per-rule payload. */
|
|
175
|
+
findingCount: number;
|
|
176
|
+
examinedCount: number;
|
|
177
|
+
durationMs: number;
|
|
178
|
+
skipReason: string | null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface AuditCounts {
|
|
182
|
+
rules: {
|
|
183
|
+
pass: number;
|
|
184
|
+
fail: number;
|
|
185
|
+
skip: number;
|
|
186
|
+
unavailable: number;
|
|
187
|
+
inconclusive: number;
|
|
188
|
+
total: number;
|
|
189
|
+
};
|
|
190
|
+
findings: {
|
|
191
|
+
/** Exact total before return-cap truncation. */
|
|
192
|
+
total: number;
|
|
193
|
+
/** Findings included in the report payload. */
|
|
194
|
+
returned: number;
|
|
195
|
+
truncated: boolean;
|
|
196
|
+
};
|
|
197
|
+
examined: {
|
|
198
|
+
documents: number;
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export interface AuditTruncation {
|
|
203
|
+
findingsTruncated: boolean;
|
|
204
|
+
maxFindings: number;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export interface AuditTiming {
|
|
208
|
+
snapshotMs: number;
|
|
209
|
+
rulesMs: number;
|
|
210
|
+
totalMs: number;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface AuditVersions {
|
|
214
|
+
gno: string;
|
|
215
|
+
schema: typeof AUDIT_SCHEMA_VERSION;
|
|
216
|
+
ruleSet: typeof AUDIT_RULE_SET_VERSION;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface AuditReport {
|
|
220
|
+
schemaVersion: typeof AUDIT_SCHEMA_VERSION;
|
|
221
|
+
ruleSetVersion: typeof AUDIT_RULE_SET_VERSION;
|
|
222
|
+
status: AuditReportStatus;
|
|
223
|
+
scope: AuditScope;
|
|
224
|
+
capabilities: AuditCapabilitySnapshot;
|
|
225
|
+
fingerprints: AuditFingerprints;
|
|
226
|
+
versions: AuditVersions;
|
|
227
|
+
startedAt: string;
|
|
228
|
+
completedAt: string;
|
|
229
|
+
durationMs: number;
|
|
230
|
+
rules: AuditRuleResult[];
|
|
231
|
+
findings: AuditFinding[];
|
|
232
|
+
counts: AuditCounts;
|
|
233
|
+
truncation: AuditTruncation;
|
|
234
|
+
timing: AuditTiming;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
238
|
+
// Draft / evaluator contracts (category rules plug in later)
|
|
239
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
export interface AuditFindingDraft {
|
|
242
|
+
subject: string;
|
|
243
|
+
location?: string | null;
|
|
244
|
+
severity: AuditFindingSeverity;
|
|
245
|
+
message: string;
|
|
246
|
+
evidence: AuditEvidence[];
|
|
247
|
+
guidance?: string[];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export interface AuditRuleContribution {
|
|
251
|
+
ruleId: string;
|
|
252
|
+
category: AuditCategory;
|
|
253
|
+
status: AuditRuleStatus;
|
|
254
|
+
message: string;
|
|
255
|
+
findings?: AuditFindingDraft[];
|
|
256
|
+
/** Exact findings before any evaluator-side payload cap. */
|
|
257
|
+
findingCount?: number;
|
|
258
|
+
examinedCount?: number;
|
|
259
|
+
durationMs?: number;
|
|
260
|
+
skipReason?: string | null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export interface AuditRuleContext {
|
|
264
|
+
scope: AuditScope;
|
|
265
|
+
capabilities: AuditCapabilitySnapshot;
|
|
266
|
+
fingerprints: AuditFingerprints;
|
|
267
|
+
attempt: number;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export type AuditRuleEvaluator = (
|
|
271
|
+
ctx: AuditRuleContext
|
|
272
|
+
) =>
|
|
273
|
+
| AuditRuleContribution
|
|
274
|
+
| AuditRuleContribution[]
|
|
275
|
+
| Promise<AuditRuleContribution | AuditRuleContribution[]>;
|
|
276
|
+
|
|
277
|
+
export interface AuditFingerprintCapture {
|
|
278
|
+
(): AuditFingerprints | Promise<AuditFingerprints>;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export interface AuditRunInput {
|
|
282
|
+
scope: AuditScope;
|
|
283
|
+
capabilities: AuditCapabilitySnapshot;
|
|
284
|
+
captureFingerprints: AuditFingerprintCapture;
|
|
285
|
+
rules: readonly AuditRuleEvaluator[];
|
|
286
|
+
maxFindings?: number;
|
|
287
|
+
maxAttempts?: number;
|
|
288
|
+
gnoVersion?: string;
|
|
289
|
+
clock?: () => Date;
|
|
290
|
+
monotonicNow?: () => number;
|
|
291
|
+
signal?: AbortSignal;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export type AuditRunResult =
|
|
295
|
+
| { ok: true; report: AuditReport; exit: AuditExitKind }
|
|
296
|
+
| { ok: false; exit: "invalid"; error: string };
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/** Observable source/index freshness audit rules. */
|
|
2
|
+
|
|
3
|
+
import type { AuditFindingDraft, AuditRuleContribution } from "./audit";
|
|
4
|
+
|
|
5
|
+
import { compareAuditFindingDrafts } from "./audit";
|
|
6
|
+
|
|
7
|
+
export const FRESHNESS_AUDIT_MAX_FINDINGS_PER_RULE = 1000;
|
|
8
|
+
|
|
9
|
+
export interface AuditFreshnessDocument {
|
|
10
|
+
uri: string;
|
|
11
|
+
relPath: string;
|
|
12
|
+
contentType: string | null;
|
|
13
|
+
indexedSourceHash: string;
|
|
14
|
+
indexedSourceMtime: string;
|
|
15
|
+
indexedAt: string | null;
|
|
16
|
+
lastErrorCode: string | null;
|
|
17
|
+
source: {
|
|
18
|
+
state: "readable" | "missing" | "unreadable";
|
|
19
|
+
hash: string | null;
|
|
20
|
+
mtime: string | null;
|
|
21
|
+
/** Logical records use their physical container only for readability. */
|
|
22
|
+
byteComparable?: boolean;
|
|
23
|
+
changedDuringRead?: boolean;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface AuditAgePolicy {
|
|
28
|
+
maxAgeDays: number;
|
|
29
|
+
contentTypes?: readonly string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface AuditFreshnessOptions {
|
|
33
|
+
now: Date;
|
|
34
|
+
agePolicy?: AuditAgePolicy;
|
|
35
|
+
truncated?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const finding = (input: {
|
|
39
|
+
document: AuditFreshnessDocument;
|
|
40
|
+
kind: string;
|
|
41
|
+
message: string;
|
|
42
|
+
detail: Record<string, unknown>;
|
|
43
|
+
severity?: "error" | "warning" | "info";
|
|
44
|
+
guidance: string;
|
|
45
|
+
}): AuditFindingDraft => ({
|
|
46
|
+
subject: input.document.uri,
|
|
47
|
+
location: null,
|
|
48
|
+
severity: input.severity ?? "warning",
|
|
49
|
+
message: input.message,
|
|
50
|
+
evidence: [
|
|
51
|
+
{
|
|
52
|
+
kind: input.kind,
|
|
53
|
+
summary: input.message,
|
|
54
|
+
uri: input.document.uri,
|
|
55
|
+
path: input.document.relPath,
|
|
56
|
+
detail: JSON.stringify(input.detail),
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
guidance: [input.guidance],
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const rule = (input: {
|
|
63
|
+
ruleId: string;
|
|
64
|
+
findings: AuditFindingDraft[];
|
|
65
|
+
examinedCount: number;
|
|
66
|
+
unavailable?: boolean;
|
|
67
|
+
inconclusive?: boolean;
|
|
68
|
+
skipped?: boolean;
|
|
69
|
+
message: string;
|
|
70
|
+
reason?: string;
|
|
71
|
+
}): AuditRuleContribution => ({
|
|
72
|
+
ruleId: input.ruleId,
|
|
73
|
+
category: "freshness",
|
|
74
|
+
status: input.inconclusive
|
|
75
|
+
? "inconclusive"
|
|
76
|
+
: input.unavailable
|
|
77
|
+
? "unavailable"
|
|
78
|
+
: input.skipped
|
|
79
|
+
? "skip"
|
|
80
|
+
: input.findings.length > 0
|
|
81
|
+
? "fail"
|
|
82
|
+
: "pass",
|
|
83
|
+
message: input.message,
|
|
84
|
+
findings: [...input.findings]
|
|
85
|
+
.sort(compareAuditFindingDrafts)
|
|
86
|
+
.slice(0, FRESHNESS_AUDIT_MAX_FINDINGS_PER_RULE),
|
|
87
|
+
findingCount: input.findings.length,
|
|
88
|
+
examinedCount: input.examinedCount,
|
|
89
|
+
skipReason: input.reason ?? null,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
/** Age findings are policy signals only and do not claim factual incorrectness. */
|
|
93
|
+
export const evaluateFreshnessAudit = (
|
|
94
|
+
documents: readonly AuditFreshnessDocument[],
|
|
95
|
+
options: AuditFreshnessOptions
|
|
96
|
+
): AuditRuleContribution[] => {
|
|
97
|
+
const unavailable: AuditFindingDraft[] = [];
|
|
98
|
+
const drift: AuditFindingDraft[] = [];
|
|
99
|
+
const staleRevision: AuditFindingDraft[] = [];
|
|
100
|
+
const ageSignals: AuditFindingDraft[] = [];
|
|
101
|
+
let changedDuringRead = false;
|
|
102
|
+
const ageTypes = new Set(options.agePolicy?.contentTypes ?? []);
|
|
103
|
+
for (const document of documents) {
|
|
104
|
+
if (document.source.changedDuringRead) changedDuringRead = true;
|
|
105
|
+
if (document.source.state !== "readable") {
|
|
106
|
+
unavailable.push(
|
|
107
|
+
finding({
|
|
108
|
+
document,
|
|
109
|
+
kind: `source-${document.source.state}`,
|
|
110
|
+
message: `Source evidence is ${document.source.state}`,
|
|
111
|
+
detail: { state: document.source.state },
|
|
112
|
+
guidance: "Restore read access and re-run the audit",
|
|
113
|
+
})
|
|
114
|
+
);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (
|
|
118
|
+
document.source.byteComparable !== false &&
|
|
119
|
+
document.source.hash !== null &&
|
|
120
|
+
document.source.hash !== document.indexedSourceHash
|
|
121
|
+
) {
|
|
122
|
+
drift.push(
|
|
123
|
+
finding({
|
|
124
|
+
document,
|
|
125
|
+
kind: "source-index-hash-drift",
|
|
126
|
+
message: "Source bytes differ from the indexed revision",
|
|
127
|
+
detail: {
|
|
128
|
+
indexedHash: document.indexedSourceHash,
|
|
129
|
+
sourceHash: document.source.hash,
|
|
130
|
+
},
|
|
131
|
+
guidance: "Run gno index and inspect conversion errors",
|
|
132
|
+
})
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
if (!document.indexedAt || document.lastErrorCode) {
|
|
136
|
+
staleRevision.push(
|
|
137
|
+
finding({
|
|
138
|
+
document,
|
|
139
|
+
kind: "stale-indexed-revision",
|
|
140
|
+
message: document.lastErrorCode
|
|
141
|
+
? "The latest indexing attempt recorded an error"
|
|
142
|
+
: "The document has no indexed-at revision evidence",
|
|
143
|
+
detail: {
|
|
144
|
+
indexedAt: document.indexedAt,
|
|
145
|
+
lastErrorCode: document.lastErrorCode,
|
|
146
|
+
},
|
|
147
|
+
guidance: "Run gno index and resolve the reported ingest error",
|
|
148
|
+
})
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
if (
|
|
152
|
+
options.agePolicy &&
|
|
153
|
+
(ageTypes.size === 0 ||
|
|
154
|
+
(document.contentType !== null && ageTypes.has(document.contentType)))
|
|
155
|
+
) {
|
|
156
|
+
const timestamp = Date.parse(document.indexedSourceMtime);
|
|
157
|
+
const ageDays = (options.now.getTime() - timestamp) / 86_400_000;
|
|
158
|
+
if (Number.isFinite(ageDays) && ageDays > options.agePolicy.maxAgeDays) {
|
|
159
|
+
ageSignals.push(
|
|
160
|
+
finding({
|
|
161
|
+
document,
|
|
162
|
+
kind: "configured-age-signal",
|
|
163
|
+
message: `Source age exceeds the configured ${options.agePolicy.maxAgeDays}-day review signal`,
|
|
164
|
+
detail: {
|
|
165
|
+
ageDays: Math.floor(ageDays),
|
|
166
|
+
maxAgeDays: options.agePolicy.maxAgeDays,
|
|
167
|
+
},
|
|
168
|
+
severity: "info",
|
|
169
|
+
guidance:
|
|
170
|
+
"Review if useful; age is not evidence that the content is false",
|
|
171
|
+
})
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const truncated = options.truncated === true;
|
|
177
|
+
const inconclusive = truncated || changedDuringRead;
|
|
178
|
+
const byteComparableCount = documents.filter(
|
|
179
|
+
({ source }) => source.byteComparable !== false
|
|
180
|
+
).length;
|
|
181
|
+
const excludedLogicalRecords = documents.length - byteComparableCount;
|
|
182
|
+
const reason = changedDuringRead
|
|
183
|
+
? "source_changed_during_read"
|
|
184
|
+
: truncated
|
|
185
|
+
? "snapshot_truncated"
|
|
186
|
+
: undefined;
|
|
187
|
+
return [
|
|
188
|
+
rule({
|
|
189
|
+
ruleId: "freshness.source-readable",
|
|
190
|
+
findings: unavailable,
|
|
191
|
+
examinedCount: documents.length,
|
|
192
|
+
unavailable: unavailable.length > 0 && !inconclusive,
|
|
193
|
+
inconclusive,
|
|
194
|
+
message: inconclusive
|
|
195
|
+
? "Source readability evidence changed or was truncated"
|
|
196
|
+
: `${unavailable.length} sources are missing or unreadable`,
|
|
197
|
+
reason,
|
|
198
|
+
}),
|
|
199
|
+
rule({
|
|
200
|
+
ruleId: "freshness.source-index-drift",
|
|
201
|
+
findings: drift,
|
|
202
|
+
examinedCount: byteComparableCount,
|
|
203
|
+
inconclusive,
|
|
204
|
+
message: inconclusive
|
|
205
|
+
? "Source/index drift evidence changed or was truncated"
|
|
206
|
+
: `${drift.length} source revisions differ from the index${excludedLogicalRecords > 0 ? `; ${excludedLogicalRecords} logical records excluded from byte comparison` : ""}`,
|
|
207
|
+
reason,
|
|
208
|
+
}),
|
|
209
|
+
rule({
|
|
210
|
+
ruleId: "freshness.index-revision",
|
|
211
|
+
findings: staleRevision,
|
|
212
|
+
examinedCount: documents.length,
|
|
213
|
+
inconclusive,
|
|
214
|
+
message: inconclusive
|
|
215
|
+
? "Indexed revision evidence changed or was truncated"
|
|
216
|
+
: `${staleRevision.length} indexed revisions need attention`,
|
|
217
|
+
reason,
|
|
218
|
+
}),
|
|
219
|
+
rule({
|
|
220
|
+
ruleId: "freshness.configured-age-signal",
|
|
221
|
+
findings: ageSignals,
|
|
222
|
+
examinedCount: documents.length,
|
|
223
|
+
inconclusive,
|
|
224
|
+
skipped: !options.agePolicy,
|
|
225
|
+
message: !options.agePolicy
|
|
226
|
+
? "No age review policy was configured"
|
|
227
|
+
: `${ageSignals.length} configured age review signals`,
|
|
228
|
+
reason:
|
|
229
|
+
reason ??
|
|
230
|
+
(!options.agePolicy ? "age_policy_not_configured" : undefined),
|
|
231
|
+
}),
|
|
232
|
+
];
|
|
233
|
+
};
|