@bookedsolid/rea 0.45.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-by-tool.d.ts +173 -0
- package/dist/cli/audit-by-tool.js +373 -0
- package/dist/cli/audit-timeline.d.ts +180 -0
- package/dist/cli/audit-timeline.js +723 -0
- package/dist/cli/audit-top-blocks.d.ts +154 -0
- package/dist/cli/audit-top-blocks.js +419 -0
- package/dist/cli/index.js +15 -0
- package/dist/config/tier-map.js +32 -0
- package/package.json +1 -1
- package/scripts/profile-hooks.mjs +377 -88
|
@@ -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
|
@@ -15,6 +15,9 @@ import { runTofuAccept, runTofuList } from './tofu.js';
|
|
|
15
15
|
import { runUpgrade } from './upgrade.js';
|
|
16
16
|
import { runUpgradeCheck } from './upgrade-check.js';
|
|
17
17
|
import { registerAuditSummaryCommand } from './audit-summary.js';
|
|
18
|
+
import { registerAuditByToolCommand } from './audit-by-tool.js';
|
|
19
|
+
import { registerAuditTimelineCommand } from './audit-timeline.js';
|
|
20
|
+
import { registerAuditTopBlocksCommand } from './audit-top-blocks.js';
|
|
18
21
|
import { registerVerifyClaimCommand } from './verify-claim.js';
|
|
19
22
|
import { err, getPkgVersion } from './utils.js';
|
|
20
23
|
async function main() {
|
|
@@ -144,6 +147,18 @@ async function main() {
|
|
|
144
147
|
// overview reader. Counts events by tool_name, tier, session,
|
|
145
148
|
// status; samples chain integrity. Tier-Read; never mutates.
|
|
146
149
|
registerAuditSummaryCommand(audit);
|
|
150
|
+
// 0.46.0 charter item 1 — `rea audit by-tool [--top=N] [--since=DUR]
|
|
151
|
+
// [--json]`. Higher-fidelity tool_name distribution than `summary`
|
|
152
|
+
// (which caps at 12 + `(other)`). Reads the same rotated-file walk.
|
|
153
|
+
registerAuditByToolCommand(audit);
|
|
154
|
+
// 0.46.0 charter item 2 — `rea audit timeline [--bucket=HOUR|DAY]
|
|
155
|
+
// [--since=DUR] [--json]`. Time-bucketed event counts with inline
|
|
156
|
+
// histogram. Useful for spotting activity spikes + cadence patterns.
|
|
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);
|
|
147
162
|
// Register `rea hook push-gate` — the stateless pre-push Codex gate
|
|
148
163
|
// called by `.husky/pre-push` and `.git/hooks/pre-push`.
|
|
149
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)",
|