@bookedsolid/rea 0.2.1 → 0.3.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/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/index.js +17 -0
- package/dist/gateway/audit/rotator.d.ts +116 -0
- package/dist/gateway/audit/rotator.js +289 -0
- package/dist/gateway/middleware/audit.d.ts +14 -0
- package/dist/gateway/middleware/audit.js +77 -57
- package/dist/policy/loader.d.ts +34 -0
- package/dist/policy/loader.js +19 -0
- package/dist/policy/types.d.ts +24 -0
- package/package.json +3 -1
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/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
|
+
import { runAuditRotate, runAuditVerify } from './audit.js';
|
|
3
4
|
import { runCheck } from './check.js';
|
|
4
5
|
import { runDoctor } from './doctor.js';
|
|
5
6
|
import { runFreeze, runUnfreeze } from './freeze.js';
|
|
@@ -76,6 +77,22 @@ async function main() {
|
|
|
76
77
|
.action(() => {
|
|
77
78
|
runCheck();
|
|
78
79
|
});
|
|
80
|
+
const audit = program
|
|
81
|
+
.command('audit')
|
|
82
|
+
.description('Audit log operations — rotate and verify .rea/audit.jsonl (G1).');
|
|
83
|
+
audit
|
|
84
|
+
.command('rotate')
|
|
85
|
+
.description('Force-rotate .rea/audit.jsonl now. Preserves hash-chain via a marker record.')
|
|
86
|
+
.action(async () => {
|
|
87
|
+
await runAuditRotate({});
|
|
88
|
+
});
|
|
89
|
+
audit
|
|
90
|
+
.command('verify')
|
|
91
|
+
.description('Re-hash the audit chain; exit 0 on clean, 1 on the first tampered record.')
|
|
92
|
+
.option('--since <file>', 'verify starting at a rotated file (e.g. audit-YYYYMMDD-HHMMSS.jsonl), walking forward through the chain')
|
|
93
|
+
.action(async (opts) => {
|
|
94
|
+
await runAuditVerify({ ...(opts.since !== undefined ? { since: opts.since } : {}) });
|
|
95
|
+
});
|
|
79
96
|
program
|
|
80
97
|
.command('doctor')
|
|
81
98
|
.description('Validate the install: policy parses, .rea/ layout, hooks, Codex plugin.')
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit rotation (G1). Size- and age-based rotation for `.rea/audit.jsonl`
|
|
3
|
+
* that preserves hash-chain continuity across the rotation boundary.
|
|
4
|
+
*
|
|
5
|
+
* ## Triggers
|
|
6
|
+
*
|
|
7
|
+
* Rotation fires when EITHER threshold is crossed:
|
|
8
|
+
*
|
|
9
|
+
* - `max_bytes` — the current `audit.jsonl` is at or above this many bytes.
|
|
10
|
+
* Default when the policy block is present but `max_bytes` is unset:
|
|
11
|
+
* `DEFAULT_MAX_BYTES` (50 MiB).
|
|
12
|
+
* - `max_age_days` — the first record's `timestamp` is older than this many
|
|
13
|
+
* days. Default when unset: `DEFAULT_MAX_AGE_DAYS` (30).
|
|
14
|
+
*
|
|
15
|
+
* Back-compat: if the `audit.rotation` policy block is ABSENT entirely,
|
|
16
|
+
* rotation is DISABLED. Defaults only apply when the operator has opted in
|
|
17
|
+
* by declaring the block (even empty). This is deliberate — we do not want
|
|
18
|
+
* a 0.2.x install to observe new file-movement behavior on 0.3.0 upgrade
|
|
19
|
+
* without being asked.
|
|
20
|
+
*
|
|
21
|
+
* ## Rotation marker
|
|
22
|
+
*
|
|
23
|
+
* On rotation, the current file is renamed to `audit-YYYYMMDD-HHMMSS.jsonl`
|
|
24
|
+
* in the same directory. A fresh `audit.jsonl` is created containing EXACTLY
|
|
25
|
+
* one record: a rotation marker.
|
|
26
|
+
*
|
|
27
|
+
* tool_name: 'audit.rotation'
|
|
28
|
+
* server_name: 'rea'
|
|
29
|
+
* status: 'allowed'
|
|
30
|
+
* tier: 'read'
|
|
31
|
+
* autonomy_level: 'system'
|
|
32
|
+
* prev_hash: hash of the LAST record in the rotated file
|
|
33
|
+
* metadata.rotated_from: the rotated filename (basename)
|
|
34
|
+
* metadata.rotated_at: ISO-8601 instant of rotation
|
|
35
|
+
*
|
|
36
|
+
* The marker's `prev_hash` is the chain bridge — an operator verifying the
|
|
37
|
+
* chain with `rea audit verify --since <rotated-file>` walks rotated →
|
|
38
|
+
* marker → current and every transition must line up.
|
|
39
|
+
*
|
|
40
|
+
* ## Concurrency
|
|
41
|
+
*
|
|
42
|
+
* `maybeRotate` is called BEFORE the per-append lock is acquired. It takes
|
|
43
|
+
* its own short-lived lock on `.rea/` to perform the rename + marker write
|
|
44
|
+
* atomically. Callers that beat the rotator to the lock simply append to
|
|
45
|
+
* the (now fresh) file — correctness is preserved because the rotation
|
|
46
|
+
* marker is a legitimate chain anchor.
|
|
47
|
+
*/
|
|
48
|
+
import type { Policy, AuditRotationPolicy } from '../../policy/types.js';
|
|
49
|
+
/** 50 MiB. Only applied when the operator has declared `audit.rotation`. */
|
|
50
|
+
export declare const DEFAULT_MAX_BYTES: number;
|
|
51
|
+
/** 30 days. Only applied when the operator has declared `audit.rotation`. */
|
|
52
|
+
export declare const DEFAULT_MAX_AGE_DAYS = 30;
|
|
53
|
+
export declare const ROTATION_TOOL_NAME = "audit.rotation";
|
|
54
|
+
export declare const ROTATION_SERVER_NAME = "rea";
|
|
55
|
+
export interface RotationResult {
|
|
56
|
+
rotated: boolean;
|
|
57
|
+
/** Absolute path of the rotated file (the `audit-TIMESTAMP.jsonl` file). */
|
|
58
|
+
rotatedTo?: string;
|
|
59
|
+
}
|
|
60
|
+
/** Resolve effective thresholds from policy. `undefined` thresholds disable that trigger. */
|
|
61
|
+
interface EffectiveThresholds {
|
|
62
|
+
maxBytes: number | undefined;
|
|
63
|
+
maxAgeMs: number | undefined;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Compute the effective rotation thresholds from policy. If the operator has
|
|
67
|
+
* NOT declared an `audit.rotation` block, BOTH thresholds are undefined and
|
|
68
|
+
* rotation is disabled (back-compat with 0.2.x).
|
|
69
|
+
*
|
|
70
|
+
* If the block IS declared but individual knobs are missing, apply the
|
|
71
|
+
* documented defaults.
|
|
72
|
+
*/
|
|
73
|
+
declare function effectiveThresholds(policy: Policy | undefined): EffectiveThresholds;
|
|
74
|
+
/**
|
|
75
|
+
* Build the rotation timestamp filename. UTC for sortability.
|
|
76
|
+
* Format: `audit-YYYYMMDD-HHMMSS.jsonl`. Collisions (two rotations in the
|
|
77
|
+
* same second) are resolved by appending `-1`, `-2`, etc.
|
|
78
|
+
*/
|
|
79
|
+
export declare function rotationFilename(at: Date): string;
|
|
80
|
+
/**
|
|
81
|
+
* Decide whether the current audit file has crossed any rotation threshold.
|
|
82
|
+
* Exported for testing.
|
|
83
|
+
*/
|
|
84
|
+
export declare function shouldRotate(auditFile: string, thresholds: EffectiveThresholds, now?: Date): Promise<boolean>;
|
|
85
|
+
/**
|
|
86
|
+
* Perform the rotation unconditionally. Assumes the caller has already
|
|
87
|
+
* determined rotation is warranted and holds (or is about to acquire) any
|
|
88
|
+
* outer locks. `performRotation` takes its own lock on `.rea/` to make the
|
|
89
|
+
* rename + marker write atomic w.r.t. other append-path lockers.
|
|
90
|
+
*
|
|
91
|
+
* Returns `{ rotated: false }` if the audit file is empty or missing — an
|
|
92
|
+
* empty file is a no-op by design (see `rea audit rotate` empty-case).
|
|
93
|
+
*/
|
|
94
|
+
export declare function performRotation(auditFile: string, now?: Date): Promise<RotationResult>;
|
|
95
|
+
/**
|
|
96
|
+
* Called by the append path BEFORE acquiring its own lock. Cheap when no
|
|
97
|
+
* rotation is due (one stat, maybe one 64 KiB read for age check); idempotent
|
|
98
|
+
* when rotation IS due (performRotation re-checks under the lock).
|
|
99
|
+
*
|
|
100
|
+
* Never throws. On any error, logs to stderr and returns `rotated: false`
|
|
101
|
+
* — a broken rotator must NOT break the audit append.
|
|
102
|
+
*/
|
|
103
|
+
export declare function maybeRotate(auditFile: string, policy: Policy | undefined, now?: Date): Promise<RotationResult>;
|
|
104
|
+
/**
|
|
105
|
+
* CLI-invoked force rotation (`rea audit rotate`). Unlike `maybeRotate` this
|
|
106
|
+
* DOES ignore thresholds — the operator asked explicitly — but empty files
|
|
107
|
+
* are still a no-op because rotating an empty chain produces a marker with
|
|
108
|
+
* no predecessor.
|
|
109
|
+
*/
|
|
110
|
+
export declare function forceRotate(auditFile: string, now?: Date): Promise<RotationResult>;
|
|
111
|
+
/**
|
|
112
|
+
* Exposed for tests/callers that already know the policy shape. Tests that
|
|
113
|
+
* want to stub thresholds can call `performRotation` directly.
|
|
114
|
+
*/
|
|
115
|
+
export { effectiveThresholds as _effectiveThresholds };
|
|
116
|
+
export type { EffectiveThresholds, AuditRotationPolicy };
|