@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
|
@@ -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/dist/policy/loader.d.ts
CHANGED
|
@@ -301,6 +301,13 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
301
301
|
threshold?: number | undefined;
|
|
302
302
|
exempt_subagents?: string[] | undefined;
|
|
303
303
|
}>>;
|
|
304
|
+
shim_cache: z.ZodOptional<z.ZodObject<{
|
|
305
|
+
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
306
|
+
}, "strict", z.ZodTypeAny, {
|
|
307
|
+
enabled: boolean;
|
|
308
|
+
}, {
|
|
309
|
+
enabled?: boolean | undefined;
|
|
310
|
+
}>>;
|
|
304
311
|
}, "strict", z.ZodTypeAny, {
|
|
305
312
|
version: string;
|
|
306
313
|
profile: string;
|
|
@@ -379,6 +386,9 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
379
386
|
threshold: number;
|
|
380
387
|
exempt_subagents: string[];
|
|
381
388
|
} | undefined;
|
|
389
|
+
shim_cache?: {
|
|
390
|
+
enabled: boolean;
|
|
391
|
+
} | undefined;
|
|
382
392
|
}, {
|
|
383
393
|
version: string;
|
|
384
394
|
profile: string;
|
|
@@ -457,6 +467,9 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
457
467
|
threshold?: number | undefined;
|
|
458
468
|
exempt_subagents?: string[] | undefined;
|
|
459
469
|
} | undefined;
|
|
470
|
+
shim_cache?: {
|
|
471
|
+
enabled?: boolean | undefined;
|
|
472
|
+
} | undefined;
|
|
460
473
|
}>;
|
|
461
474
|
/**
|
|
462
475
|
* Async policy loader with TTL cache and mtime-based invalidation.
|
package/dist/policy/loader.js
CHANGED
|
@@ -328,6 +328,32 @@ const DelegationAdvisoryPolicySchema = z
|
|
|
328
328
|
]),
|
|
329
329
|
})
|
|
330
330
|
.strict();
|
|
331
|
+
/**
|
|
332
|
+
* 0.48.0 — per-session shim cache policy. The
|
|
333
|
+
* `hooks/_lib/shim-cache.sh` helper, sourced by every Node-binary
|
|
334
|
+
* shim via `hooks/_lib/shim-runtime.sh`, caches the (sandbox-ok,
|
|
335
|
+
* shape-ok) tuple for a given (session, project, CLI realpath,
|
|
336
|
+
* mtime, size, euid, enforce_shape) key, with a 3600s TTL ceiling.
|
|
337
|
+
* The cache is an OPTIMIZATION — every cache-miss path falls through
|
|
338
|
+
* to the existing uncached hot path. See
|
|
339
|
+
* `docs/shim-session-cache-design.md` for the full contract.
|
|
340
|
+
*
|
|
341
|
+
* Strict mode rejects unknown keys so a typo (`enabld`, `enable`)
|
|
342
|
+
* fails loudly at policy load. The block is optional — vanilla
|
|
343
|
+
* installs with no `shim_cache:` block get the default behavior
|
|
344
|
+
* (cache enabled). To disable: `shim_cache: { enabled: false }`.
|
|
345
|
+
*
|
|
346
|
+
* The bash-tier helper does a narrow YAML grep for the field
|
|
347
|
+
* BEFORE the canonical 4-tier policy reader is available (cache
|
|
348
|
+
* runs in the shim's pre-CLI section). This zod schema validates
|
|
349
|
+
* the field at CLI load time so wrong types / typos are caught at
|
|
350
|
+
* the load boundary.
|
|
351
|
+
*/
|
|
352
|
+
const ShimCachePolicySchema = z
|
|
353
|
+
.object({
|
|
354
|
+
enabled: z.boolean().default(true),
|
|
355
|
+
})
|
|
356
|
+
.strict();
|
|
331
357
|
const PolicySchema = z
|
|
332
358
|
.object({
|
|
333
359
|
version: z.string(),
|
|
@@ -387,6 +413,16 @@ const PolicySchema = z
|
|
|
387
413
|
// when unset/false). When the block IS present the inner schema
|
|
388
414
|
// supplies defaults for any omitted field.
|
|
389
415
|
delegation_advisory: DelegationAdvisoryPolicySchema.optional(),
|
|
416
|
+
// 0.48.0 per-session shim cache — drives `hooks/_lib/shim-cache.sh`
|
|
417
|
+
// which short-circuits the sandbox check + version probe in
|
|
418
|
+
// `hooks/_lib/shim-runtime.sh` on session-warm fires of the same
|
|
419
|
+
// shim. Optional — vanilla installs get the default behavior
|
|
420
|
+
// (cache enabled). The bash-tier `shim_cache_disabled` helper
|
|
421
|
+
// honors `enabled: false` via a narrow inline YAML grep before
|
|
422
|
+
// the canonical policy reader is reachable. `REA_SHIM_CACHE=0`
|
|
423
|
+
// in env overrides this to `false` for the current invocation
|
|
424
|
+
// regardless of policy.
|
|
425
|
+
shim_cache: ShimCachePolicySchema.optional(),
|
|
390
426
|
})
|
|
391
427
|
.strict();
|
|
392
428
|
const DEFAULT_CACHE_TTL_MS = 30_000;
|
package/dist/policy/types.d.ts
CHANGED
|
@@ -533,4 +533,56 @@ export interface Policy {
|
|
|
533
533
|
* blocks. See `DelegationAdvisoryPolicy` for the full contract.
|
|
534
534
|
*/
|
|
535
535
|
delegation_advisory?: DelegationAdvisoryPolicy;
|
|
536
|
+
/**
|
|
537
|
+
* Per-session shim cache (0.48.0+).
|
|
538
|
+
*
|
|
539
|
+
* The `hooks/_lib/shim-cache.sh` helper, sourced by every Node-binary
|
|
540
|
+
* shim via `hooks/_lib/shim-runtime.sh`, records the answers to the
|
|
541
|
+
* sandbox check + version probe under a per-user, per-session,
|
|
542
|
+
* per-CLI key. Subsequent shim fires within the same Claude Code
|
|
543
|
+
* session against the same CLI (mtime + size unchanged) skip
|
|
544
|
+
* straight to the forward step.
|
|
545
|
+
*
|
|
546
|
+
* The cache is an OPTIMIZATION, not a security boundary. Cache miss
|
|
547
|
+
* / disabled / corruption all fall through to the existing uncached
|
|
548
|
+
* hot path — never fail closed.
|
|
549
|
+
*
|
|
550
|
+
* `enabled` default: `true`. Set `false` to disable the cache layer
|
|
551
|
+
* at the policy tier (equivalent effect to setting the
|
|
552
|
+
* `REA_SHIM_CACHE=0` env var on every invocation). Operators who
|
|
553
|
+
* want to measure unconditional steady-state latency should use the
|
|
554
|
+
* env-var form so the cache stays off only for the measurement
|
|
555
|
+
* window. See `docs/shim-session-cache-design.md` for the security
|
|
556
|
+
* contract and `docs/hook-perf-baseline.md` for the perf
|
|
557
|
+
* methodology note.
|
|
558
|
+
*/
|
|
559
|
+
shim_cache?: ShimCachePolicy;
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Per-session shim cache policy (0.48.0+).
|
|
563
|
+
*
|
|
564
|
+
* The cache short-circuits the sandbox check + version probe in
|
|
565
|
+
* `hooks/_lib/shim-runtime.sh` on session-warm fires of the same
|
|
566
|
+
* shim. The on-disk entry shape is bound to `schema_version: "v1"`
|
|
567
|
+
* — a schema bump (future cache field additions) invalidates every
|
|
568
|
+
* existing entry. TTL is hard-capped at 3600s (1h) inside the
|
|
569
|
+
* runtime; this block does not expose a TTL knob in 0.48.0 because
|
|
570
|
+
* the optimization is steady-state-bound and a longer TTL would
|
|
571
|
+
* extend staleness without measurable benefit.
|
|
572
|
+
*/
|
|
573
|
+
export interface ShimCachePolicy {
|
|
574
|
+
/**
|
|
575
|
+
* Master switch. `true` (default) enables the cache. `false`
|
|
576
|
+
* disables both reads and writes — the runtime falls through to
|
|
577
|
+
* the existing uncached hot path on every fire. `REA_SHIM_CACHE=0`
|
|
578
|
+
* in env overrides this to `false` for the current invocation
|
|
579
|
+
* regardless of policy.
|
|
580
|
+
*
|
|
581
|
+
* NOTE 0.48.0: the bash-tier `shim_cache_disabled` helper consults
|
|
582
|
+
* this field via a narrow YAML grep BEFORE the canonical 4-tier
|
|
583
|
+
* policy reader is available (cache runs in the shim's pre-CLI
|
|
584
|
+
* section). The TS loader's schema validation runs at full CLI
|
|
585
|
+
* load time and catches typos / wrong types.
|
|
586
|
+
*/
|
|
587
|
+
enabled?: boolean;
|
|
536
588
|
}
|