@bookedsolid/rea 0.46.0 → 0.47.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 +89 -0
- package/dist/cli/audit-timeline.d.ts +20 -0
- package/dist/cli/audit-timeline.js +262 -20
- package/dist/cli/audit-top-blocks.d.ts +154 -0
- package/dist/cli/audit-top-blocks.js +419 -0
- package/dist/cli/index.js +5 -0
- package/dist/config/tier-map.js +32 -0
- package/package.json +1 -1
package/MIGRATING.md
CHANGED
|
@@ -528,6 +528,95 @@ under `--since` and lets the per-record timestamp filter drop the
|
|
|
528
528
|
out-of-window entries. Correctness over micro-optimization;
|
|
529
529
|
`rea audit summary` performance is unchanged in practice.
|
|
530
530
|
|
|
531
|
+
## Audit observability completion (added in 0.47.0)
|
|
532
|
+
|
|
533
|
+
0.46.0 shipped `rea audit by-tool` and `rea audit timeline`. 0.47.0
|
|
534
|
+
rounds out the observability surface with two timeline ergonomics fixes
|
|
535
|
+
and a new refusal-debugging reader:
|
|
536
|
+
|
|
537
|
+
### `rea audit timeline` — helpful MAX_BUCKETS errors + auto-clamp
|
|
538
|
+
|
|
539
|
+
Pre-0.47.0, `rea audit timeline --bucket=15m --since=21d` (= 2016
|
|
540
|
+
buckets, just past the 2000-bucket ceiling) rejected with a generic
|
|
541
|
+
"use a larger --bucket or narrower --since" message. The 0.47.0 error
|
|
542
|
+
now carries concrete remediation:
|
|
543
|
+
|
|
544
|
+
```text
|
|
545
|
+
rea audit timeline: --bucket=15m × --since=21d = 2016 buckets exceeds
|
|
546
|
+
MAX_BUCKETS=2000. Try --bucket=1h (504 buckets) or --since=20d 20h
|
|
547
|
+
(1999 buckets).
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
For the related "I omitted `--since` and the audit log spans a year"
|
|
551
|
+
case, the timeline now AUTO-CLAMPS to the widest window that fits at
|
|
552
|
+
the requested cadence rather than throwing. The clamp is surfaced
|
|
553
|
+
inline in human output:
|
|
554
|
+
|
|
555
|
+
```text
|
|
556
|
+
rea audit timeline (clamped to ~1999h of newest activity, hourly)
|
|
557
|
+
────────────────────────────────────────
|
|
558
|
+
note: --since not specified; auto-clamped to newest 2000 buckets
|
|
559
|
+
(~1999h span at --bucket=1h). Pass --since=DUR to anchor at
|
|
560
|
+
now, or rerun with a WIDER --bucket (current 1h) to fit the
|
|
561
|
+
full log.
|
|
562
|
+
…
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
JSON consumers see the clamp as a new `clamped_since` field — `null`
|
|
566
|
+
in the common case, a duration string (e.g. `"1999h"`) when the
|
|
567
|
+
clamp fired. The field is informational, not reproducible: `--since`
|
|
568
|
+
always anchors at `now`, so a clamp anchored at an older record
|
|
569
|
+
cannot be round-tripped through `--since=<clamped_since>`. Use the
|
|
570
|
+
field to detect that clamping occurred and to size the rendered
|
|
571
|
+
window in dashboards. For a fully reproducible view, pass `--since`
|
|
572
|
+
or `--bucket` explicitly. Schema version is unchanged (still v1) —
|
|
573
|
+
the field is purely additive. `window.start/end/seconds` is also
|
|
574
|
+
nulled out on sparse-log clamps where the kept buckets don't form a
|
|
575
|
+
contiguous time lattice, so `total_events / window.seconds` never
|
|
576
|
+
derives a misleading rate.
|
|
577
|
+
|
|
578
|
+
### `rea audit top-blocks` — debugging "why was that refused?"
|
|
579
|
+
|
|
580
|
+
A new subcommand surfaces the most recent refusal events (any record
|
|
581
|
+
whose `status` is `denied` or `error`) from the audit log:
|
|
582
|
+
|
|
583
|
+
```bash
|
|
584
|
+
rea audit top-blocks # last 20 refusals, all time
|
|
585
|
+
rea audit top-blocks --since=24h # last 24h
|
|
586
|
+
rea audit top-blocks --since=7d --limit=50 # last week, top 50
|
|
587
|
+
rea audit top-blocks --json # dashboard shape
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
Each row carries the short hash (first 8 chars), full timestamp, tool
|
|
591
|
+
name, and the refusal reason (sourced from the record's `error` field;
|
|
592
|
+
truncated to ~80 chars in human output, full text in JSON). Sorted
|
|
593
|
+
newest-first so the most recent refusals are at the top.
|
|
594
|
+
|
|
595
|
+
Use this when an agent reports "the hook blocked my push" or "the
|
|
596
|
+
write was refused" and you need the exact reason without grepping
|
|
597
|
+
`.rea/audit.jsonl` by hand.
|
|
598
|
+
|
|
599
|
+
JSON shape (stable, v1):
|
|
600
|
+
|
|
601
|
+
```json
|
|
602
|
+
{
|
|
603
|
+
"schema_version": 1,
|
|
604
|
+
"since": "24h",
|
|
605
|
+
"limit": 20,
|
|
606
|
+
"window": { "seconds": 86400, "start": "...", "end": "..." },
|
|
607
|
+
"total_matched": 4,
|
|
608
|
+
"events": [
|
|
609
|
+
{ "hash": "...", "timestamp": "...", "tool": "Bash",
|
|
610
|
+
"status": "denied", "reason": "...", "session_id": "..." }
|
|
611
|
+
],
|
|
612
|
+
"files_scanned": ["/abs/path/.rea/audit.jsonl"]
|
|
613
|
+
}
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
`total_matched` is the pre-limit count, so dashboards can show
|
|
617
|
+
"20 of 47 refusals in window". Walk scope mirrors the sibling audit
|
|
618
|
+
readers — current `.rea/audit.jsonl` PLUS every rotated segment.
|
|
619
|
+
|
|
531
620
|
## Policy knobs worth setting
|
|
532
621
|
|
|
533
622
|
For consumers with a long-running migration branch (>30 commits since
|
|
@@ -112,6 +112,16 @@ export interface AuditTimelineResult {
|
|
|
112
112
|
/** Index of the bucket with the highest count. `-1` when no events. */
|
|
113
113
|
peak_index: number;
|
|
114
114
|
files_scanned: string[];
|
|
115
|
+
/**
|
|
116
|
+
* 0.47.0 charter item 2: when `--since` was NOT specified and the
|
|
117
|
+
* audit log spans more than `MAX_BUCKETS` buckets at the requested
|
|
118
|
+
* cadence, the timeline auto-clamps the window to the widest duration
|
|
119
|
+
* that fits. This field carries the duration string that was actually
|
|
120
|
+
* applied (e.g. `"7d"`) — `null` when no clamping fired (the common
|
|
121
|
+
* case). Dashboard consumers use this to flag "the window you saw is
|
|
122
|
+
* not the whole log" in their UI.
|
|
123
|
+
*/
|
|
124
|
+
clamped_since: string | null;
|
|
115
125
|
}
|
|
116
126
|
export interface ComputeAuditTimelineOptions {
|
|
117
127
|
/** Override CWD. Tests set this; production uses `process.cwd()`. */
|
|
@@ -134,6 +144,16 @@ export interface ComputeAuditTimelineOptions {
|
|
|
134
144
|
* value but `MAX_BUCKETS` will bound the rendered output.
|
|
135
145
|
*/
|
|
136
146
|
export declare function resolveBucketSeconds(raw: string): number;
|
|
147
|
+
/**
|
|
148
|
+
* Format a duration in seconds as the coarsest single-unit compact
|
|
149
|
+
* string that round-trips through `parseDurationSeconds`. Mirrors the
|
|
150
|
+
* shape `--since` accepts (`s`/`m`/`h`/`d`/`w`).
|
|
151
|
+
*
|
|
152
|
+
* 0.47.0 charter item 1: powers the helpful-error suggestion + the
|
|
153
|
+
* auto-clamp `clamped_since` field. The largest-unit pass keeps the
|
|
154
|
+
* suggestion readable — `"21d"` not `"1814400s"`.
|
|
155
|
+
*/
|
|
156
|
+
export declare function formatDurationCompact(seconds: number): string;
|
|
137
157
|
/**
|
|
138
158
|
* Compute the bucketed timeline. Pure (read-only). Throws
|
|
139
159
|
* `AuditTimelineOptionError` on bad `--since` / `--bucket`; throws on
|
|
@@ -165,6 +165,107 @@ function alignToBucket(epochMs, bucketSeconds) {
|
|
|
165
165
|
const bucketMs = bucketSeconds * 1000;
|
|
166
166
|
return Math.floor(epochMs / bucketMs) * bucketMs;
|
|
167
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* Format a duration in seconds as the coarsest single-unit compact
|
|
170
|
+
* string that round-trips through `parseDurationSeconds`. Mirrors the
|
|
171
|
+
* shape `--since` accepts (`s`/`m`/`h`/`d`/`w`).
|
|
172
|
+
*
|
|
173
|
+
* 0.47.0 charter item 1: powers the helpful-error suggestion + the
|
|
174
|
+
* auto-clamp `clamped_since` field. The largest-unit pass keeps the
|
|
175
|
+
* suggestion readable — `"21d"` not `"1814400s"`.
|
|
176
|
+
*/
|
|
177
|
+
export function formatDurationCompact(seconds) {
|
|
178
|
+
if (!Number.isFinite(seconds) || seconds <= 0)
|
|
179
|
+
return '0s';
|
|
180
|
+
const units = [
|
|
181
|
+
['w', 60 * 60 * 24 * 7],
|
|
182
|
+
['d', 60 * 60 * 24],
|
|
183
|
+
['h', 60 * 60],
|
|
184
|
+
['m', 60],
|
|
185
|
+
['s', 1],
|
|
186
|
+
];
|
|
187
|
+
for (const [unit, factor] of units) {
|
|
188
|
+
if (seconds % factor === 0) {
|
|
189
|
+
return `${String(seconds / factor)}${unit}`;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return `${String(seconds)}s`;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* 0.47.0 charter item 1: build a helpful error message for the
|
|
196
|
+
* MAX_BUCKETS guard. Computes a concrete "use --bucket=X" and
|
|
197
|
+
* "use --since=Y" suggestion based on the actual inputs, so the
|
|
198
|
+
* operator sees the next step inline instead of having to do the
|
|
199
|
+
* division themselves.
|
|
200
|
+
*
|
|
201
|
+
* Strategy:
|
|
202
|
+
* - "Try a wider bucket" — smallest unit from {1h, 4h, 1d, 1w} that
|
|
203
|
+
* brings projected ≤ MAX_BUCKETS. Falls back to a concrete second
|
|
204
|
+
* count if no unit fits (extreme `--since`).
|
|
205
|
+
* - "Try a narrower since" — largest multiple of the requested bucket
|
|
206
|
+
* that fits under MAX_BUCKETS, rendered compactly.
|
|
207
|
+
*
|
|
208
|
+
* The error text always includes the substrings `bucket=`, `since=`,
|
|
209
|
+
* and `Try` so test assertions can pin the shape.
|
|
210
|
+
*/
|
|
211
|
+
function bucketOverflowMessage(windowSeconds, bucketSeconds, rawBucket, rawSince, sinceImplicit) {
|
|
212
|
+
const projected = Math.ceil(windowSeconds / bucketSeconds);
|
|
213
|
+
// Candidate wider buckets, in ascending size — pick the first that fits.
|
|
214
|
+
const allCandidates = [
|
|
215
|
+
['1h', 60 * 60],
|
|
216
|
+
['4h', 4 * 60 * 60],
|
|
217
|
+
['1d', 60 * 60 * 24],
|
|
218
|
+
['1w', 60 * 60 * 24 * 7],
|
|
219
|
+
];
|
|
220
|
+
const widerCandidates = allCandidates.filter((entry) => entry[1] > bucketSeconds);
|
|
221
|
+
let bucketSuggestion = null;
|
|
222
|
+
let bucketSuggestionCount = null;
|
|
223
|
+
for (const [label, secs] of widerCandidates) {
|
|
224
|
+
// Account for alignment slack: a window of N seconds at bucket
|
|
225
|
+
// size S emits up to `ceil(N/S) + 1` buckets after alignment
|
|
226
|
+
// (lower-edge + upper-edge alignment can each contribute one
|
|
227
|
+
// extra bucket vs the naive division). Codex round-4 P2 (0.47.0):
|
|
228
|
+
// suggesting a bucket where `ceil(N/S) === MAX_BUCKETS` would
|
|
229
|
+
// still re-throw at the post-alignment guard. Use the +1
|
|
230
|
+
// worst-case so the operator's retry actually succeeds.
|
|
231
|
+
const cnt = Math.ceil(windowSeconds / secs) + 1;
|
|
232
|
+
if (cnt <= MAX_BUCKETS) {
|
|
233
|
+
bucketSuggestion = label;
|
|
234
|
+
bucketSuggestionCount = cnt;
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// Largest --since that fits at the requested bucket size, rendered
|
|
239
|
+
// compactly. Subtract one bucket so the suggested value survives the
|
|
240
|
+
// post-alignment guard regardless of where `now` falls on the
|
|
241
|
+
// bucket lattice — codex round-1 P2 (0.47.0): a window of exactly
|
|
242
|
+
// `MAX_BUCKETS * bucketSeconds` aligns to MAX_BUCKETS+1 buckets in
|
|
243
|
+
// the common case (`now` not already on a boundary), so the
|
|
244
|
+
// operator pasting our suggestion would hit the same error they
|
|
245
|
+
// just got. `(MAX_BUCKETS - 1) * bucketSeconds` leaves alignment
|
|
246
|
+
// slack on either edge.
|
|
247
|
+
const fittingSinceSeconds = (MAX_BUCKETS - 1) * bucketSeconds;
|
|
248
|
+
const sinceSuggestion = formatDurationCompact(fittingSinceSeconds);
|
|
249
|
+
const sinceSuggestionCount = Math.floor(fittingSinceSeconds / bucketSeconds);
|
|
250
|
+
const parts = [];
|
|
251
|
+
const reason = sinceImplicit
|
|
252
|
+
? `--since not specified; defaulting to full audit log (${rawSince}) at --bucket=${rawBucket} = ${String(projected)} buckets exceeds MAX_BUCKETS=${String(MAX_BUCKETS)}.`
|
|
253
|
+
: `--bucket=${rawBucket} × --since=${rawSince} = ${String(projected)} buckets exceeds MAX_BUCKETS=${String(MAX_BUCKETS)}.`;
|
|
254
|
+
parts.push(reason);
|
|
255
|
+
const suggestions = [];
|
|
256
|
+
if (bucketSuggestion !== null && bucketSuggestionCount !== null) {
|
|
257
|
+
suggestions.push(`--bucket=${bucketSuggestion} (${String(bucketSuggestionCount)} buckets)`);
|
|
258
|
+
}
|
|
259
|
+
suggestions.push(`--since=${sinceSuggestion} (${String(sinceSuggestionCount)} buckets)`);
|
|
260
|
+
parts.push(`Try ${suggestions.join(' or ')}.`);
|
|
261
|
+
return parts.join(' ');
|
|
262
|
+
}
|
|
263
|
+
// 0.47.0 round-3 (codex P1+P2): the pre-scan `measureLogBounds`
|
|
264
|
+
// helper was removed. The all-time auto-clamp now runs as a
|
|
265
|
+
// post-scan recovery against observed bucket keys (the only data
|
|
266
|
+
// that can't be fooled by caller-supplied timestamps or empty
|
|
267
|
+
// current-file edge cases). See the "post-scan auto-clamp" branch
|
|
268
|
+
// inside `computeAuditTimeline`.
|
|
168
269
|
/**
|
|
169
270
|
* Compute the bucketed timeline. Pure (read-only). Throws
|
|
170
271
|
* `AuditTimelineOptionError` on bad `--since` / `--bucket`; throws on
|
|
@@ -181,6 +282,7 @@ export async function computeAuditTimeline(options = {}) {
|
|
|
181
282
|
let windowSeconds = null;
|
|
182
283
|
let windowStart = null;
|
|
183
284
|
let windowEnd = null;
|
|
285
|
+
let clampedSince = null;
|
|
184
286
|
if (options.since !== undefined && options.since.length > 0) {
|
|
185
287
|
try {
|
|
186
288
|
windowSeconds = parseDurationSeconds(options.since);
|
|
@@ -194,16 +296,28 @@ export async function computeAuditTimeline(options = {}) {
|
|
|
194
296
|
windowEnd = now;
|
|
195
297
|
windowStart = new Date(now.getTime() - windowSeconds * 1000);
|
|
196
298
|
// Guard against runaway bucket counts under a wide --since with a
|
|
197
|
-
// tiny --bucket.
|
|
198
|
-
//
|
|
299
|
+
// tiny --bucket. 0.47.0 charter item 1: deliver a concrete-suggestion
|
|
300
|
+
// error rather than the prior "use a larger --bucket or narrower
|
|
301
|
+
// --since" generic line — the operator should see the next step
|
|
302
|
+
// inline.
|
|
199
303
|
const projected = Math.ceil(windowSeconds / bucketSeconds);
|
|
200
304
|
if (projected > MAX_BUCKETS) {
|
|
201
|
-
throw new AuditTimelineOptionError(
|
|
202
|
-
`${String(projected)} buckets (max ${String(MAX_BUCKETS)}). ` +
|
|
203
|
-
`Use a larger --bucket or narrower --since.`);
|
|
305
|
+
throw new AuditTimelineOptionError(bucketOverflowMessage(windowSeconds, bucketSeconds, bucketRaw, options.since, false));
|
|
204
306
|
}
|
|
205
307
|
}
|
|
206
308
|
const files = await resolveTimelineFileWalk(baseDir);
|
|
309
|
+
// 0.47.0 charter item 2: auto-clamp on long-history repos is
|
|
310
|
+
// implemented as a POST-SCAN recovery, not a pre-scan guess. Codex
|
|
311
|
+
// round-3 P1: a pre-scan clamp based on `latestMs - earliestMs`
|
|
312
|
+
// span is wrong for the no-`--since` path, which normally emits
|
|
313
|
+
// ONLY event-bearing buckets. A sparse long-lived repo (two
|
|
314
|
+
// records a year apart at `--bucket=1h`) has a 365d span but a
|
|
315
|
+
// 2-bucket result — pre-clamping would incorrectly drop one of
|
|
316
|
+
// those events. We must let the scan see what bucket count the
|
|
317
|
+
// actual records produce, then clamp only if that exceeds
|
|
318
|
+
// MAX_BUCKETS. The clamp anchor uses the busiest in-data range
|
|
319
|
+
// (max observed timestamp + alignment buffer), so we never have
|
|
320
|
+
// to guess from disk metadata. See the post-scan branch below.
|
|
207
321
|
// Bucket key is the aligned epoch-ms boundary; value is the count.
|
|
208
322
|
const buckets = new Map();
|
|
209
323
|
let totalEvents = 0;
|
|
@@ -275,8 +389,19 @@ export async function computeAuditTimeline(options = {}) {
|
|
|
275
389
|
// the renderer.
|
|
276
390
|
const emit = Math.floor((endKey - startKey) / stepMs) + 1;
|
|
277
391
|
if (emit > MAX_BUCKETS) {
|
|
278
|
-
|
|
279
|
-
|
|
392
|
+
// Post-alignment overflow is a near-miss vs the pre-scan
|
|
393
|
+
// projection check (alignment can add at most one bucket on
|
|
394
|
+
// either edge). Codex round-6 P3 (0.47.0): the helpful-error
|
|
395
|
+
// builder previously recomputed the projected count from
|
|
396
|
+
// `windowSeconds` and could end up saying "= 2000 buckets
|
|
397
|
+
// exceeds MAX_BUCKETS=2000" when the actual post-alignment
|
|
398
|
+
// count was 2001. Inflate `windowSeconds` by enough to make
|
|
399
|
+
// the projection match the actual aligned emit count — that
|
|
400
|
+
// way the operator sees a consistent number, and the
|
|
401
|
+
// remediation suggestions inherit the same +1 bias.
|
|
402
|
+
const effectiveSince = clampedSince ?? (options.since ?? formatDurationCompact(windowSeconds ?? 0));
|
|
403
|
+
const reportedSeconds = Math.max(windowSeconds ?? 0, (emit - 1) * bucketSeconds + 1);
|
|
404
|
+
throw new AuditTimelineOptionError(bucketOverflowMessage(reportedSeconds, bucketSeconds, bucketRaw, effectiveSince, options.since === undefined || options.since.length === 0));
|
|
280
405
|
}
|
|
281
406
|
for (let k = startKey; k <= endKey; k += stepMs) {
|
|
282
407
|
result.push({
|
|
@@ -288,20 +413,107 @@ export async function computeAuditTimeline(options = {}) {
|
|
|
288
413
|
}
|
|
289
414
|
else if (buckets.size > 0) {
|
|
290
415
|
const keys = Array.from(buckets.keys()).sort((a, b) => a - b);
|
|
416
|
+
const stepMs = bucketSeconds * 1000;
|
|
291
417
|
if (keys.length > MAX_BUCKETS) {
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
|
|
296
|
-
|
|
418
|
+
// 0.47.0 charter item 2 (post-scan auto-clamp): the actual
|
|
419
|
+
// observed bucket count exceeds MAX_BUCKETS. The no-`--since`
|
|
420
|
+
// path emits only event-bearing buckets (not a zero-filled
|
|
421
|
+
// lattice), so clamping by a contiguous time window would
|
|
422
|
+
// discard most of the newest activity on SPARSE logs (codex
|
|
423
|
+
// round-5 P1: e.g. one record/day across 2001 days at
|
|
424
|
+
// --bucket=1h, a time-window clamp keeps ~83 buckets — the
|
|
425
|
+
// newest-2000-keys clamp keeps all 2000 newest event-bearing
|
|
426
|
+
// buckets). The right shape: slice the keys array to the newest
|
|
427
|
+
// MAX_BUCKETS entries directly.
|
|
428
|
+
//
|
|
429
|
+
// Codex round-3 P1: clamp on OBSERVED data, not guessed span.
|
|
430
|
+
// Codex round-3 P2: observed-max timestamp sidesteps both the
|
|
431
|
+
// empty-current-file case and the out-of-order-timestamp case.
|
|
432
|
+
// Codex round-4 P2: full MAX_BUCKETS budget (the +1 alignment
|
|
433
|
+
// slack doesn't apply when cherry-picking from observed keys).
|
|
434
|
+
const fittingBuckets = MAX_BUCKETS;
|
|
435
|
+
const kept = keys.slice(keys.length - fittingBuckets);
|
|
436
|
+
const startKey = kept[0];
|
|
437
|
+
const anchorKey = kept[kept.length - 1];
|
|
438
|
+
// Determine whether the kept buckets form a CONTIGUOUS lattice
|
|
439
|
+
// (every bucket between startKey and anchorKey is present) or a
|
|
440
|
+
// SPARSE one (gaps inside). The no-`--since` path emits only
|
|
441
|
+
// event-bearing buckets, so a sparse clamp is the common case.
|
|
442
|
+
// Codex round-6 P2 (0.47.0): if we filled `window.start/end/
|
|
443
|
+
// seconds` with the bucket span of a sparse clamp, the JSON
|
|
444
|
+
// would lie to dashboard consumers — `total_events / window.
|
|
445
|
+
// seconds` would derive a wildly-wrong rate, and the operator
|
|
446
|
+
// could NOT reproduce the view by re-running with
|
|
447
|
+
// `--since=<clamped_since>` (it would either error or include
|
|
448
|
+
// far more buckets). Treat the two shapes distinctly:
|
|
449
|
+
// - contiguous: report the time-window shape (operator can
|
|
450
|
+
// paste `--since=<clamped_since>` to reproduce).
|
|
451
|
+
// - sparse: leave `window` null (no reproducible duration
|
|
452
|
+
// exists), but still report `clamped_since` so callers
|
|
453
|
+
// know the kept-bucket count was budgeted.
|
|
454
|
+
const expectedContiguousCount = Math.floor((anchorKey - startKey) / stepMs) + 1;
|
|
455
|
+
const isContiguous = expectedContiguousCount === kept.length;
|
|
456
|
+
// 0.47.0 charter item 2: `clamped_since` is ALWAYS a duration
|
|
457
|
+
// string (per the charter `clamped_since: "<DUR>"` contract).
|
|
458
|
+
// It carries the approximate time span the rendered window
|
|
459
|
+
// covers — informative, not necessarily paste-back-safe.
|
|
460
|
+
//
|
|
461
|
+
// Codex round-8 P2 (0.47.0): on stale logs (latest record
|
|
462
|
+
// hours/days ago) `--since=<DUR>` would NOT reproduce the
|
|
463
|
+
// returned data because `--since` always anchors on `now`,
|
|
464
|
+
// not on the audit's latest record. The reproducibility
|
|
465
|
+
// promise we entertained briefly across rounds 7-8 is
|
|
466
|
+
// inherently unsound — `--since` semantics fix one side of
|
|
467
|
+
// the window (now), so any clamp anchored at an older
|
|
468
|
+
// timestamp can't round-trip through it. The `note:` line in
|
|
469
|
+
// human output now describes the field as APPROXIMATE
|
|
470
|
+
// rather than pasteable.
|
|
471
|
+
//
|
|
472
|
+
// Codex round-8 P2 (0.47.0): the sparse-clamp branch
|
|
473
|
+
// previously emitted `"newest 2000 buckets"` for clarity, but
|
|
474
|
+
// that broke the documented `<DUR>` shape — dashboards trying
|
|
475
|
+
// to parse it as a duration would fail only on sparse logs.
|
|
476
|
+
// Both branches now emit a duration string; the human note
|
|
477
|
+
// adds the "sparse" qualifier so operators understand what
|
|
478
|
+
// they're looking at.
|
|
479
|
+
const spanSeconds = Math.max(bucketSeconds, Math.ceil((anchorKey - startKey) / 1000) + bucketSeconds);
|
|
480
|
+
clampedSince = formatDurationCompact(spanSeconds);
|
|
481
|
+
// For contiguous clamps, also fill window.* so consumers can
|
|
482
|
+
// compute rates against a real duration. For sparse clamps,
|
|
483
|
+
// window.* stays null — `total_events / window.seconds` would
|
|
484
|
+
// be meaningless when the kept buckets don't form a contiguous
|
|
485
|
+
// lattice.
|
|
486
|
+
if (isContiguous) {
|
|
487
|
+
windowSeconds = spanSeconds;
|
|
488
|
+
windowEnd = new Date(anchorKey + stepMs);
|
|
489
|
+
windowStart = new Date(startKey);
|
|
490
|
+
}
|
|
491
|
+
// Track the contiguous-vs-sparse shape so the renderer can
|
|
492
|
+
// surface the right note.
|
|
493
|
+
void expectedContiguousCount; // used above via isContiguous
|
|
494
|
+
// Emit each kept bucket. total_events under the post-scan clamp
|
|
495
|
+
// path counts only what the rendered buckets contain — older
|
|
496
|
+
// sliced-out buckets contribute nothing to the report.
|
|
497
|
+
let inWindow = 0;
|
|
498
|
+
for (const k of kept) {
|
|
499
|
+
const cnt = buckets.get(k) ?? 0;
|
|
500
|
+
result.push({
|
|
501
|
+
start: new Date(k).toISOString(),
|
|
502
|
+
end: new Date(k + stepMs).toISOString(),
|
|
503
|
+
count: cnt,
|
|
504
|
+
});
|
|
505
|
+
inWindow += cnt;
|
|
506
|
+
}
|
|
507
|
+
totalEvents = inWindow;
|
|
297
508
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
509
|
+
else {
|
|
510
|
+
for (const k of keys) {
|
|
511
|
+
result.push({
|
|
512
|
+
start: new Date(k).toISOString(),
|
|
513
|
+
end: new Date(k + stepMs).toISOString(),
|
|
514
|
+
count: buckets.get(k) ?? 0,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
305
517
|
}
|
|
306
518
|
}
|
|
307
519
|
// Peak index. -1 when no events at all (every bucket is 0 or the
|
|
@@ -329,6 +541,7 @@ export async function computeAuditTimeline(options = {}) {
|
|
|
329
541
|
total_events: totalEvents,
|
|
330
542
|
peak_index: peakIndex,
|
|
331
543
|
files_scanned: filesScanned,
|
|
544
|
+
clamped_since: clampedSince,
|
|
332
545
|
};
|
|
333
546
|
}
|
|
334
547
|
/**
|
|
@@ -381,10 +594,39 @@ function formatWindowLabel(seconds) {
|
|
|
381
594
|
*/
|
|
382
595
|
export function renderAuditTimeline(result) {
|
|
383
596
|
const lines = [];
|
|
384
|
-
|
|
597
|
+
// Codex round-9 P2 (0.47.0): when auto-clamp fires, the regular
|
|
598
|
+
// `last <DUR>` header (derived from `window.seconds`) is wrong —
|
|
599
|
+
// contiguous stale logs would print "last 1d" even though the
|
|
600
|
+
// newest event was days ago, and sparse clamps would fall back
|
|
601
|
+
// to "all time". Use a clamp-aware header that describes the
|
|
602
|
+
// returned shape instead.
|
|
385
603
|
const cadenceLabel = bucketLabel(result.bucket.seconds, result.bucket.raw);
|
|
604
|
+
let windowLabel;
|
|
605
|
+
if (result.clamped_since !== null) {
|
|
606
|
+
windowLabel = `clamped to ~${result.clamped_since} of newest activity`;
|
|
607
|
+
}
|
|
608
|
+
else {
|
|
609
|
+
windowLabel = formatWindowLabel(result.window.seconds);
|
|
610
|
+
}
|
|
386
611
|
lines.push(`rea audit timeline (${windowLabel}, ${cadenceLabel})`);
|
|
387
612
|
lines.push('─'.repeat(40));
|
|
613
|
+
// 0.47.0 charter item 2: surface the auto-clamp inline. Operators
|
|
614
|
+
// scanning the rendered output should immediately see that the
|
|
615
|
+
// window they got isn't the full audit log. Codex round-8 P2
|
|
616
|
+
// (0.47.0): `clamped_since` is informational, not reproducible —
|
|
617
|
+
// `--since=DUR` anchors at `now`, so a clamp anchored at an older
|
|
618
|
+
// record can't round-trip. Codex round-9 P3 (0.47.0): only a
|
|
619
|
+
// WIDER `--bucket` actually changes the result — pinning the same
|
|
620
|
+
// bucket would just re-trigger the clamp. The remediation
|
|
621
|
+
// suggestion names "wider" explicitly to avoid sending operators
|
|
622
|
+
// down a no-op retry path.
|
|
623
|
+
if (result.clamped_since !== null) {
|
|
624
|
+
lines.push(`note: --since not specified; auto-clamped to newest ${String(MAX_BUCKETS)} buckets ` +
|
|
625
|
+
`(~${result.clamped_since} span at --bucket=${result.bucket.raw}). ` +
|
|
626
|
+
`Pass --since=DUR to anchor at now, or rerun with a WIDER --bucket ` +
|
|
627
|
+
`(current ${result.bucket.raw}) to fit the full log.`);
|
|
628
|
+
lines.push('');
|
|
629
|
+
}
|
|
388
630
|
// Codex round-1 P2 (0.46.0): the zero-events case has two distinct
|
|
389
631
|
// shapes and the renderer must NOT collapse them.
|
|
390
632
|
//
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rea audit top-blocks [--since=DUR] [--limit=N] [--json]` — 0.47.0
|
|
3
|
+
* charter item 3.
|
|
4
|
+
*
|
|
5
|
+
* Surface the most recent refusal events from the audit log. Designed
|
|
6
|
+
* for the question "why was that refused?" — operators see the latest
|
|
7
|
+
* blocks at a glance with enough context (timestamp, tool, reason) to
|
|
8
|
+
* grep the offending Bash/Edit/Write call site or fix the policy that
|
|
9
|
+
* tripped the gate.
|
|
10
|
+
*
|
|
11
|
+
* A "refusal" in the rea audit schema is any record whose
|
|
12
|
+
* `InvocationStatus` is NOT `Allowed` — that's `Denied` (policy
|
|
13
|
+
* refused) OR `Error` (middleware exception or downstream failure).
|
|
14
|
+
* Both are interesting to operators debugging "why didn't this run".
|
|
15
|
+
*
|
|
16
|
+
* # Walk scope
|
|
17
|
+
*
|
|
18
|
+
* Mirrors `audit summary` / `audit by-tool` / `audit timeline`: the
|
|
19
|
+
* current `.rea/audit.jsonl` PLUS every rotated `audit-…jsonl` segment
|
|
20
|
+
* is walked regardless of `--since` (the per-record timestamp filter
|
|
21
|
+
* inside the main loop decides what counts). Rotated filename stamps
|
|
22
|
+
* mark the rotation INSTANT, not the earliest record contained
|
|
23
|
+
* (0.41.0 round-3 P2 / 0.42.0 charter item 3) — pruning by filename
|
|
24
|
+
* would silently drop in-window records from conservatively-rotated
|
|
25
|
+
* logs. Walking every segment is the only sound shape.
|
|
26
|
+
*
|
|
27
|
+
* # Output (default)
|
|
28
|
+
*
|
|
29
|
+
* rea audit top-blocks (last 24h, limit 20)
|
|
30
|
+
* ─────────────────────────────────────────
|
|
31
|
+
* a1b2c3d4 2026-05-17T12:34:56.789Z Bash rm -rf bypass attempted (...)
|
|
32
|
+
* deadbeef 2026-05-17T11:20:01.123Z Write blocked-path .env write
|
|
33
|
+
* …
|
|
34
|
+
* total: 4 refusal events in window
|
|
35
|
+
* files scanned: 2
|
|
36
|
+
*
|
|
37
|
+
* # JSON output
|
|
38
|
+
*
|
|
39
|
+
* {
|
|
40
|
+
* "schema_version": 1,
|
|
41
|
+
* "since": "24h",
|
|
42
|
+
* "limit": 20,
|
|
43
|
+
* "window": { "seconds": 86400, "start": "...", "end": "..." },
|
|
44
|
+
* "total_matched": 4,
|
|
45
|
+
* "events": [
|
|
46
|
+
* { "hash": "a1b2c3d4...", "timestamp": "...", "tool": "Bash",
|
|
47
|
+
* "status": "denied", "reason": "rm -rf bypass attempted (...)" },
|
|
48
|
+
* …
|
|
49
|
+
* ],
|
|
50
|
+
* "files_scanned": ["/abs/path/.rea/audit.jsonl"]
|
|
51
|
+
* }
|
|
52
|
+
*
|
|
53
|
+
* `total_matched` is the pre-limit count so dashboards can show "20 of
|
|
54
|
+
* 47 refusals in window". `events` is sorted newest-first and capped at
|
|
55
|
+
* `limit`.
|
|
56
|
+
*/
|
|
57
|
+
import type { Command } from 'commander';
|
|
58
|
+
export declare const AUDIT_TOP_BLOCKS_SCHEMA_VERSION = 1;
|
|
59
|
+
/** Default `--limit` value. 20 fits a debugging session's eyeballable window. */
|
|
60
|
+
export declare const DEFAULT_LIMIT = 20;
|
|
61
|
+
/**
|
|
62
|
+
* Hard ceiling on `--limit`. Refusal events are typically a small slice
|
|
63
|
+
* of total traffic, but a 1000 cap keeps the renderer / JSON output
|
|
64
|
+
* bounded under a runaway misconfiguration that's denying everything.
|
|
65
|
+
*/
|
|
66
|
+
export declare const MAX_LIMIT = 1000;
|
|
67
|
+
/**
|
|
68
|
+
* Thrown when `--limit` is outside [1, MAX_LIMIT] or `--since` fails to
|
|
69
|
+
* parse. The commander wrapper catches and exits 1. Distinct from
|
|
70
|
+
* `AuditByToolOptionError` / `AuditTimelineOptionError` so the
|
|
71
|
+
* caller-facing message names the right flag.
|
|
72
|
+
*/
|
|
73
|
+
export declare class AuditTopBlocksOptionError extends Error {
|
|
74
|
+
constructor(message: string);
|
|
75
|
+
}
|
|
76
|
+
export interface AuditTopBlocksEvent {
|
|
77
|
+
/** Full sha256 hash from the audit record — stable cross-tool ID. */
|
|
78
|
+
hash: string;
|
|
79
|
+
/** Raw ISO-8601 timestamp from the record. */
|
|
80
|
+
timestamp: string;
|
|
81
|
+
/** Tool name as recorded; `(unknown)` for missing/empty. */
|
|
82
|
+
tool: string;
|
|
83
|
+
/** Raw `status` value (`denied` / `error`). */
|
|
84
|
+
status: string;
|
|
85
|
+
/**
|
|
86
|
+
* Best-effort human-readable reason. Sourced from the record's
|
|
87
|
+
* `error` field when present, else a synthesized "<status>: <tool>"
|
|
88
|
+
* fallback so the row carries SOMETHING informative even when the
|
|
89
|
+
* middleware didn't attach an error message.
|
|
90
|
+
*/
|
|
91
|
+
reason: string;
|
|
92
|
+
/** Session ID from the record; useful for cross-referencing. */
|
|
93
|
+
session_id: string;
|
|
94
|
+
}
|
|
95
|
+
export interface AuditTopBlocksResult {
|
|
96
|
+
schema_version: typeof AUDIT_TOP_BLOCKS_SCHEMA_VERSION;
|
|
97
|
+
/** Raw `--since` value as passed by the caller (`null` when omitted). */
|
|
98
|
+
since: string | null;
|
|
99
|
+
/** Resolved `--limit` actually used. */
|
|
100
|
+
limit: number;
|
|
101
|
+
window: {
|
|
102
|
+
seconds: number | null;
|
|
103
|
+
start: string | null;
|
|
104
|
+
end: string | null;
|
|
105
|
+
};
|
|
106
|
+
/** Pre-limit count of refusal records in window. */
|
|
107
|
+
total_matched: number;
|
|
108
|
+
/** Sorted newest-first; capped at `limit`. */
|
|
109
|
+
events: AuditTopBlocksEvent[];
|
|
110
|
+
/** Absolute paths of audit files actually read. */
|
|
111
|
+
files_scanned: string[];
|
|
112
|
+
}
|
|
113
|
+
export interface ComputeAuditTopBlocksOptions {
|
|
114
|
+
/** Override CWD. Tests set this; production uses `process.cwd()`. */
|
|
115
|
+
baseDir?: string;
|
|
116
|
+
/** Raw `--since` value (e.g. `24h`, `7d`). Parsed via parseDuration. */
|
|
117
|
+
since?: string;
|
|
118
|
+
/** Raw `--limit` value. Default `DEFAULT_LIMIT`. */
|
|
119
|
+
limit?: number;
|
|
120
|
+
/** Test seam — pin "now" for deterministic window calculations. */
|
|
121
|
+
now?: Date;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Compute the top-blocks list. Pure (read-only). Throws
|
|
125
|
+
* `AuditTopBlocksOptionError` on bad `--since` / `--limit`.
|
|
126
|
+
*/
|
|
127
|
+
export declare function computeAuditTopBlocks(options?: ComputeAuditTopBlocksOptions): Promise<AuditTopBlocksResult>;
|
|
128
|
+
/**
|
|
129
|
+
* Render the result as a human-readable terminal block. JSON callers
|
|
130
|
+
* bypass this; the rendering is intentionally minimal — a fixed-column
|
|
131
|
+
* table that scans cleanly in a typical terminal.
|
|
132
|
+
*/
|
|
133
|
+
export declare function renderAuditTopBlocks(result: AuditTopBlocksResult): string;
|
|
134
|
+
export interface RunAuditTopBlocksOptions {
|
|
135
|
+
since?: string;
|
|
136
|
+
limit?: number;
|
|
137
|
+
json?: boolean;
|
|
138
|
+
/** Test seam — pin "now". */
|
|
139
|
+
now?: Date;
|
|
140
|
+
}
|
|
141
|
+
/** Commander entrypoint. */
|
|
142
|
+
export declare function runAuditTopBlocks(options: RunAuditTopBlocksOptions): Promise<void>;
|
|
143
|
+
/**
|
|
144
|
+
* Strict integer parser for the commander `--limit <n>` option.
|
|
145
|
+
*
|
|
146
|
+
* Mirrors the `parseTopOption` discipline in `audit-by-tool.ts`:
|
|
147
|
+
* reject anything that isn't a bare integer so `Number.parseInt`
|
|
148
|
+
* can't silently truncate (`1.5` → `1`, `10abc` → `10`).
|
|
149
|
+
*/
|
|
150
|
+
export declare function parseLimitOption(raw: string): number;
|
|
151
|
+
/**
|
|
152
|
+
* Register `rea audit top-blocks` under the `audit` command group.
|
|
153
|
+
*/
|
|
154
|
+
export declare function registerAuditTopBlocksCommand(auditCommand: Command): void;
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rea audit top-blocks [--since=DUR] [--limit=N] [--json]` — 0.47.0
|
|
3
|
+
* charter item 3.
|
|
4
|
+
*
|
|
5
|
+
* Surface the most recent refusal events from the audit log. Designed
|
|
6
|
+
* for the question "why was that refused?" — operators see the latest
|
|
7
|
+
* blocks at a glance with enough context (timestamp, tool, reason) to
|
|
8
|
+
* grep the offending Bash/Edit/Write call site or fix the policy that
|
|
9
|
+
* tripped the gate.
|
|
10
|
+
*
|
|
11
|
+
* A "refusal" in the rea audit schema is any record whose
|
|
12
|
+
* `InvocationStatus` is NOT `Allowed` — that's `Denied` (policy
|
|
13
|
+
* refused) OR `Error` (middleware exception or downstream failure).
|
|
14
|
+
* Both are interesting to operators debugging "why didn't this run".
|
|
15
|
+
*
|
|
16
|
+
* # Walk scope
|
|
17
|
+
*
|
|
18
|
+
* Mirrors `audit summary` / `audit by-tool` / `audit timeline`: the
|
|
19
|
+
* current `.rea/audit.jsonl` PLUS every rotated `audit-…jsonl` segment
|
|
20
|
+
* is walked regardless of `--since` (the per-record timestamp filter
|
|
21
|
+
* inside the main loop decides what counts). Rotated filename stamps
|
|
22
|
+
* mark the rotation INSTANT, not the earliest record contained
|
|
23
|
+
* (0.41.0 round-3 P2 / 0.42.0 charter item 3) — pruning by filename
|
|
24
|
+
* would silently drop in-window records from conservatively-rotated
|
|
25
|
+
* logs. Walking every segment is the only sound shape.
|
|
26
|
+
*
|
|
27
|
+
* # Output (default)
|
|
28
|
+
*
|
|
29
|
+
* rea audit top-blocks (last 24h, limit 20)
|
|
30
|
+
* ─────────────────────────────────────────
|
|
31
|
+
* a1b2c3d4 2026-05-17T12:34:56.789Z Bash rm -rf bypass attempted (...)
|
|
32
|
+
* deadbeef 2026-05-17T11:20:01.123Z Write blocked-path .env write
|
|
33
|
+
* …
|
|
34
|
+
* total: 4 refusal events in window
|
|
35
|
+
* files scanned: 2
|
|
36
|
+
*
|
|
37
|
+
* # JSON output
|
|
38
|
+
*
|
|
39
|
+
* {
|
|
40
|
+
* "schema_version": 1,
|
|
41
|
+
* "since": "24h",
|
|
42
|
+
* "limit": 20,
|
|
43
|
+
* "window": { "seconds": 86400, "start": "...", "end": "..." },
|
|
44
|
+
* "total_matched": 4,
|
|
45
|
+
* "events": [
|
|
46
|
+
* { "hash": "a1b2c3d4...", "timestamp": "...", "tool": "Bash",
|
|
47
|
+
* "status": "denied", "reason": "rm -rf bypass attempted (...)" },
|
|
48
|
+
* …
|
|
49
|
+
* ],
|
|
50
|
+
* "files_scanned": ["/abs/path/.rea/audit.jsonl"]
|
|
51
|
+
* }
|
|
52
|
+
*
|
|
53
|
+
* `total_matched` is the pre-limit count so dashboards can show "20 of
|
|
54
|
+
* 47 refusals in window". `events` is sorted newest-first and capped at
|
|
55
|
+
* `limit`.
|
|
56
|
+
*/
|
|
57
|
+
import fs from 'node:fs/promises';
|
|
58
|
+
import path from 'node:path';
|
|
59
|
+
import { listRotatedAuditFiles } from './audit-specialists.js';
|
|
60
|
+
import { AuditSummarySinceError, parseDurationSeconds, } from './audit-summary.js';
|
|
61
|
+
import { AUDIT_FILE, REA_DIR, err } from './utils.js';
|
|
62
|
+
export const AUDIT_TOP_BLOCKS_SCHEMA_VERSION = 1;
|
|
63
|
+
/** Default `--limit` value. 20 fits a debugging session's eyeballable window. */
|
|
64
|
+
export const DEFAULT_LIMIT = 20;
|
|
65
|
+
/**
|
|
66
|
+
* Hard ceiling on `--limit`. Refusal events are typically a small slice
|
|
67
|
+
* of total traffic, but a 1000 cap keeps the renderer / JSON output
|
|
68
|
+
* bounded under a runaway misconfiguration that's denying everything.
|
|
69
|
+
*/
|
|
70
|
+
export const MAX_LIMIT = 1000;
|
|
71
|
+
/** Max characters of refusal reason to display per row before truncation. */
|
|
72
|
+
const REASON_TRUNCATE = 80;
|
|
73
|
+
/** Short-hash prefix length for the displayed event ID. */
|
|
74
|
+
const SHORT_HASH_LEN = 8;
|
|
75
|
+
/**
|
|
76
|
+
* Thrown when `--limit` is outside [1, MAX_LIMIT] or `--since` fails to
|
|
77
|
+
* parse. The commander wrapper catches and exits 1. Distinct from
|
|
78
|
+
* `AuditByToolOptionError` / `AuditTimelineOptionError` so the
|
|
79
|
+
* caller-facing message names the right flag.
|
|
80
|
+
*/
|
|
81
|
+
export class AuditTopBlocksOptionError extends Error {
|
|
82
|
+
constructor(message) {
|
|
83
|
+
super(message);
|
|
84
|
+
this.name = 'AuditTopBlocksOptionError';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Resolve the audit files to walk. Identical strategy to the sibling
|
|
89
|
+
* audit commands — inlined to keep the public surface of
|
|
90
|
+
* `audit-summary.ts` narrow.
|
|
91
|
+
*/
|
|
92
|
+
async function resolveTopBlocksFileWalk(baseDir) {
|
|
93
|
+
const reaDir = path.join(baseDir, REA_DIR);
|
|
94
|
+
const currentAudit = path.join(reaDir, AUDIT_FILE);
|
|
95
|
+
const files = [];
|
|
96
|
+
const rotated = await listRotatedAuditFiles(reaDir);
|
|
97
|
+
for (const name of rotated)
|
|
98
|
+
files.push(path.join(reaDir, name));
|
|
99
|
+
try {
|
|
100
|
+
const stat = await fs.stat(currentAudit);
|
|
101
|
+
if (stat.isFile())
|
|
102
|
+
files.push(currentAudit);
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
if (e.code !== 'ENOENT')
|
|
106
|
+
throw e;
|
|
107
|
+
}
|
|
108
|
+
return files;
|
|
109
|
+
}
|
|
110
|
+
function parseTimestamp(raw) {
|
|
111
|
+
if (typeof raw !== 'string')
|
|
112
|
+
return null;
|
|
113
|
+
const d = new Date(raw);
|
|
114
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Decide whether an audit record represents a refusal. The rea
|
|
118
|
+
* InvocationStatus enum has three values (`allowed`, `denied`,
|
|
119
|
+
* `error`); refusals are the non-`allowed` set. We accept any other
|
|
120
|
+
* string here too so a future status enum extension (or an unusual
|
|
121
|
+
* consumer-emitted status) surfaces in the report rather than silently
|
|
122
|
+
* dropping — the operator can decide whether the new bucket is signal
|
|
123
|
+
* or noise.
|
|
124
|
+
*/
|
|
125
|
+
function isRefusal(status) {
|
|
126
|
+
if (typeof status !== 'string')
|
|
127
|
+
return false;
|
|
128
|
+
return status !== 'allowed';
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Compute the top-blocks list. Pure (read-only). Throws
|
|
132
|
+
* `AuditTopBlocksOptionError` on bad `--since` / `--limit`.
|
|
133
|
+
*/
|
|
134
|
+
export async function computeAuditTopBlocks(options = {}) {
|
|
135
|
+
const baseDir = options.baseDir ?? process.cwd();
|
|
136
|
+
const now = options.now ?? new Date();
|
|
137
|
+
// Resolve --limit first so a bad value fails fast before any I/O.
|
|
138
|
+
const limit = options.limit ?? DEFAULT_LIMIT;
|
|
139
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
|
|
140
|
+
throw new AuditTopBlocksOptionError(`--limit: must be an integer between 1 and ${String(MAX_LIMIT)}; got ${JSON.stringify(limit)}.`);
|
|
141
|
+
}
|
|
142
|
+
let windowSeconds = null;
|
|
143
|
+
let windowStart = null;
|
|
144
|
+
let windowEnd = null;
|
|
145
|
+
if (options.since !== undefined && options.since.length > 0) {
|
|
146
|
+
try {
|
|
147
|
+
windowSeconds = parseDurationSeconds(options.since);
|
|
148
|
+
}
|
|
149
|
+
catch (e) {
|
|
150
|
+
if (e instanceof AuditSummarySinceError) {
|
|
151
|
+
throw new AuditTopBlocksOptionError(e.message);
|
|
152
|
+
}
|
|
153
|
+
throw e;
|
|
154
|
+
}
|
|
155
|
+
windowEnd = now;
|
|
156
|
+
windowStart = new Date(now.getTime() - windowSeconds * 1000);
|
|
157
|
+
}
|
|
158
|
+
const files = await resolveTopBlocksFileWalk(baseDir);
|
|
159
|
+
const filesScanned = [];
|
|
160
|
+
// Codex round-10 P2 (0.47.0): in a policy-storm scenario (many
|
|
161
|
+
// refusals, verbose `error` strings) the prior shape accumulated
|
|
162
|
+
// every match into a flat array, sorted it, then sliced to
|
|
163
|
+
// `--limit`. Memory + runtime scaled with the total refusal count
|
|
164
|
+
// — exactly the case `top-blocks` was designed to debug. The
|
|
165
|
+
// bounded-buffer shape keeps memory O(limit): we maintain a
|
|
166
|
+
// sorted "top K newest" list of size <= limit and discard the
|
|
167
|
+
// oldest entry whenever a newer one displaces it. `totalMatched`
|
|
168
|
+
// counts every in-window refusal so the JSON shape still
|
|
169
|
+
// communicates "N of M shown".
|
|
170
|
+
const topBuf = [];
|
|
171
|
+
let totalMatched = 0;
|
|
172
|
+
// Insert into the bounded buffer, keeping it sorted newest-first
|
|
173
|
+
// by parsed instant (with hash tiebreaker for determinism). Drop
|
|
174
|
+
// the oldest when capacity exceeded.
|
|
175
|
+
const insertIntoTop = (event, parsedTime) => {
|
|
176
|
+
// Find insertion point — small linear scan; for limit=20 (the
|
|
177
|
+
// default) this is cheaper than a heap and keeps the code
|
|
178
|
+
// simple. For limit=1000 (the max) we're still O(limit) per
|
|
179
|
+
// insert in the worst case, well under the prior O(N log N)
|
|
180
|
+
// sort across N matches.
|
|
181
|
+
let idx = topBuf.length;
|
|
182
|
+
for (let i = 0; i < topBuf.length; i += 1) {
|
|
183
|
+
const cur = topBuf[i];
|
|
184
|
+
if (parsedTime > cur.parsedTime ||
|
|
185
|
+
(parsedTime === cur.parsedTime && event.hash.localeCompare(cur.event.hash) < 0)) {
|
|
186
|
+
idx = i;
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (idx < limit) {
|
|
191
|
+
topBuf.splice(idx, 0, { event, parsedTime });
|
|
192
|
+
if (topBuf.length > limit)
|
|
193
|
+
topBuf.length = limit;
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
for (const filePath of files) {
|
|
197
|
+
let raw;
|
|
198
|
+
try {
|
|
199
|
+
raw = await fs.readFile(filePath, 'utf8');
|
|
200
|
+
}
|
|
201
|
+
catch (e) {
|
|
202
|
+
const errno = e.code;
|
|
203
|
+
if (errno === 'ENOENT')
|
|
204
|
+
continue;
|
|
205
|
+
// Mirror the sibling audit commands' stance: any non-ENOENT read
|
|
206
|
+
// error is fatal. A silent skip on a rotated segment that may
|
|
207
|
+
// contain in-window refusals would let `top-blocks` exit 0 with
|
|
208
|
+
// the operator's question unanswered.
|
|
209
|
+
throw new Error(`rea audit top-blocks: cannot read ${filePath} (${errno ?? 'unknown errno'}). ` +
|
|
210
|
+
`An unreadable audit segment may contain in-window records, so the ` +
|
|
211
|
+
`refusal report would be silently incomplete. Fix permissions ` +
|
|
212
|
+
`(e.g. \`chmod u+r ${filePath}\`), or move the file out of \`.rea/\` ` +
|
|
213
|
+
`if you no longer need it.`);
|
|
214
|
+
}
|
|
215
|
+
filesScanned.push(filePath);
|
|
216
|
+
for (const line of raw.split('\n')) {
|
|
217
|
+
if (line.length === 0)
|
|
218
|
+
continue;
|
|
219
|
+
let parsed;
|
|
220
|
+
try {
|
|
221
|
+
parsed = JSON.parse(line);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
// Malformed line — `rea audit verify` is the right tool. Skip
|
|
225
|
+
// so a single corrupt line doesn't tank the report.
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (!isRefusal(parsed.status))
|
|
229
|
+
continue;
|
|
230
|
+
const ts = parseTimestamp(parsed.timestamp);
|
|
231
|
+
if (windowStart !== null && (ts === null || ts < windowStart))
|
|
232
|
+
continue;
|
|
233
|
+
if (windowEnd !== null && (ts === null || ts > windowEnd))
|
|
234
|
+
continue;
|
|
235
|
+
totalMatched += 1;
|
|
236
|
+
const tool = typeof parsed.tool_name === 'string' && parsed.tool_name.length > 0
|
|
237
|
+
? parsed.tool_name
|
|
238
|
+
: '(unknown)';
|
|
239
|
+
const errorText = typeof parsed.error === 'string' && parsed.error.length > 0
|
|
240
|
+
? parsed.error
|
|
241
|
+
: `${typeof parsed.status === 'string' ? parsed.status : 'refused'}: ${tool}`;
|
|
242
|
+
const event = {
|
|
243
|
+
hash: typeof parsed.hash === 'string' ? parsed.hash : '',
|
|
244
|
+
timestamp: typeof parsed.timestamp === 'string' ? parsed.timestamp : '',
|
|
245
|
+
tool,
|
|
246
|
+
status: typeof parsed.status === 'string' ? parsed.status : '(unknown)',
|
|
247
|
+
reason: errorText,
|
|
248
|
+
session_id: typeof parsed.session_id === 'string' ? parsed.session_id : '',
|
|
249
|
+
};
|
|
250
|
+
// Codex round-2 P2 (0.47.0): parse the timestamp before
|
|
251
|
+
// comparing — `appendAuditRecord` accepts any ISO-8601 shape,
|
|
252
|
+
// so `2026-05-17T23:00:00+02:00` (= 21:00:00Z, OLDER) would
|
|
253
|
+
// lex-sort ahead of `2026-05-17T22:30:00Z` (NEWER) under a
|
|
254
|
+
// string compare.
|
|
255
|
+
const parsedTime = Date.parse(event.timestamp);
|
|
256
|
+
insertIntoTop(event, Number.isFinite(parsedTime) ? parsedTime : 0);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const capped = topBuf.map((entry) => entry.event);
|
|
260
|
+
return {
|
|
261
|
+
schema_version: AUDIT_TOP_BLOCKS_SCHEMA_VERSION,
|
|
262
|
+
since: options.since !== undefined && options.since.length > 0 ? options.since : null,
|
|
263
|
+
limit,
|
|
264
|
+
window: {
|
|
265
|
+
seconds: windowSeconds,
|
|
266
|
+
start: windowStart !== null ? windowStart.toISOString() : null,
|
|
267
|
+
end: windowEnd !== null ? windowEnd.toISOString() : null,
|
|
268
|
+
},
|
|
269
|
+
total_matched: totalMatched,
|
|
270
|
+
events: capped,
|
|
271
|
+
files_scanned: filesScanned,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Truncate a reason string to `REASON_TRUNCATE` chars for the human
|
|
276
|
+
* renderer. JSON consumers get the full string — they can render at
|
|
277
|
+
* any width.
|
|
278
|
+
*
|
|
279
|
+
* Codex round-10 P3 (0.47.0): refusal reasons often contain embedded
|
|
280
|
+
* newlines (shell stderr, Node stack traces). Writing them straight
|
|
281
|
+
* into a fixed-width row spills a single event across multiple
|
|
282
|
+
* terminal lines and breaks the hash/timestamp/tool columns. Collapse
|
|
283
|
+
* `\r`, `\n`, and tabs to single spaces FIRST, then truncate to the
|
|
284
|
+
* column width. The JSON path preserves the raw `reason` field so
|
|
285
|
+
* consumers see the full multiline message.
|
|
286
|
+
*/
|
|
287
|
+
function truncateReason(reason) {
|
|
288
|
+
const collapsed = reason.replace(/[\r\n\t]+/g, ' ').replace(/ +/g, ' ').trim();
|
|
289
|
+
if (collapsed.length <= REASON_TRUNCATE)
|
|
290
|
+
return collapsed;
|
|
291
|
+
return collapsed.slice(0, REASON_TRUNCATE - 1) + '…';
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Short-hash prefix for the displayed event ID. Falls back to the
|
|
295
|
+
* full string when it's shorter than the prefix length (degenerate
|
|
296
|
+
* inputs only — real hashes are always 64 hex chars).
|
|
297
|
+
*/
|
|
298
|
+
function shortHash(hash) {
|
|
299
|
+
if (hash.length <= SHORT_HASH_LEN)
|
|
300
|
+
return hash || '(no-hash)';
|
|
301
|
+
return hash.slice(0, SHORT_HASH_LEN);
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Compact human duration label. Mirrors the sibling audit commands.
|
|
305
|
+
*/
|
|
306
|
+
function formatDurationShort(seconds) {
|
|
307
|
+
const units = [
|
|
308
|
+
['w', 60 * 60 * 24 * 7],
|
|
309
|
+
['d', 60 * 60 * 24],
|
|
310
|
+
['h', 60 * 60],
|
|
311
|
+
['m', 60],
|
|
312
|
+
['s', 1],
|
|
313
|
+
];
|
|
314
|
+
for (const [unit, factor] of units) {
|
|
315
|
+
if (seconds % factor === 0) {
|
|
316
|
+
return `last ${String(seconds / factor)}${unit}`;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return `last ${String(seconds)}s`;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Render the result as a human-readable terminal block. JSON callers
|
|
323
|
+
* bypass this; the rendering is intentionally minimal — a fixed-column
|
|
324
|
+
* table that scans cleanly in a typical terminal.
|
|
325
|
+
*/
|
|
326
|
+
export function renderAuditTopBlocks(result) {
|
|
327
|
+
const lines = [];
|
|
328
|
+
const windowLabel = result.window.seconds !== null ? formatDurationShort(result.window.seconds) : 'all time';
|
|
329
|
+
lines.push(`rea audit top-blocks (${windowLabel}, limit ${String(result.limit)})`);
|
|
330
|
+
lines.push('─'.repeat(40));
|
|
331
|
+
if (result.total_matched === 0) {
|
|
332
|
+
lines.push(result.window.seconds !== null
|
|
333
|
+
? 'No refusal events in the requested window.'
|
|
334
|
+
: 'No refusal events in the audit log.');
|
|
335
|
+
if (result.files_scanned.length === 0) {
|
|
336
|
+
lines.push('(no audit files found — has `rea serve` ever run?)');
|
|
337
|
+
}
|
|
338
|
+
lines.push('');
|
|
339
|
+
return lines.join('\n');
|
|
340
|
+
}
|
|
341
|
+
// Stable column widths for the table:
|
|
342
|
+
// short hash (8) + 2 | timestamp (24) + 2 | tool (max in view) + 2 | reason
|
|
343
|
+
const maxToolLen = result.events.reduce((m, ev) => Math.max(m, ev.tool.length), 4);
|
|
344
|
+
for (const ev of result.events) {
|
|
345
|
+
const h = shortHash(ev.hash).padEnd(SHORT_HASH_LEN);
|
|
346
|
+
const ts = ev.timestamp.padEnd(24); // ISO-8601 with ms is 24 chars
|
|
347
|
+
const tool = ev.tool.padEnd(maxToolLen);
|
|
348
|
+
const reason = truncateReason(ev.reason);
|
|
349
|
+
lines.push(`${h} ${ts} ${tool} ${reason}`);
|
|
350
|
+
}
|
|
351
|
+
lines.push('');
|
|
352
|
+
if (result.total_matched > result.events.length) {
|
|
353
|
+
lines.push(`total: ${String(result.events.length)} of ${String(result.total_matched)} refusal events shown (--limit=${String(result.limit)})`);
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
lines.push(`total: ${String(result.total_matched)} refusal event${result.total_matched === 1 ? '' : 's'} in window`);
|
|
357
|
+
}
|
|
358
|
+
lines.push(`files scanned: ${String(result.files_scanned.length)}`);
|
|
359
|
+
lines.push('');
|
|
360
|
+
return lines.join('\n');
|
|
361
|
+
}
|
|
362
|
+
/** Commander entrypoint. */
|
|
363
|
+
export async function runAuditTopBlocks(options) {
|
|
364
|
+
let result;
|
|
365
|
+
try {
|
|
366
|
+
result = await computeAuditTopBlocks({
|
|
367
|
+
...(options.since !== undefined ? { since: options.since } : {}),
|
|
368
|
+
...(options.limit !== undefined ? { limit: options.limit } : {}),
|
|
369
|
+
...(options.now !== undefined ? { now: options.now } : {}),
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
catch (e) {
|
|
373
|
+
if (e instanceof AuditTopBlocksOptionError) {
|
|
374
|
+
err(`rea audit top-blocks: ${e.message}`);
|
|
375
|
+
process.exit(1);
|
|
376
|
+
}
|
|
377
|
+
throw e;
|
|
378
|
+
}
|
|
379
|
+
if (options.json === true) {
|
|
380
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
process.stdout.write(renderAuditTopBlocks(result));
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Strict integer parser for the commander `--limit <n>` option.
|
|
387
|
+
*
|
|
388
|
+
* Mirrors the `parseTopOption` discipline in `audit-by-tool.ts`:
|
|
389
|
+
* reject anything that isn't a bare integer so `Number.parseInt`
|
|
390
|
+
* can't silently truncate (`1.5` → `1`, `10abc` → `10`).
|
|
391
|
+
*/
|
|
392
|
+
export function parseLimitOption(raw) {
|
|
393
|
+
if (!/^-?\d+$/.test(raw.trim())) {
|
|
394
|
+
throw new AuditTopBlocksOptionError(`--limit: expected integer; got ${JSON.stringify(raw)}.`);
|
|
395
|
+
}
|
|
396
|
+
const n = Number.parseInt(raw.trim(), 10);
|
|
397
|
+
if (!Number.isFinite(n)) {
|
|
398
|
+
throw new AuditTopBlocksOptionError(`--limit: expected integer; got ${JSON.stringify(raw)}.`);
|
|
399
|
+
}
|
|
400
|
+
return n;
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Register `rea audit top-blocks` under the `audit` command group.
|
|
404
|
+
*/
|
|
405
|
+
export function registerAuditTopBlocksCommand(auditCommand) {
|
|
406
|
+
auditCommand
|
|
407
|
+
.command('top-blocks')
|
|
408
|
+
.description('Recent refusal events from the audit log — `--limit=N` (default 20, max 1000), `--since=DUR` window filter, `--json` for dashboards. Read-only.')
|
|
409
|
+
.option('--limit <n>', `cap the rendered / serialized list to the most recent N refusals (default ${String(DEFAULT_LIMIT)}, max ${String(MAX_LIMIT)})`, parseLimitOption)
|
|
410
|
+
.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).')
|
|
411
|
+
.option('--json', 'emit a JSON document instead of the human-readable table')
|
|
412
|
+
.action(async (opts) => {
|
|
413
|
+
await runAuditTopBlocks({
|
|
414
|
+
...(opts.limit !== undefined ? { limit: opts.limit } : {}),
|
|
415
|
+
...(opts.since !== undefined ? { since: opts.since } : {}),
|
|
416
|
+
...(opts.json === true ? { json: true } : {}),
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -17,6 +17,7 @@ import { runUpgradeCheck } from './upgrade-check.js';
|
|
|
17
17
|
import { registerAuditSummaryCommand } from './audit-summary.js';
|
|
18
18
|
import { registerAuditByToolCommand } from './audit-by-tool.js';
|
|
19
19
|
import { registerAuditTimelineCommand } from './audit-timeline.js';
|
|
20
|
+
import { registerAuditTopBlocksCommand } from './audit-top-blocks.js';
|
|
20
21
|
import { registerVerifyClaimCommand } from './verify-claim.js';
|
|
21
22
|
import { err, getPkgVersion } from './utils.js';
|
|
22
23
|
async function main() {
|
|
@@ -154,6 +155,10 @@ async function main() {
|
|
|
154
155
|
// [--since=DUR] [--json]`. Time-bucketed event counts with inline
|
|
155
156
|
// histogram. Useful for spotting activity spikes + cadence patterns.
|
|
156
157
|
registerAuditTimelineCommand(audit);
|
|
158
|
+
// 0.47.0 charter item 3 — `rea audit top-blocks [--limit=N]
|
|
159
|
+
// [--since=DUR] [--json]`. Most-recent refusal events (denied/error).
|
|
160
|
+
// The "why was that refused?" debugging lens.
|
|
161
|
+
registerAuditTopBlocksCommand(audit);
|
|
157
162
|
// Register `rea hook push-gate` — the stateless pre-push Codex gate
|
|
158
163
|
// called by `.husky/pre-push` and `.git/hooks/pre-push`.
|
|
159
164
|
registerHookCommand(program);
|
package/dist/config/tier-map.js
CHANGED
|
@@ -301,6 +301,38 @@ export function reaCommandTier(command) {
|
|
|
301
301
|
// Write-tier default. Codex round 3 P2 (2026-05-12).
|
|
302
302
|
if (sub2 === 'specialists')
|
|
303
303
|
return Tier.Read;
|
|
304
|
+
// 0.47.0 codex round-11 P2: the audit-reader trio
|
|
305
|
+
// (`summary`, `by-tool`, `timeline`, `top-blocks`) all share
|
|
306
|
+
// the read-only contract — they walk audit.jsonl + rotated
|
|
307
|
+
// segments and emit aggregations to stdout. The pre-0.47.0
|
|
308
|
+
// tier-map only downgraded `verify` + `specialists`, leaving
|
|
309
|
+
// 0.41.0+ readers misclassified as Write under TRUSTED
|
|
310
|
+
// invocations — which made them unavailable in L0 sessions
|
|
311
|
+
// run from `/usr/local/bin/rea` or
|
|
312
|
+
// `/proj/node_modules/.bin/rea` despite being purely
|
|
313
|
+
// observational. Close the gap for all four readers here.
|
|
314
|
+
//
|
|
315
|
+
// 0.47.0 codex round-12 P2 (DELIBERATE NON-FIX): the weak-
|
|
316
|
+
// trust branch below intentionally returns null for Read-tier
|
|
317
|
+
// subcommands, including these audit readers. That keeps a
|
|
318
|
+
// bare `rea audit summary` (PATH-lookup or relative path) at
|
|
319
|
+
// generic Bash Write, where an attacker who shadowed `rea` on
|
|
320
|
+
// PATH cannot trick the gateway into downgrading their
|
|
321
|
+
// payload via a fake subcommand. Consumers needing Read
|
|
322
|
+
// semantics under L0 use the trusted invocation shapes
|
|
323
|
+
// (`/usr/local/bin/rea …`, `npx rea …`,
|
|
324
|
+
// `./node_modules/.bin/rea …`) — same contract as `verify`
|
|
325
|
+
// and `specialists` since 0.10.x / 0.29.0. The UX gap is
|
|
326
|
+
// documented in `docs/cli/trust-model.md` (see also the
|
|
327
|
+
// weak-trust branch comment below).
|
|
328
|
+
if (sub2 === 'summary')
|
|
329
|
+
return Tier.Read;
|
|
330
|
+
if (sub2 === 'by-tool')
|
|
331
|
+
return Tier.Read;
|
|
332
|
+
if (sub2 === 'timeline')
|
|
333
|
+
return Tier.Read;
|
|
334
|
+
if (sub2 === 'top-blocks')
|
|
335
|
+
return Tier.Read;
|
|
304
336
|
if (sub2 === 'rotate')
|
|
305
337
|
return Tier.Write;
|
|
306
338
|
return Tier.Write;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bookedsolid/rea",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.47.0",
|
|
4
4
|
"description": "Agentic governance layer for Claude Code — policy enforcement, hook-based safety gates, audit logging, and Codex-integrated adversarial review for AI-assisted projects",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Booked Solid Technology <oss@bookedsolid.tech> (https://bookedsolid.tech)",
|