@bookedsolid/rea 0.2.1 → 0.4.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 (65) hide show
  1. package/.husky/pre-push +15 -18
  2. package/README.md +41 -1
  3. package/THREAT_MODEL.md +100 -29
  4. package/dist/audit/append.d.ts +21 -8
  5. package/dist/audit/append.js +48 -83
  6. package/dist/audit/fs.d.ts +68 -0
  7. package/dist/audit/fs.js +171 -0
  8. package/dist/cli/audit.d.ts +40 -0
  9. package/dist/cli/audit.js +205 -0
  10. package/dist/cli/doctor.d.ts +19 -4
  11. package/dist/cli/doctor.js +172 -5
  12. package/dist/cli/index.js +26 -1
  13. package/dist/cli/init.js +93 -7
  14. package/dist/cli/install/pre-push.d.ts +335 -0
  15. package/dist/cli/install/pre-push.js +2818 -0
  16. package/dist/cli/serve.d.ts +64 -0
  17. package/dist/cli/serve.js +270 -2
  18. package/dist/cli/status.d.ts +90 -0
  19. package/dist/cli/status.js +399 -0
  20. package/dist/cli/utils.d.ts +4 -0
  21. package/dist/cli/utils.js +4 -0
  22. package/dist/gateway/audit/rotator.d.ts +116 -0
  23. package/dist/gateway/audit/rotator.js +289 -0
  24. package/dist/gateway/circuit-breaker.d.ts +17 -0
  25. package/dist/gateway/circuit-breaker.js +32 -3
  26. package/dist/gateway/downstream-pool.d.ts +2 -1
  27. package/dist/gateway/downstream-pool.js +2 -2
  28. package/dist/gateway/downstream.d.ts +39 -3
  29. package/dist/gateway/downstream.js +73 -14
  30. package/dist/gateway/log.d.ts +122 -0
  31. package/dist/gateway/log.js +334 -0
  32. package/dist/gateway/middleware/audit.d.ts +24 -1
  33. package/dist/gateway/middleware/audit.js +103 -58
  34. package/dist/gateway/middleware/blocked-paths.d.ts +0 -9
  35. package/dist/gateway/middleware/blocked-paths.js +439 -67
  36. package/dist/gateway/middleware/injection.d.ts +218 -13
  37. package/dist/gateway/middleware/injection.js +433 -51
  38. package/dist/gateway/middleware/kill-switch.d.ts +10 -1
  39. package/dist/gateway/middleware/kill-switch.js +20 -1
  40. package/dist/gateway/observability/metrics.d.ts +125 -0
  41. package/dist/gateway/observability/metrics.js +321 -0
  42. package/dist/gateway/server.d.ts +19 -0
  43. package/dist/gateway/server.js +99 -15
  44. package/dist/policy/loader.d.ts +47 -0
  45. package/dist/policy/loader.js +47 -0
  46. package/dist/policy/profiles.d.ts +13 -0
  47. package/dist/policy/profiles.js +12 -0
  48. package/dist/policy/types.d.ts +52 -0
  49. package/dist/registry/fingerprint.d.ts +73 -0
  50. package/dist/registry/fingerprint.js +81 -0
  51. package/dist/registry/fingerprints-store.d.ts +62 -0
  52. package/dist/registry/fingerprints-store.js +111 -0
  53. package/dist/registry/interpolate.d.ts +58 -0
  54. package/dist/registry/interpolate.js +121 -0
  55. package/dist/registry/loader.d.ts +2 -2
  56. package/dist/registry/loader.js +22 -1
  57. package/dist/registry/tofu-gate.d.ts +41 -0
  58. package/dist/registry/tofu-gate.js +189 -0
  59. package/dist/registry/tofu.d.ts +111 -0
  60. package/dist/registry/tofu.js +173 -0
  61. package/dist/registry/types.d.ts +9 -1
  62. package/package.json +3 -1
  63. package/profiles/bst-internal-no-codex.yaml +5 -0
  64. package/profiles/bst-internal.yaml +7 -0
  65. package/scripts/tarball-smoke.sh +197 -0
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Shared audit filesystem primitives (G1). Both the gateway audit middleware
3
+ * (`src/gateway/middleware/audit.ts`) and the public append helper
4
+ * (`src/audit/append.ts`) funnel through this module so locking, partial-write
5
+ * recovery, and rotation semantics stay in lockstep.
6
+ *
7
+ * ## Locking
8
+ *
9
+ * Every append acquires a `proper-lockfile` lock on the audit file's parent
10
+ * directory (`.rea/`) — NOT on `audit.jsonl` directly, because `proper-lockfile`
11
+ * refuses to lock a file that does not yet exist. The lock is taken BEFORE the
12
+ * read-last-record → compute-hash → append → fsync sequence, so two processes
13
+ * on the same filesystem can append concurrently without interleaving.
14
+ *
15
+ * Stale-lock detection: `proper-lockfile` handles `EEXIST` with `stale: 10000`
16
+ * (10s). A crashed writer that leaves a stale lockfile frees itself on the
17
+ * next append attempt.
18
+ *
19
+ * ## Partial-write recovery
20
+ *
21
+ * An append that crashes mid-write leaves a trailing line WITHOUT a newline.
22
+ * `readLastRecord()` detects this signal (file doesn't end with `\n`) and
23
+ * truncates the partial line before returning the previous record's hash.
24
+ * This recovery is idempotent and runs on every read.
25
+ *
26
+ * ## Locking contract
27
+ *
28
+ * Callers invoke `withAuditLock(auditFile, async () => { ... })`. The callback
29
+ * MUST perform its read → compute → append → fsync inside the lock scope. On
30
+ * lock acquisition failure the callback does NOT run — the caller receives
31
+ * the error and decides whether to fall back (middleware logs and continues;
32
+ * the public helper propagates).
33
+ */
34
+ import type { AuditRecord } from '../gateway/middleware/audit-types.js';
35
+ export declare const GENESIS_HASH: string;
36
+ /**
37
+ * Acquire an exclusive lock on the audit file's parent directory and run
38
+ * `fn` inside it. The parent directory must exist before calling this.
39
+ *
40
+ * The lock is released even if `fn` throws. Lock-acquisition failures
41
+ * surface as the caller's rejection — middleware catches and logs, the
42
+ * public helper propagates.
43
+ */
44
+ export declare function withAuditLock<T>(auditFile: string, fn: () => Promise<T>): Promise<T>;
45
+ export declare function computeHash(record: Omit<AuditRecord, 'hash'>): string;
46
+ /**
47
+ * Read the last complete JSON record from the audit file. Returns the parsed
48
+ * record plus its hash (the value a new append should use for `prev_hash`).
49
+ *
50
+ * Recovers from three tail states:
51
+ * - File does not exist → genesis.
52
+ * - File exists but is empty (or only whitespace) → genesis.
53
+ * - File tail does not end in `\n` → treat the trailing partial line as a
54
+ * crash signal, truncate it, and return the record before it.
55
+ *
56
+ * Never throws on read-side issues except raw I/O errors (permission, ENOSPC,
57
+ * etc.) that the caller should surface.
58
+ */
59
+ export declare function readLastRecord(auditFile: string): Promise<{
60
+ record: AuditRecord | null;
61
+ hash: string;
62
+ }>;
63
+ /**
64
+ * Open-and-fsync the audit file. Called after an append to flush the write
65
+ * to durable storage. fsync failure is not fatal — the append itself
66
+ * already succeeded; durability is best-effort.
67
+ */
68
+ export declare function fsyncFile(filePath: string): Promise<void>;
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Shared audit filesystem primitives (G1). Both the gateway audit middleware
3
+ * (`src/gateway/middleware/audit.ts`) and the public append helper
4
+ * (`src/audit/append.ts`) funnel through this module so locking, partial-write
5
+ * recovery, and rotation semantics stay in lockstep.
6
+ *
7
+ * ## Locking
8
+ *
9
+ * Every append acquires a `proper-lockfile` lock on the audit file's parent
10
+ * directory (`.rea/`) — NOT on `audit.jsonl` directly, because `proper-lockfile`
11
+ * refuses to lock a file that does not yet exist. The lock is taken BEFORE the
12
+ * read-last-record → compute-hash → append → fsync sequence, so two processes
13
+ * on the same filesystem can append concurrently without interleaving.
14
+ *
15
+ * Stale-lock detection: `proper-lockfile` handles `EEXIST` with `stale: 10000`
16
+ * (10s). A crashed writer that leaves a stale lockfile frees itself on the
17
+ * next append attempt.
18
+ *
19
+ * ## Partial-write recovery
20
+ *
21
+ * An append that crashes mid-write leaves a trailing line WITHOUT a newline.
22
+ * `readLastRecord()` detects this signal (file doesn't end with `\n`) and
23
+ * truncates the partial line before returning the previous record's hash.
24
+ * This recovery is idempotent and runs on every read.
25
+ *
26
+ * ## Locking contract
27
+ *
28
+ * Callers invoke `withAuditLock(auditFile, async () => { ... })`. The callback
29
+ * MUST perform its read → compute → append → fsync inside the lock scope. On
30
+ * lock acquisition failure the callback does NOT run — the caller receives
31
+ * the error and decides whether to fall back (middleware logs and continues;
32
+ * the public helper propagates).
33
+ */
34
+ import fs from 'node:fs/promises';
35
+ import path from 'node:path';
36
+ import crypto from 'node:crypto';
37
+ import properLockfile from 'proper-lockfile';
38
+ export const GENESIS_HASH = '0'.repeat(64);
39
+ /**
40
+ * Lock-file retry envelope. `stale: 10000` lets a crashed holder's lock be
41
+ * reclaimed after 10s of inactivity. Retries are generous because individual
42
+ * appends are cheap (single-digit milliseconds) and under heavy cross-process
43
+ * contention we would rather wait than surface EEXIST to the caller. The
44
+ * budget is bounded (`retries: 40`, max ~300ms per retry) so a truly
45
+ * compromised lockfile still surfaces quickly.
46
+ */
47
+ const LOCK_OPTIONS = {
48
+ stale: 10_000,
49
+ retries: {
50
+ retries: 40,
51
+ factor: 1.3,
52
+ minTimeout: 15,
53
+ maxTimeout: 300,
54
+ randomize: true,
55
+ },
56
+ // Lock the parent directory (the audit file may not exist yet on first
57
+ // write). proper-lockfile's `realpath: false` avoids a symlink check that
58
+ // fails when the file itself hasn't been created.
59
+ realpath: false,
60
+ };
61
+ /**
62
+ * Acquire an exclusive lock on the audit file's parent directory and run
63
+ * `fn` inside it. The parent directory must exist before calling this.
64
+ *
65
+ * The lock is released even if `fn` throws. Lock-acquisition failures
66
+ * surface as the caller's rejection — middleware catches and logs, the
67
+ * public helper propagates.
68
+ */
69
+ export async function withAuditLock(auditFile, fn) {
70
+ const lockTarget = path.dirname(auditFile);
71
+ const release = await properLockfile.lock(lockTarget, LOCK_OPTIONS);
72
+ try {
73
+ return await fn();
74
+ }
75
+ finally {
76
+ try {
77
+ await release();
78
+ }
79
+ catch {
80
+ // Releasing a lock can fail if the lockfile was already cleaned up
81
+ // by stale-detection. That's not a correctness problem for the caller
82
+ // — the work already completed. Swallow.
83
+ }
84
+ }
85
+ }
86
+ export function computeHash(record) {
87
+ return crypto.createHash('sha256').update(JSON.stringify(record)).digest('hex');
88
+ }
89
+ /**
90
+ * Read the last complete JSON record from the audit file. Returns the parsed
91
+ * record plus its hash (the value a new append should use for `prev_hash`).
92
+ *
93
+ * Recovers from three tail states:
94
+ * - File does not exist → genesis.
95
+ * - File exists but is empty (or only whitespace) → genesis.
96
+ * - File tail does not end in `\n` → treat the trailing partial line as a
97
+ * crash signal, truncate it, and return the record before it.
98
+ *
99
+ * Never throws on read-side issues except raw I/O errors (permission, ENOSPC,
100
+ * etc.) that the caller should surface.
101
+ */
102
+ export async function readLastRecord(auditFile) {
103
+ let data;
104
+ try {
105
+ data = await fs.readFile(auditFile, 'utf8');
106
+ }
107
+ catch (err) {
108
+ if (err.code === 'ENOENT') {
109
+ return { record: null, hash: GENESIS_HASH };
110
+ }
111
+ throw err;
112
+ }
113
+ if (data.length === 0) {
114
+ return { record: null, hash: GENESIS_HASH };
115
+ }
116
+ // Partial-write recovery: a crash mid-append leaves the file without a
117
+ // trailing newline. Truncate the unterminated tail before consulting the
118
+ // chain. This is the only way a partial line can reach disk — every clean
119
+ // append writes `JSON.stringify(record) + '\n'`.
120
+ const endsWithNewline = data.endsWith('\n');
121
+ if (!endsWithNewline) {
122
+ const lastNewline = data.lastIndexOf('\n');
123
+ if (lastNewline === -1) {
124
+ // Whole file is a partial write — truncate to empty.
125
+ await fs.truncate(auditFile, 0);
126
+ return { record: null, hash: GENESIS_HASH };
127
+ }
128
+ // Keep everything through the last newline (inclusive); drop the partial
129
+ // tail. +1 to include the newline itself.
130
+ const keepLength = Buffer.byteLength(data.slice(0, lastNewline + 1), 'utf8');
131
+ await fs.truncate(auditFile, keepLength);
132
+ data = data.slice(0, lastNewline + 1);
133
+ }
134
+ const trimmed = data.replace(/\n+$/, '');
135
+ if (trimmed.length === 0) {
136
+ return { record: null, hash: GENESIS_HASH };
137
+ }
138
+ const lastNewline = trimmed.lastIndexOf('\n');
139
+ const lastLine = lastNewline === -1 ? trimmed : trimmed.slice(lastNewline + 1);
140
+ try {
141
+ const parsed = JSON.parse(lastLine);
142
+ if (typeof parsed.hash === 'string' && parsed.hash.length === 64) {
143
+ return { record: parsed, hash: parsed.hash };
144
+ }
145
+ }
146
+ catch {
147
+ // Corrupt tail — fall through. We do NOT throw: refusing to append would
148
+ // mask every subsequent event. The chain-verify command (`rea audit
149
+ // verify`) will flag the break point for the operator.
150
+ }
151
+ return { record: null, hash: GENESIS_HASH };
152
+ }
153
+ /**
154
+ * Open-and-fsync the audit file. Called after an append to flush the write
155
+ * to durable storage. fsync failure is not fatal — the append itself
156
+ * already succeeded; durability is best-effort.
157
+ */
158
+ export async function fsyncFile(filePath) {
159
+ let fh;
160
+ try {
161
+ fh = await fs.open(filePath, 'r');
162
+ await fh.sync();
163
+ }
164
+ catch {
165
+ // fsync failure is not fatal — the write itself already succeeded.
166
+ }
167
+ finally {
168
+ if (fh)
169
+ await fh.close();
170
+ }
171
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * `rea audit` — operator-facing subcommands for the durability story (G1).
3
+ *
4
+ * Two verbs:
5
+ * - `rotate` force-rotate the current `.rea/audit.jsonl`.
6
+ * - `verify [--since <file>]` re-hash the chain and exit 0 on clean, 1
7
+ * naming the first tampered record.
8
+ *
9
+ * Neither command reads policy defaults for thresholds — force rotation is
10
+ * explicit by definition, and verify operates on existing files regardless
11
+ * of policy.
12
+ */
13
+ /**
14
+ * Reserved for future rotate knobs (e.g. `--retain N` to prune old rotated
15
+ * files). Empty today — kept as a typed record so the call site's option
16
+ * object stays self-documenting.
17
+ */
18
+ export type AuditRotateOptions = Record<string, never>;
19
+ export interface AuditVerifyOptions {
20
+ /**
21
+ * Optional rotated-file basename (e.g. `audit-20260418-193200.jsonl`).
22
+ * When set, verification walks forward through all rotated files in
23
+ * timestamp order starting at this one, then through the current
24
+ * `audit.jsonl`. When unset, verification runs over the current file
25
+ * only.
26
+ */
27
+ since?: string | undefined;
28
+ }
29
+ /**
30
+ * `rea audit rotate`. Forces a rotation now regardless of thresholds.
31
+ * Empty audit files are a no-op — rotating an empty chain would produce a
32
+ * rotation marker with no meaningful predecessor.
33
+ */
34
+ export declare function runAuditRotate(_options: AuditRotateOptions): Promise<void>;
35
+ /**
36
+ * `rea audit verify [--since <rotated-file>]`. Exits 0 on clean chain, 1 on
37
+ * first tampered record. All diagnostic output goes to stderr so the
38
+ * exit code is the primary signal.
39
+ */
40
+ export declare function runAuditVerify(options: AuditVerifyOptions): Promise<void>;
@@ -0,0 +1,205 @@
1
+ /**
2
+ * `rea audit` — operator-facing subcommands for the durability story (G1).
3
+ *
4
+ * Two verbs:
5
+ * - `rotate` force-rotate the current `.rea/audit.jsonl`.
6
+ * - `verify [--since <file>]` re-hash the chain and exit 0 on clean, 1
7
+ * naming the first tampered record.
8
+ *
9
+ * Neither command reads policy defaults for thresholds — force rotation is
10
+ * explicit by definition, and verify operates on existing files regardless
11
+ * of policy.
12
+ */
13
+ import fs from 'node:fs/promises';
14
+ import path from 'node:path';
15
+ import { forceRotate } from '../gateway/audit/rotator.js';
16
+ import { computeHash, GENESIS_HASH } from '../audit/fs.js';
17
+ import { AUDIT_FILE, REA_DIR, err, log, reaPath } from './utils.js';
18
+ /**
19
+ * `rea audit rotate`. Forces a rotation now regardless of thresholds.
20
+ * Empty audit files are a no-op — rotating an empty chain would produce a
21
+ * rotation marker with no meaningful predecessor.
22
+ */
23
+ export async function runAuditRotate(_options) {
24
+ const baseDir = process.cwd();
25
+ const auditFile = reaPath(baseDir, AUDIT_FILE);
26
+ let exists = true;
27
+ try {
28
+ const stat = await fs.stat(auditFile);
29
+ if (!stat.isFile() || stat.size === 0)
30
+ exists = false;
31
+ }
32
+ catch (e) {
33
+ if (e.code === 'ENOENT') {
34
+ exists = false;
35
+ }
36
+ else {
37
+ err(`Cannot stat ${auditFile}: ${e.message}`);
38
+ process.exit(1);
39
+ }
40
+ }
41
+ if (!exists) {
42
+ log('Audit log empty or missing — nothing to rotate.');
43
+ console.log(` File: ${path.relative(baseDir, auditFile)}`);
44
+ return;
45
+ }
46
+ const result = await forceRotate(auditFile);
47
+ if (!result.rotated) {
48
+ log('Audit log empty — nothing to rotate.');
49
+ return;
50
+ }
51
+ const rotated = result.rotatedTo;
52
+ log('Audit log rotated.');
53
+ console.log(` Rotated to: ${path.relative(baseDir, rotated)}`);
54
+ console.log(` Fresh file: ${path.relative(baseDir, auditFile)}`);
55
+ console.log(` A rotation marker anchors the new chain on the old tail's hash.`);
56
+ }
57
+ /**
58
+ * Load a JSONL audit file as a record array + per-line raw text, so we can
59
+ * re-hash against the exact serialization that was written. Throws on read
60
+ * errors; returns an empty array for an empty file.
61
+ */
62
+ async function loadRecords(filePath) {
63
+ const raw = await fs.readFile(filePath, 'utf8');
64
+ // Drop a single trailing newline but preserve blank lines inside the file
65
+ // so index numbers line up with real record positions.
66
+ const trimmedTail = raw.replace(/\n$/, '');
67
+ if (trimmedTail.length === 0)
68
+ return { records: [], rawLines: [] };
69
+ const rawLines = trimmedTail.split('\n');
70
+ const records = rawLines.map((line, i) => {
71
+ try {
72
+ return JSON.parse(line);
73
+ }
74
+ catch (e) {
75
+ throw new Error(`Cannot parse JSON at ${path.basename(filePath)} line ${i + 1}: ${e.message}`);
76
+ }
77
+ });
78
+ return { records, rawLines };
79
+ }
80
+ function verifyChain(fileBasename, records, expectedStartPrev) {
81
+ let prev = expectedStartPrev;
82
+ for (let i = 0; i < records.length; i++) {
83
+ const r = records[i];
84
+ if (r.prev_hash !== prev) {
85
+ return {
86
+ file: fileBasename,
87
+ lineIndex: i,
88
+ reason: 'prev_hash does not match previous record',
89
+ expected: prev,
90
+ actual: r.prev_hash,
91
+ };
92
+ }
93
+ // Recompute hash across the canonical serialization of the record
94
+ // minus the `hash` field.
95
+ const { hash, ...rest } = r;
96
+ const recomputed = computeHash(rest);
97
+ if (recomputed !== hash) {
98
+ return {
99
+ file: fileBasename,
100
+ lineIndex: i,
101
+ reason: 'stored hash does not match recomputed hash over record body',
102
+ expected: recomputed,
103
+ actual: hash,
104
+ };
105
+ }
106
+ prev = hash;
107
+ }
108
+ return null;
109
+ }
110
+ /**
111
+ * Find all rotated audit files in `reaDir`, in timestamp-ascending order.
112
+ * Filenames follow `audit-YYYYMMDD-HHMMSS.jsonl` (with optional `-N` suffix
113
+ * for intra-second collisions). Lexicographic sort handles both cases.
114
+ */
115
+ async function listRotatedFiles(reaDir) {
116
+ let entries;
117
+ try {
118
+ entries = await fs.readdir(reaDir);
119
+ }
120
+ catch {
121
+ return [];
122
+ }
123
+ const rotated = entries.filter((n) => /^audit-\d{8}-\d{6}(-\d+)?\.jsonl$/.test(n));
124
+ rotated.sort();
125
+ return rotated;
126
+ }
127
+ /**
128
+ * `rea audit verify [--since <rotated-file>]`. Exits 0 on clean chain, 1 on
129
+ * first tampered record. All diagnostic output goes to stderr so the
130
+ * exit code is the primary signal.
131
+ */
132
+ export async function runAuditVerify(options) {
133
+ const baseDir = process.cwd();
134
+ const reaDir = path.join(baseDir, REA_DIR);
135
+ const currentAudit = path.join(reaDir, AUDIT_FILE);
136
+ // Assemble the file walk.
137
+ const filesToVerify = [];
138
+ if (options.since !== undefined && options.since.length > 0) {
139
+ const sinceName = path.basename(options.since);
140
+ if (!/^audit-\d{8}-\d{6}(-\d+)?\.jsonl$/.test(sinceName)) {
141
+ err(`--since must name a rotated audit file (audit-YYYYMMDD-HHMMSS.jsonl); got ${JSON.stringify(options.since)}`);
142
+ process.exit(1);
143
+ }
144
+ const allRotated = await listRotatedFiles(reaDir);
145
+ const startIdx = allRotated.indexOf(sinceName);
146
+ if (startIdx === -1) {
147
+ err(`Rotated file not found: ${path.join(REA_DIR, sinceName)}`);
148
+ process.exit(1);
149
+ }
150
+ for (const name of allRotated.slice(startIdx)) {
151
+ filesToVerify.push(path.join(reaDir, name));
152
+ }
153
+ }
154
+ // The current audit.jsonl is ALWAYS the tail of the walk (unless it
155
+ // doesn't exist — then the caller either asked for --since only or has
156
+ // a fresh install).
157
+ try {
158
+ const stat = await fs.stat(currentAudit);
159
+ if (stat.isFile())
160
+ filesToVerify.push(currentAudit);
161
+ }
162
+ catch (e) {
163
+ if (e.code !== 'ENOENT') {
164
+ err(`Cannot stat ${currentAudit}: ${e.message}`);
165
+ process.exit(1);
166
+ }
167
+ }
168
+ if (filesToVerify.length === 0) {
169
+ err('No audit files to verify.');
170
+ console.error(` Expected: ${path.relative(baseDir, currentAudit)}`);
171
+ process.exit(1);
172
+ }
173
+ let expectedPrev = GENESIS_HASH;
174
+ let totalRecords = 0;
175
+ for (const filePath of filesToVerify) {
176
+ let records;
177
+ try {
178
+ ({ records } = await loadRecords(filePath));
179
+ }
180
+ catch (e) {
181
+ err(`${e.message}`);
182
+ process.exit(1);
183
+ }
184
+ const basename = path.basename(filePath);
185
+ const failure = verifyChain(basename, records, expectedPrev);
186
+ if (failure !== null) {
187
+ err(`Audit chain TAMPER DETECTED in ${failure.file}`);
188
+ console.error(` Record index: ${failure.lineIndex} (0-based within file)`);
189
+ console.error(` Reason: ${failure.reason}`);
190
+ if (failure.expected !== undefined) {
191
+ console.error(` Expected: ${failure.expected}`);
192
+ }
193
+ if (failure.actual !== undefined) {
194
+ console.error(` Actual: ${failure.actual}`);
195
+ }
196
+ process.exit(1);
197
+ }
198
+ // Advance the cross-file anchor for the next file.
199
+ if (records.length > 0) {
200
+ expectedPrev = records[records.length - 1].hash;
201
+ }
202
+ totalRecords += records.length;
203
+ }
204
+ log(`Audit chain verified: ${totalRecords} records across ${filesToVerify.length} file(s) — clean.`);
205
+ }
@@ -1,4 +1,5 @@
1
1
  import { type CodexProbeState } from '../gateway/observability/codex-probe.js';
2
+ import { type PrePushDoctorState } from './install/pre-push.js';
2
3
  export interface CheckResult {
3
4
  label: string;
4
5
  /**
@@ -9,6 +10,15 @@ export interface CheckResult {
9
10
  status: 'pass' | 'fail' | 'warn' | 'info';
10
11
  detail?: string;
11
12
  }
13
+ /**
14
+ * G7: report the TOFU fingerprint-store state. Pass = every enabled server
15
+ * in the registry has a matching stored fingerprint. Warn = at least one
16
+ * server would be first-seen or drifted at next `rea serve`. Info = no
17
+ * enabled servers (nothing to fingerprint). Fail only for unreadable store.
18
+ *
19
+ * Exported so tests can drive this without spinning up the full `runDoctor`.
20
+ */
21
+ export declare function checkFingerprintStore(baseDir: string): Promise<CheckResult>;
12
22
  /**
13
23
  * Translate a `CodexProbeState` into two doctor CheckResults: one for
14
24
  * responsiveness (pass/warn) and one informational line about the last
@@ -22,11 +32,16 @@ export declare function checksFromProbeState(state: CodexProbeState): CheckResul
22
32
  * `runDoctor`.
23
33
  *
24
34
  * `codexProbeState` is consulted ONLY when Codex is required by policy.
25
- * Callers that already have a fresh probe state (e.g. `runDoctor`) should
26
- * pass it; callers that don't (e.g. unit tests of the existing doctor
27
- * surface) can omit it and the probe-derived fields are skipped.
35
+ * `prePushState` is the pre-computed G6 pre-push inspection; when omitted
36
+ * the pre-push check is skipped entirely (older call sites that don't yet
37
+ * thread the state through keep working without behavioural change).
38
+ * Callers that already have fresh state (e.g. `runDoctor`) should pass
39
+ * both; callers that don't (e.g. unit tests of the existing doctor
40
+ * surface) can omit them and those checks are skipped.
41
+ *
42
+ * `activeForeign` always yields `fail` — a foreign hook bypassing the gate is a hard governance gap.
28
43
  */
29
- export declare function collectChecks(baseDir: string, codexProbeState?: CodexProbeState): CheckResult[];
44
+ export declare function collectChecks(baseDir: string, codexProbeState?: CodexProbeState, prePushState?: PrePushDoctorState): CheckResult[];
30
45
  export interface RunDoctorOptions {
31
46
  /** When true, print a 7-day telemetry summary after the checks (G11.5). */
32
47
  metrics?: boolean;