@bookedsolid/rea 0.40.0 → 0.42.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/MIGRATING.md +139 -0
- package/README.md +153 -36
- package/dist/cli/audit-summary.d.ts +160 -0
- package/dist/cli/audit-summary.js +535 -0
- package/dist/cli/doctor.d.ts +44 -4
- package/dist/cli/doctor.js +141 -37
- package/dist/cli/index.js +33 -0
- package/dist/cli/install/gitignore.d.ts +23 -1
- package/dist/cli/install/gitignore.js +33 -6
- package/dist/cli/install/unified-diff.d.ts +78 -0
- package/dist/cli/install/unified-diff.js +270 -0
- package/dist/cli/upgrade-check.d.ts +187 -0
- package/dist/cli/upgrade-check.js +685 -0
- package/dist/cli/upgrade.js +42 -0
- package/package.json +1 -1
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rea audit summary` — high-level audit-log overview (0.41.0).
|
|
3
|
+
*
|
|
4
|
+
* The audit log is rich and `rea audit specialists` already exists for
|
|
5
|
+
* one narrow event-class. `rea audit summary` complements it with a
|
|
6
|
+
* broad rollup: total events, counts by `tool_name`, by tier, by
|
|
7
|
+
* session, by status, the time window covered, and a sample-verified
|
|
8
|
+
* chain-integrity check.
|
|
9
|
+
*
|
|
10
|
+
* # Filtering
|
|
11
|
+
*
|
|
12
|
+
* `--since <duration>` accepts a compact duration string
|
|
13
|
+
* (`s`/`m`/`h`/`d`/`w`) and filters records by `timestamp >= now -
|
|
14
|
+
* duration`. Examples: `24h`, `7d`, `90m`, `2w`. This is DIFFERENT
|
|
15
|
+
* from `rea audit verify --since <file>` / `rea audit specialists
|
|
16
|
+
* --since <file>` which take a rotated-file ANCHOR, not a duration —
|
|
17
|
+
* the two `--since` semantics serve different needs (anchor for chain
|
|
18
|
+
* walks, duration for summarization) and we accept the surface area
|
|
19
|
+
* cost. The duration form is what consumers reach for when asking
|
|
20
|
+
* "what happened in the last day?".
|
|
21
|
+
*
|
|
22
|
+
* # Chain integrity
|
|
23
|
+
*
|
|
24
|
+
* `rea audit verify` does the rigorous per-record re-hash. `summary`
|
|
25
|
+
* samples up to `CHAIN_SAMPLE_SIZE` records, evenly spaced through
|
|
26
|
+
* the filtered window, and reports `ok` / `tampered` / `unsampled`
|
|
27
|
+
* (window empty). Operators who suspect tampering should still run
|
|
28
|
+
* `rea audit verify` for an authoritative answer.
|
|
29
|
+
*
|
|
30
|
+
* # JSON output
|
|
31
|
+
*
|
|
32
|
+
* {
|
|
33
|
+
* "schema_version": 1,
|
|
34
|
+
* "window_seconds": 86400,
|
|
35
|
+
* "window_start": "2026-05-15T13:42:00Z",
|
|
36
|
+
* "window_end": "2026-05-16T13:42:00Z",
|
|
37
|
+
* "files_scanned": ["/abs/path/.rea/audit.jsonl"],
|
|
38
|
+
* "total_events": 1247,
|
|
39
|
+
* "by_tool_name": { "Bash": 612, "Edit": 289, … },
|
|
40
|
+
* "by_tier": { "read": 683, "write": 416, "destructive": 148 },
|
|
41
|
+
* "by_status": { "allowed": 1242, "denied": 5, "error": 0 },
|
|
42
|
+
* "by_session": { "session-abc…": 312, "session-def…": 935 },
|
|
43
|
+
* "session_count": 8,
|
|
44
|
+
* "earliest_timestamp": "2026-05-15T13:43:01.103Z",
|
|
45
|
+
* "latest_timestamp": "2026-05-16T13:41:57.842Z",
|
|
46
|
+
* "chain_integrity": "ok",
|
|
47
|
+
* "chain_samples_verified": 12
|
|
48
|
+
* }
|
|
49
|
+
*
|
|
50
|
+
* # Walk scope
|
|
51
|
+
*
|
|
52
|
+
* v1 walks the current `.rea/audit.jsonl` plus EVERY rotated file
|
|
53
|
+
* whose latest record falls within the window. Older rotated files
|
|
54
|
+
* are skipped — they cannot contain in-window records. When `--since`
|
|
55
|
+
* is omitted, no time filter is applied and the walk covers the
|
|
56
|
+
* current `audit.jsonl` only (operators wanting historical depth
|
|
57
|
+
* should pass `--since <DUR>`).
|
|
58
|
+
*/
|
|
59
|
+
import fs from 'node:fs/promises';
|
|
60
|
+
import path from 'node:path';
|
|
61
|
+
import { computeHash, GENESIS_HASH } from '../audit/fs.js';
|
|
62
|
+
import { listRotatedAuditFiles } from './audit-specialists.js';
|
|
63
|
+
import { AUDIT_FILE, REA_DIR, err, log } from './utils.js';
|
|
64
|
+
export const AUDIT_SUMMARY_SCHEMA_VERSION = 1;
|
|
65
|
+
/** Hard cap on chain-integrity samples. Keeps `rea audit summary`
|
|
66
|
+
* fast even on large logs while still surfacing obvious tampering. */
|
|
67
|
+
export const CHAIN_SAMPLE_SIZE = 12;
|
|
68
|
+
/**
|
|
69
|
+
* Thrown by `computeAuditSummary` when `--since` cannot be parsed.
|
|
70
|
+
* The commander wrapper exits 1.
|
|
71
|
+
*/
|
|
72
|
+
export class AuditSummarySinceError extends Error {
|
|
73
|
+
constructor(message) {
|
|
74
|
+
super(message);
|
|
75
|
+
this.name = 'AuditSummarySinceError';
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Parse a compact duration string into seconds. Accepts:
|
|
80
|
+
*
|
|
81
|
+
* - `<N>s` — seconds
|
|
82
|
+
* - `<N>m` — minutes
|
|
83
|
+
* - `<N>h` — hours
|
|
84
|
+
* - `<N>d` — days
|
|
85
|
+
* - `<N>w` — weeks (7 days)
|
|
86
|
+
*
|
|
87
|
+
* `N` must be a positive integer with no whitespace. Returns the
|
|
88
|
+
* number of seconds; throws `AuditSummarySinceError` on parse failure.
|
|
89
|
+
*
|
|
90
|
+
* We deliberately do not accept bare numbers (would be ambiguous) or
|
|
91
|
+
* fractional units (no real use case; complicates rendering).
|
|
92
|
+
*/
|
|
93
|
+
export function parseDurationSeconds(raw) {
|
|
94
|
+
const m = /^(\d+)(s|m|h|d|w)$/i.exec(raw.trim());
|
|
95
|
+
if (m === null) {
|
|
96
|
+
throw new AuditSummarySinceError(`--since: cannot parse ${JSON.stringify(raw)}. ` +
|
|
97
|
+
`Expected <N><unit> where unit is s|m|h|d|w (e.g. 24h, 7d, 90m).`);
|
|
98
|
+
}
|
|
99
|
+
const n = Number.parseInt(m[1], 10);
|
|
100
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
101
|
+
throw new AuditSummarySinceError(`--since: duration must be a positive integer; got ${JSON.stringify(raw)}.`);
|
|
102
|
+
}
|
|
103
|
+
const unit = m[2].toLowerCase();
|
|
104
|
+
const factor = {
|
|
105
|
+
s: 1,
|
|
106
|
+
m: 60,
|
|
107
|
+
h: 60 * 60,
|
|
108
|
+
d: 60 * 60 * 24,
|
|
109
|
+
w: 60 * 60 * 24 * 7,
|
|
110
|
+
};
|
|
111
|
+
return n * factor[unit];
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Resolve the audit files to walk. Always includes the current
|
|
115
|
+
* `audit.jsonl` (when it exists).
|
|
116
|
+
*
|
|
117
|
+
* - `windowStart === null` (no `--since`): walk EVERY rotated file
|
|
118
|
+
* PLUS the current `audit.jsonl`. Round-1 P2: the prior shape
|
|
119
|
+
* dropped rotated history silently while the header still
|
|
120
|
+
* advertised "all time", undercounting long-lived repos.
|
|
121
|
+
* - `windowStart` set: walk EVERY rotated file. The per-record
|
|
122
|
+
* timestamp filter inside `computeAuditSummary` then drops
|
|
123
|
+
* out-of-window records during the scan. 0.41.0 round-3 P2 +
|
|
124
|
+
* 0.42.0 charter item 3: rotated filenames are NOT authoritative
|
|
125
|
+
* for "earliest contained record" — they are wall-clock at the
|
|
126
|
+
* ROTATION INSTANT, which can be days after the file's earliest
|
|
127
|
+
* contents when the rotation size cap is reached late. Pruning
|
|
128
|
+
* by filename therefore drops in-window records from
|
|
129
|
+
* conservatively-rotated logs (a rotated file from 7 days ago can
|
|
130
|
+
* still contain records from 14 days ago because the previous
|
|
131
|
+
* rotation event was 14 days ago). The cost of walking every
|
|
132
|
+
* rotated segment under `--since` is bounded by the rotation cap
|
|
133
|
+
* × number of segments — comfortably manageable in the
|
|
134
|
+
* summary-rollup setting where we already read every byte for
|
|
135
|
+
* the in-window scan; the win is correctness.
|
|
136
|
+
*
|
|
137
|
+
* Sort order is timestamp-ascending (by FILENAME stamp); the current
|
|
138
|
+
* `audit.jsonl` is always appended last (it is the newest segment
|
|
139
|
+
* of the chain).
|
|
140
|
+
*/
|
|
141
|
+
async function resolveSummaryFileWalk(baseDir, windowStart) {
|
|
142
|
+
const reaDir = path.join(baseDir, REA_DIR);
|
|
143
|
+
const currentAudit = path.join(reaDir, AUDIT_FILE);
|
|
144
|
+
const files = [];
|
|
145
|
+
const rotated = await listRotatedAuditFiles(reaDir);
|
|
146
|
+
// Both `windowStart === null` and `windowStart` set: walk every
|
|
147
|
+
// rotated segment. Pre-0.42.0 the `windowStart` branch attempted to
|
|
148
|
+
// prune rotated files by their filename stamp ("rotated at >=
|
|
149
|
+
// windowStart minus one buffer file"). That was wrong: the filename
|
|
150
|
+
// stamp marks the ROTATION event, not the earliest record contained
|
|
151
|
+
// in the file. A rotated file's records can pre-date its filename
|
|
152
|
+
// stamp by days when the previous rotation cycle was long. Walking
|
|
153
|
+
// every rotated file and letting the per-record `timestamp >=
|
|
154
|
+
// windowStart` filter inside `computeAuditSummary` decide is the
|
|
155
|
+
// only correct approach: we never falsely drop an in-window record
|
|
156
|
+
// because of where it happens to live on disk. Reference:
|
|
157
|
+
// 0.41.0 round-3 P2 + 0.42.0 charter item 3.
|
|
158
|
+
//
|
|
159
|
+
// `windowStart === null` (no --since) already walks every rotated
|
|
160
|
+
// segment — same code path.
|
|
161
|
+
void windowStart; // intentionally unused — full-walk is correct in both modes
|
|
162
|
+
for (const name of rotated)
|
|
163
|
+
files.push(path.join(reaDir, name));
|
|
164
|
+
try {
|
|
165
|
+
const stat = await fs.stat(currentAudit);
|
|
166
|
+
if (stat.isFile())
|
|
167
|
+
files.push(currentAudit);
|
|
168
|
+
}
|
|
169
|
+
catch (e) {
|
|
170
|
+
if (e.code !== 'ENOENT')
|
|
171
|
+
throw e;
|
|
172
|
+
}
|
|
173
|
+
return files;
|
|
174
|
+
}
|
|
175
|
+
/** Map a rea Tier value (or unknown) to a stable bucket key for the
|
|
176
|
+
* by_tier table. Unknown values bucket to `'unknown'` so the rollup
|
|
177
|
+
* surfaces them rather than silently dropping.
|
|
178
|
+
*/
|
|
179
|
+
function tierBucket(value) {
|
|
180
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
181
|
+
return 'unknown';
|
|
182
|
+
return value;
|
|
183
|
+
}
|
|
184
|
+
function statusBucket(value) {
|
|
185
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
186
|
+
return 'unknown';
|
|
187
|
+
return value;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Parse an ISO-8601 timestamp into a Date. Returns `null` on failure.
|
|
191
|
+
*/
|
|
192
|
+
function parseTimestamp(raw) {
|
|
193
|
+
if (typeof raw !== 'string')
|
|
194
|
+
return null;
|
|
195
|
+
const d = new Date(raw);
|
|
196
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Sample-verify per-record hash integrity across the IN-WINDOW
|
|
200
|
+
* records only. Returns `ok`, `tampered`, or `unsampled` (no
|
|
201
|
+
* records in window).
|
|
202
|
+
*
|
|
203
|
+
* Sampling: take indices at offsets 0, n/k, 2n/k, …, (k-1)n/k where
|
|
204
|
+
* n is the in-window count and k is `CHAIN_SAMPLE_SIZE`. Always
|
|
205
|
+
* sample the first and last record.
|
|
206
|
+
*
|
|
207
|
+
* Codex round-2 P1: we do NOT verify `prev_hash` linkage between
|
|
208
|
+
* adjacent records in the filtered list. `appendAuditRecord` accepts
|
|
209
|
+
* caller-supplied timestamps, so a valid chain can look like
|
|
210
|
+
* `in-window → out-of-window → in-window`; in that case the second
|
|
211
|
+
* in-window record's `prev_hash` points to the filtered-out entry,
|
|
212
|
+
* not the previous survivor in our filtered view, and a linkage
|
|
213
|
+
* check would false-positive `tampered` on a healthy log. The
|
|
214
|
+
* authoritative chain walk lives in `rea audit verify`; summary
|
|
215
|
+
* stays advisory.
|
|
216
|
+
*/
|
|
217
|
+
function sampleChainIntegrity(records) {
|
|
218
|
+
if (records.length === 0)
|
|
219
|
+
return { result: 'unsampled', samplesVerified: 0 };
|
|
220
|
+
const sampleIndices = new Set();
|
|
221
|
+
const k = Math.min(CHAIN_SAMPLE_SIZE, records.length);
|
|
222
|
+
for (let i = 0; i < k; i += 1) {
|
|
223
|
+
const idx = Math.floor((i * records.length) / k);
|
|
224
|
+
sampleIndices.add(idx);
|
|
225
|
+
}
|
|
226
|
+
sampleIndices.add(records.length - 1);
|
|
227
|
+
let samplesVerified = 0;
|
|
228
|
+
for (const i of sampleIndices) {
|
|
229
|
+
const r = records[i];
|
|
230
|
+
const { hash, ...rest } = r;
|
|
231
|
+
const recomputed = computeHash(rest);
|
|
232
|
+
if (recomputed !== hash) {
|
|
233
|
+
return { result: 'tampered', samplesVerified };
|
|
234
|
+
}
|
|
235
|
+
samplesVerified += 1;
|
|
236
|
+
}
|
|
237
|
+
return { result: 'ok', samplesVerified };
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Compute the summary. Pure (read-only). Throws
|
|
241
|
+
* `AuditSummarySinceError` on bad `--since`; everything else is
|
|
242
|
+
* surfaced via the result.
|
|
243
|
+
*/
|
|
244
|
+
export async function computeAuditSummary(options = {}) {
|
|
245
|
+
const baseDir = options.baseDir ?? process.cwd();
|
|
246
|
+
const now = options.now ?? new Date();
|
|
247
|
+
let windowSeconds = null;
|
|
248
|
+
let windowStart = null;
|
|
249
|
+
let windowEnd = null;
|
|
250
|
+
if (options.since !== undefined && options.since.length > 0) {
|
|
251
|
+
windowSeconds = parseDurationSeconds(options.since);
|
|
252
|
+
windowEnd = now;
|
|
253
|
+
windowStart = new Date(now.getTime() - windowSeconds * 1000);
|
|
254
|
+
}
|
|
255
|
+
const files = await resolveSummaryFileWalk(baseDir, windowStart);
|
|
256
|
+
const byToolName = {};
|
|
257
|
+
const byTier = {};
|
|
258
|
+
const byStatus = {};
|
|
259
|
+
const bySession = {};
|
|
260
|
+
let totalEvents = 0;
|
|
261
|
+
let earliest = null;
|
|
262
|
+
let latest = null;
|
|
263
|
+
// We only feed in-window records to the chain-sample check.
|
|
264
|
+
const inWindowRecords = [];
|
|
265
|
+
// 0.42.0 codex round 4 P2 + round 6 P2 (2026-05-16): reserved for
|
|
266
|
+
// future per-segment time-range metadata that would let us prove a
|
|
267
|
+
// skipped file is out of scope. Always empty under 0.42.0 — see
|
|
268
|
+
// the AuditSummaryResult.unreadable_segments docstring.
|
|
269
|
+
const unreadableSegments = [];
|
|
270
|
+
// We rebuild the actually-read file list as we go so the summary
|
|
271
|
+
// never claims to have scanned a file that was silently skipped.
|
|
272
|
+
// (Currently identical to `files` minus ENOENT entries since every
|
|
273
|
+
// other read error throws — kept as a separate accumulator so the
|
|
274
|
+
// shape stays correct when the future `unreadable_segments`
|
|
275
|
+
// soft-skip path lands.)
|
|
276
|
+
const actuallyScanned = [];
|
|
277
|
+
for (const filePath of files) {
|
|
278
|
+
let raw;
|
|
279
|
+
try {
|
|
280
|
+
raw = await fs.readFile(filePath, 'utf8');
|
|
281
|
+
}
|
|
282
|
+
catch (e) {
|
|
283
|
+
const errno = e.code;
|
|
284
|
+
if (errno === 'ENOENT')
|
|
285
|
+
continue;
|
|
286
|
+
// 0.42.0 codex round 4 P2 + round 5 P2 + round 6 P2 (2026-05-16):
|
|
287
|
+
// earlier rounds attempted to soft-skip unreadable rotations to
|
|
288
|
+
// accommodate backup-restore artifacts. Round 6 caught that the
|
|
289
|
+
// soft-skip is unsound: `resolveSummaryFileWalk` now enqueues
|
|
290
|
+
// every rotated segment under `--since` (filename-stamp pruning
|
|
291
|
+
// was correctly removed because the stamp marks the rotation
|
|
292
|
+
// event, not the earliest record contained), so we CANNOT prove
|
|
293
|
+
// an unreadable file is out of scope without reading it. A
|
|
294
|
+
// silent skip would mean `rea audit summary` could exit 0 with
|
|
295
|
+
// an undercount AND `chain_integrity: ok` while real in-window
|
|
296
|
+
// records went uncounted.
|
|
297
|
+
//
|
|
298
|
+
// Throwing with a precise, actionable error is the right call:
|
|
299
|
+
// the operator can chmod the file, move it out of .rea/, or
|
|
300
|
+
// delete it. `unreadable_segments` in the result is reserved
|
|
301
|
+
// for the never-reached future case where we can prove a file
|
|
302
|
+
// is genuinely out of scope (we'd need rotation start/end
|
|
303
|
+
// metadata for that — out of scope here).
|
|
304
|
+
throw new Error(`rea audit summary: cannot read ${filePath} (${errno ?? 'unknown errno'}). ` +
|
|
305
|
+
`An unreadable audit segment may contain in-window records, so the summary ` +
|
|
306
|
+
`would be silently incomplete. Fix permissions (e.g. \`chmod u+r ${filePath}\`), ` +
|
|
307
|
+
`or move the file out of \`.rea/\` if you no longer need it. The current ` +
|
|
308
|
+
`audit.jsonl is always required.`);
|
|
309
|
+
}
|
|
310
|
+
actuallyScanned.push(filePath);
|
|
311
|
+
for (const line of raw.split('\n')) {
|
|
312
|
+
if (line.length === 0)
|
|
313
|
+
continue;
|
|
314
|
+
let parsed;
|
|
315
|
+
try {
|
|
316
|
+
parsed = JSON.parse(line);
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
// Malformed line — `rea audit verify` is the tool for that.
|
|
320
|
+
// Summary just skips and moves on.
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
const ts = parseTimestamp(parsed.timestamp);
|
|
324
|
+
if (windowStart !== null && (ts === null || ts < windowStart))
|
|
325
|
+
continue;
|
|
326
|
+
// upper bound is `now`; future-dated records (skew, replay) are
|
|
327
|
+
// still counted — they're real records that landed in the file.
|
|
328
|
+
totalEvents += 1;
|
|
329
|
+
const toolName = typeof parsed.tool_name === 'string' && parsed.tool_name.length > 0
|
|
330
|
+
? parsed.tool_name
|
|
331
|
+
: '(unknown)';
|
|
332
|
+
byToolName[toolName] = (byToolName[toolName] ?? 0) + 1;
|
|
333
|
+
const tier = tierBucket(parsed.tier);
|
|
334
|
+
byTier[tier] = (byTier[tier] ?? 0) + 1;
|
|
335
|
+
const status = statusBucket(parsed.status);
|
|
336
|
+
byStatus[status] = (byStatus[status] ?? 0) + 1;
|
|
337
|
+
const session = typeof parsed.session_id === 'string' && parsed.session_id.length > 0
|
|
338
|
+
? parsed.session_id
|
|
339
|
+
: '(unknown)';
|
|
340
|
+
bySession[session] = (bySession[session] ?? 0) + 1;
|
|
341
|
+
const tsRaw = typeof parsed.timestamp === 'string' ? parsed.timestamp : null;
|
|
342
|
+
if (tsRaw !== null) {
|
|
343
|
+
if (earliest === null || tsRaw < earliest)
|
|
344
|
+
earliest = tsRaw;
|
|
345
|
+
if (latest === null || tsRaw > latest)
|
|
346
|
+
latest = tsRaw;
|
|
347
|
+
}
|
|
348
|
+
inWindowRecords.push(parsed);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const { result: chainIntegrity, samplesVerified } = sampleChainIntegrity(inWindowRecords);
|
|
352
|
+
// Suppress unused-variable warning for genesis hash by referencing
|
|
353
|
+
// it: the chain-sample check uses it implicitly via the relaxed
|
|
354
|
+
// first-record rule. Kept as an import-time anchor for the linkage
|
|
355
|
+
// contract documented in `sampleChainIntegrity`.
|
|
356
|
+
void GENESIS_HASH;
|
|
357
|
+
return {
|
|
358
|
+
schema_version: AUDIT_SUMMARY_SCHEMA_VERSION,
|
|
359
|
+
window_seconds: windowSeconds,
|
|
360
|
+
window_start: windowStart !== null ? windowStart.toISOString() : null,
|
|
361
|
+
window_end: windowEnd !== null ? windowEnd.toISOString() : null,
|
|
362
|
+
// 0.42.0 codex round 4 P2: report only the files actually read.
|
|
363
|
+
// Unreadable rotations are reported separately under
|
|
364
|
+
// `unreadable_segments` so consumers can tell the difference
|
|
365
|
+
// between "scanned and empty" and "skipped because permissions".
|
|
366
|
+
files_scanned: actuallyScanned,
|
|
367
|
+
unreadable_segments: unreadableSegments,
|
|
368
|
+
total_events: totalEvents,
|
|
369
|
+
by_tool_name: byToolName,
|
|
370
|
+
by_tier: byTier,
|
|
371
|
+
by_status: byStatus,
|
|
372
|
+
by_session: bySession,
|
|
373
|
+
session_count: Object.keys(bySession).length,
|
|
374
|
+
earliest_timestamp: earliest,
|
|
375
|
+
latest_timestamp: latest,
|
|
376
|
+
chain_integrity: chainIntegrity,
|
|
377
|
+
chain_samples_verified: samplesVerified,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
function sortBucket(bucket) {
|
|
381
|
+
return Object.entries(bucket).sort((a, b) => {
|
|
382
|
+
if (b[1] !== a[1])
|
|
383
|
+
return b[1] - a[1];
|
|
384
|
+
return a[0].localeCompare(b[0]);
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
function renderBucket(d, total) {
|
|
388
|
+
const lines = [];
|
|
389
|
+
lines.push(`${d.title}:`);
|
|
390
|
+
const visible = d.limit !== undefined ? d.entries.slice(0, d.limit) : d.entries;
|
|
391
|
+
const overflow = d.limit !== undefined ? d.entries.slice(d.limit) : [];
|
|
392
|
+
const maxNameLen = visible.reduce((m, [n]) => Math.max(m, n.length), 0);
|
|
393
|
+
for (const [name, count] of visible) {
|
|
394
|
+
const pad = ' '.repeat(maxNameLen - name.length + 2);
|
|
395
|
+
const pct = total > 0 ? ` (${((count * 100) / total).toFixed(1)}%)` : '';
|
|
396
|
+
lines.push(` ${name}${pad}${String(count).padStart(6)}${pct}`);
|
|
397
|
+
}
|
|
398
|
+
if (overflow.length > 0) {
|
|
399
|
+
const overflowSum = overflow.reduce((s, [, n]) => s + n, 0);
|
|
400
|
+
const pad = ' '.repeat(Math.max(0, maxNameLen - 7) + 2);
|
|
401
|
+
const pct = total > 0 ? ` (${((overflowSum * 100) / total).toFixed(1)}%)` : '';
|
|
402
|
+
lines.push(` (other)${pad}${String(overflowSum).padStart(6)}${pct}`);
|
|
403
|
+
}
|
|
404
|
+
return lines.join('\n');
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Render the result as a human-readable terminal block. Designed for
|
|
408
|
+
* the default `rea audit summary` invocation; `--json` callers bypass
|
|
409
|
+
* this entirely.
|
|
410
|
+
*/
|
|
411
|
+
export function renderAuditSummary(result) {
|
|
412
|
+
const lines = [];
|
|
413
|
+
const windowLabel = result.window_seconds !== null ? formatDurationShort(result.window_seconds) : 'all time';
|
|
414
|
+
lines.push(`rea audit summary (${windowLabel})`);
|
|
415
|
+
lines.push('─'.repeat(40));
|
|
416
|
+
lines.push(`total events: ${String(result.total_events).padStart(6)}`);
|
|
417
|
+
lines.push(`sessions: ${String(result.session_count).padStart(6)}`);
|
|
418
|
+
if (result.session_count > 0) {
|
|
419
|
+
const avg = result.total_events / result.session_count;
|
|
420
|
+
lines.push(`events/session avg: ${avg.toFixed(1).padStart(6)}`);
|
|
421
|
+
}
|
|
422
|
+
lines.push('');
|
|
423
|
+
if (result.total_events === 0) {
|
|
424
|
+
lines.push(result.window_seconds !== null
|
|
425
|
+
? 'No events in the requested window.'
|
|
426
|
+
: 'No events in the audit log.');
|
|
427
|
+
lines.push('');
|
|
428
|
+
if (result.files_scanned.length === 0) {
|
|
429
|
+
lines.push('(no audit files found — has `rea serve` ever run?)');
|
|
430
|
+
lines.push('');
|
|
431
|
+
}
|
|
432
|
+
// 0.42.0 codex round 4 P2: even in the zero-events early-return,
|
|
433
|
+
// surface unreadable segments so the operator sees the gap.
|
|
434
|
+
if (result.unreadable_segments.length > 0) {
|
|
435
|
+
lines.push(`unreadable rotated segments: ${String(result.unreadable_segments.length)} ` +
|
|
436
|
+
`(see stderr for paths; fix permissions and re-run to include them)`);
|
|
437
|
+
lines.push('');
|
|
438
|
+
}
|
|
439
|
+
return lines.join('\n');
|
|
440
|
+
}
|
|
441
|
+
const total = result.total_events;
|
|
442
|
+
lines.push(renderBucket({ title: 'by tool_name', entries: sortBucket(result.by_tool_name), limit: 12 }, total));
|
|
443
|
+
lines.push('');
|
|
444
|
+
lines.push(renderBucket({ title: 'by tier', entries: sortBucket(result.by_tier) }, total));
|
|
445
|
+
lines.push('');
|
|
446
|
+
lines.push(renderBucket({ title: 'by status', entries: sortBucket(result.by_status) }, total));
|
|
447
|
+
lines.push('');
|
|
448
|
+
// Sessions can balloon — limit to 5 by default.
|
|
449
|
+
lines.push(renderBucket({ title: 'top sessions', entries: sortBucket(result.by_session), limit: 5 }, total));
|
|
450
|
+
lines.push('');
|
|
451
|
+
if (result.earliest_timestamp !== null && result.latest_timestamp !== null) {
|
|
452
|
+
lines.push(`window: ${result.earliest_timestamp} → ${result.latest_timestamp}`);
|
|
453
|
+
}
|
|
454
|
+
const chainLabel = result.chain_integrity === 'ok'
|
|
455
|
+
? `ok (${String(result.chain_samples_verified)} sample${result.chain_samples_verified === 1 ? '' : 's'} verified)`
|
|
456
|
+
: result.chain_integrity === 'tampered'
|
|
457
|
+
? 'TAMPERED — run `rea audit verify` for the exact break'
|
|
458
|
+
: 'unsampled (no records in window)';
|
|
459
|
+
lines.push(`chain integrity: ${chainLabel}`);
|
|
460
|
+
lines.push(`files scanned: ${String(result.files_scanned.length)}`);
|
|
461
|
+
// 0.42.0 codex round 4 P2 (2026-05-16): surface unreadable rotated
|
|
462
|
+
// segments so an operator scanning the rendered summary doesn't
|
|
463
|
+
// miss a skipped archive that the JSON consumers can see.
|
|
464
|
+
if (result.unreadable_segments.length > 0) {
|
|
465
|
+
lines.push(`unreadable rotated segments: ${String(result.unreadable_segments.length)} ` +
|
|
466
|
+
`(see stderr for paths; fix permissions and re-run to include them)`);
|
|
467
|
+
}
|
|
468
|
+
lines.push('');
|
|
469
|
+
return lines.join('\n');
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Compact human duration label for the header. `86400` → `last 24h`,
|
|
473
|
+
* `604800` → `last 7d`, etc. We pick the coarsest single unit that
|
|
474
|
+
* yields an integer; otherwise fall back to seconds.
|
|
475
|
+
*/
|
|
476
|
+
function formatDurationShort(seconds) {
|
|
477
|
+
const units = [
|
|
478
|
+
['w', 60 * 60 * 24 * 7],
|
|
479
|
+
['d', 60 * 60 * 24],
|
|
480
|
+
['h', 60 * 60],
|
|
481
|
+
['m', 60],
|
|
482
|
+
['s', 1],
|
|
483
|
+
];
|
|
484
|
+
for (const [unit, factor] of units) {
|
|
485
|
+
if (seconds % factor === 0) {
|
|
486
|
+
return `last ${String(seconds / factor)}${unit}`;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return `last ${String(seconds)}s`;
|
|
490
|
+
}
|
|
491
|
+
/** Commander entrypoint. */
|
|
492
|
+
export async function runAuditSummary(options) {
|
|
493
|
+
let result;
|
|
494
|
+
try {
|
|
495
|
+
result = await computeAuditSummary({
|
|
496
|
+
...(options.since !== undefined ? { since: options.since } : {}),
|
|
497
|
+
...(options.now !== undefined ? { now: options.now } : {}),
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
catch (e) {
|
|
501
|
+
if (e instanceof AuditSummarySinceError) {
|
|
502
|
+
err(`rea audit summary: ${e.message}`);
|
|
503
|
+
process.exit(1);
|
|
504
|
+
}
|
|
505
|
+
throw e;
|
|
506
|
+
}
|
|
507
|
+
if (options.json === true) {
|
|
508
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
process.stdout.write(renderAuditSummary(result));
|
|
512
|
+
if (result.chain_integrity === 'tampered') {
|
|
513
|
+
// Non-zero exit gives CI users a single signal for "something is
|
|
514
|
+
// off"; the JSON path stays exit 0 so machine consumers can react
|
|
515
|
+
// to the integrity field directly.
|
|
516
|
+
log('summary complete with tampered chain — see message above.');
|
|
517
|
+
process.exit(1);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Register `rea audit summary` under the `audit` command group.
|
|
522
|
+
*/
|
|
523
|
+
export function registerAuditSummaryCommand(auditCommand) {
|
|
524
|
+
auditCommand
|
|
525
|
+
.command('summary')
|
|
526
|
+
.description('High-level audit-log summary — counts by tool_name, tier, session, status; window timestamps; sample-verified chain integrity. Read-only.')
|
|
527
|
+
.option('--since <duration>', 'Filter to records within the last <duration>. Compact form: <N><unit> where unit is s|m|h|d|w (e.g. 24h, 7d). Distinct from `rea audit verify --since <file>` which anchors on a rotated file.')
|
|
528
|
+
.option('--json', 'emit a JSON document instead of the human-readable table')
|
|
529
|
+
.action(async (opts) => {
|
|
530
|
+
await runAuditSummary({
|
|
531
|
+
...(opts.since !== undefined ? { since: opts.since } : {}),
|
|
532
|
+
...(opts.json === true ? { json: true } : {}),
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
}
|
package/dist/cli/doctor.d.ts
CHANGED
|
@@ -138,6 +138,26 @@ export interface PolicyReaderProbes {
|
|
|
138
138
|
* ignore the argument; the default production probe uses it.
|
|
139
139
|
*/
|
|
140
140
|
python3PyYamlReachable?: (baseDir: string) => boolean;
|
|
141
|
+
/**
|
|
142
|
+
* 0.42.0 codex round 5 P2 (2026-05-16) — execution probe for the
|
|
143
|
+
* python3 list-walker branch in `policy_reader_get_list`. That
|
|
144
|
+
* branch needs to spawn `python3 -c "..."` with `import json` from
|
|
145
|
+
* stdlib; PyYAML is irrelevant. The check is execution-based (not
|
|
146
|
+
* PATH-only) because a `python3` symlink can resolve on PATH but
|
|
147
|
+
* fail to start in the current sandbox (dangling pyenv/asdf stub,
|
|
148
|
+
* permission-denied interpreter, missing dynamic libs). A PATH-only
|
|
149
|
+
* check would let the doctor declare `warn` on a box where the
|
|
150
|
+
* shim will actually fall through to Tier 3 — masking a real
|
|
151
|
+
* enforcement gap for list-valued policy keys.
|
|
152
|
+
*
|
|
153
|
+
* The probe runs `python3 -c "import json; print('ok')"` with the
|
|
154
|
+
* same env scrub as the PyYAML probe (PYTHONPATH/PYTHONHOME/
|
|
155
|
+
* PYTHONSTARTUP unset, PYTHONSAFEPATH=1, sys.path scrubbed) so a
|
|
156
|
+
* malicious repo cannot plant a `./json.py` that shadows stdlib
|
|
157
|
+
* and falsely report `true` while the real loader (which scrubs)
|
|
158
|
+
* fails.
|
|
159
|
+
*/
|
|
160
|
+
python3ListWalkerReachable?: (baseDir: string) => boolean;
|
|
141
161
|
awkOnPath?: () => string | null;
|
|
142
162
|
jqOnPath?: () => string | null;
|
|
143
163
|
}
|
|
@@ -183,12 +203,12 @@ export declare function checkPolicyReaderTier2(baseDir: string, probes?: PolicyR
|
|
|
183
203
|
* Practically always present (POSIX requirement).
|
|
184
204
|
*
|
|
185
205
|
* 0.40.0 charter item 2 — conditional verdict, refined by codex
|
|
186
|
-
* round 1 P2:
|
|
206
|
+
* round 1 P2 (0.40.0) and round 2 P2 (0.42.0):
|
|
187
207
|
* - awk present → `pass`
|
|
188
208
|
* - awk absent AND Tier 2 reachable → `warn`
|
|
189
209
|
* (Tier 2 implies python3, which is a list-walker)
|
|
190
210
|
* - awk absent AND Tier 1 reachable AND a list walker
|
|
191
|
-
* (jq OR
|
|
211
|
+
* (jq OR full Tier-2 reachable) is usable → `warn`
|
|
192
212
|
* - awk absent AND Tier 1 reachable BUT no list walker → `fail`
|
|
193
213
|
* (codex round 1 P2 — list-valued policy reads silently
|
|
194
214
|
* fail-closed even though scalar reads work, so the
|
|
@@ -207,9 +227,29 @@ export declare function checkPolicyReaderTier2(baseDir: string, probes?: PolicyR
|
|
|
207
227
|
* functional box that has python3 + jq + the rea CLI all wired but
|
|
208
228
|
* happens to lack awk.
|
|
209
229
|
*
|
|
230
|
+
* List-iteration semantic (clarifying note for codex round 2 P2,
|
|
231
|
+
* 2026-05-16): `policy_reader_get_list` in
|
|
232
|
+
* `hooks/_lib/policy-reader.sh` walks the cached subtree JSON via
|
|
233
|
+
* `jq` OR `python3` (stdlib-only — `json` module, no PyYAML import).
|
|
234
|
+
* PyYAML is only needed for Tier 2 itself (YAML PARSING into JSON),
|
|
235
|
+
* NOT for iterating the already-parsed JSON arrays at list-read time.
|
|
236
|
+
*
|
|
237
|
+
* Codex round 5 P2 (2026-05-16): the "list walker" predicate uses
|
|
238
|
+
* `python3ListWalkerReachable` — an EXECUTION probe that actually
|
|
239
|
+
* spawns `python3 -c "import json"` — instead of `python3OnPath`. A
|
|
240
|
+
* PATH-only check passes for broken pyenv/asdf shims, dangling
|
|
241
|
+
* symlinks, and sandboxed environments where the interpreter cannot
|
|
242
|
+
* start; in those cases the shim's list-walker branch would actually
|
|
243
|
+
* fail and `blocked_paths`/`protected_writes` enforcement would
|
|
244
|
+
* silently break while doctor reported `warn`. The execution probe
|
|
245
|
+
* mirrors `defaultPython3PyYamlReachable` exactly but swaps the
|
|
246
|
+
* `import yaml` for `import json` so it's not gated on PyYAML
|
|
247
|
+
* availability (which is irrelevant to list iteration).
|
|
248
|
+
*
|
|
210
249
|
* Takes `baseDir` so it can evaluate Tier 1's two-stage check (dist
|
|
211
|
-
* present + CLI invokable)
|
|
212
|
-
* threaded through
|
|
250
|
+
* present + CLI invokable), Tier 2's reachability, and the
|
|
251
|
+
* list-walker execution probe. All probes are threaded through
|
|
252
|
+
* identically.
|
|
213
253
|
*/
|
|
214
254
|
export declare function checkPolicyReaderTier3(baseDir: string, probes?: PolicyReaderProbes): CheckResult;
|
|
215
255
|
/**
|