@bookedsolid/rea 0.46.0 → 0.48.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 +177 -0
- package/THREAT_MODEL.md +140 -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/dist/policy/loader.d.ts +13 -0
- package/dist/policy/loader.js +36 -0
- package/dist/policy/types.d.ts +52 -0
- package/hooks/_lib/shim-cache.sh +650 -0
- package/hooks/_lib/shim-runtime.sh +293 -3
- package/package.json +1 -1
- package/scripts/profile-hooks.mjs +10 -1
- package/templates/_lib_shim-cache.dogfood-staged.sh +650 -0
- package/templates/_lib_shim-runtime.dogfood-staged.sh +293 -3
|
@@ -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;
|