@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.
- package/.husky/pre-push +15 -18
- package/README.md +41 -1
- package/THREAT_MODEL.md +100 -29
- package/dist/audit/append.d.ts +21 -8
- package/dist/audit/append.js +48 -83
- package/dist/audit/fs.d.ts +68 -0
- package/dist/audit/fs.js +171 -0
- package/dist/cli/audit.d.ts +40 -0
- package/dist/cli/audit.js +205 -0
- package/dist/cli/doctor.d.ts +19 -4
- package/dist/cli/doctor.js +172 -5
- package/dist/cli/index.js +26 -1
- package/dist/cli/init.js +93 -7
- package/dist/cli/install/pre-push.d.ts +335 -0
- package/dist/cli/install/pre-push.js +2818 -0
- package/dist/cli/serve.d.ts +64 -0
- package/dist/cli/serve.js +270 -2
- package/dist/cli/status.d.ts +90 -0
- package/dist/cli/status.js +399 -0
- package/dist/cli/utils.d.ts +4 -0
- package/dist/cli/utils.js +4 -0
- package/dist/gateway/audit/rotator.d.ts +116 -0
- package/dist/gateway/audit/rotator.js +289 -0
- package/dist/gateway/circuit-breaker.d.ts +17 -0
- package/dist/gateway/circuit-breaker.js +32 -3
- package/dist/gateway/downstream-pool.d.ts +2 -1
- package/dist/gateway/downstream-pool.js +2 -2
- package/dist/gateway/downstream.d.ts +39 -3
- package/dist/gateway/downstream.js +73 -14
- package/dist/gateway/log.d.ts +122 -0
- package/dist/gateway/log.js +334 -0
- package/dist/gateway/middleware/audit.d.ts +24 -1
- package/dist/gateway/middleware/audit.js +103 -58
- package/dist/gateway/middleware/blocked-paths.d.ts +0 -9
- package/dist/gateway/middleware/blocked-paths.js +439 -67
- package/dist/gateway/middleware/injection.d.ts +218 -13
- package/dist/gateway/middleware/injection.js +433 -51
- package/dist/gateway/middleware/kill-switch.d.ts +10 -1
- package/dist/gateway/middleware/kill-switch.js +20 -1
- package/dist/gateway/observability/metrics.d.ts +125 -0
- package/dist/gateway/observability/metrics.js +321 -0
- package/dist/gateway/server.d.ts +19 -0
- package/dist/gateway/server.js +99 -15
- package/dist/policy/loader.d.ts +47 -0
- package/dist/policy/loader.js +47 -0
- package/dist/policy/profiles.d.ts +13 -0
- package/dist/policy/profiles.js +12 -0
- package/dist/policy/types.d.ts +52 -0
- package/dist/registry/fingerprint.d.ts +73 -0
- package/dist/registry/fingerprint.js +81 -0
- package/dist/registry/fingerprints-store.d.ts +62 -0
- package/dist/registry/fingerprints-store.js +111 -0
- package/dist/registry/interpolate.d.ts +58 -0
- package/dist/registry/interpolate.js +121 -0
- package/dist/registry/loader.d.ts +2 -2
- package/dist/registry/loader.js +22 -1
- package/dist/registry/tofu-gate.d.ts +41 -0
- package/dist/registry/tofu-gate.js +189 -0
- package/dist/registry/tofu.d.ts +111 -0
- package/dist/registry/tofu.js +173 -0
- package/dist/registry/types.d.ts +9 -1
- package/package.json +3 -1
- package/profiles/bst-internal-no-codex.yaml +5 -0
- package/profiles/bst-internal.yaml +7 -0
- 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>;
|
package/dist/audit/fs.js
ADDED
|
@@ -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
|
+
}
|
package/dist/cli/doctor.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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;
|