@bookedsolid/rea 0.2.1 → 0.4.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/.husky/pre-push +15 -18
- package/README.md +41 -1
- package/THREAT_MODEL.md +100 -29
- package/dist/audit/append.d.ts +21 -8
- package/dist/audit/append.js +48 -83
- package/dist/audit/fs.d.ts +68 -0
- package/dist/audit/fs.js +171 -0
- package/dist/cli/audit.d.ts +40 -0
- package/dist/cli/audit.js +205 -0
- package/dist/cli/doctor.d.ts +19 -4
- package/dist/cli/doctor.js +172 -5
- package/dist/cli/index.js +26 -1
- package/dist/cli/init.js +93 -7
- package/dist/cli/install/pre-push.d.ts +335 -0
- package/dist/cli/install/pre-push.js +2818 -0
- package/dist/cli/serve.d.ts +64 -0
- package/dist/cli/serve.js +270 -2
- package/dist/cli/status.d.ts +90 -0
- package/dist/cli/status.js +399 -0
- package/dist/cli/utils.d.ts +4 -0
- package/dist/cli/utils.js +4 -0
- package/dist/gateway/audit/rotator.d.ts +116 -0
- package/dist/gateway/audit/rotator.js +289 -0
- package/dist/gateway/circuit-breaker.d.ts +17 -0
- package/dist/gateway/circuit-breaker.js +32 -3
- package/dist/gateway/downstream-pool.d.ts +2 -1
- package/dist/gateway/downstream-pool.js +2 -2
- package/dist/gateway/downstream.d.ts +39 -3
- package/dist/gateway/downstream.js +73 -14
- package/dist/gateway/log.d.ts +122 -0
- package/dist/gateway/log.js +334 -0
- package/dist/gateway/middleware/audit.d.ts +24 -1
- package/dist/gateway/middleware/audit.js +103 -58
- package/dist/gateway/middleware/blocked-paths.d.ts +0 -9
- package/dist/gateway/middleware/blocked-paths.js +439 -67
- package/dist/gateway/middleware/injection.d.ts +218 -13
- package/dist/gateway/middleware/injection.js +433 -51
- package/dist/gateway/middleware/kill-switch.d.ts +10 -1
- package/dist/gateway/middleware/kill-switch.js +20 -1
- package/dist/gateway/observability/metrics.d.ts +125 -0
- package/dist/gateway/observability/metrics.js +321 -0
- package/dist/gateway/server.d.ts +19 -0
- package/dist/gateway/server.js +99 -15
- package/dist/policy/loader.d.ts +47 -0
- package/dist/policy/loader.js +47 -0
- package/dist/policy/profiles.d.ts +13 -0
- package/dist/policy/profiles.js +12 -0
- package/dist/policy/types.d.ts +52 -0
- package/dist/registry/fingerprint.d.ts +73 -0
- package/dist/registry/fingerprint.js +81 -0
- package/dist/registry/fingerprints-store.d.ts +62 -0
- package/dist/registry/fingerprints-store.js +111 -0
- package/dist/registry/interpolate.d.ts +58 -0
- package/dist/registry/interpolate.js +121 -0
- package/dist/registry/loader.d.ts +2 -2
- package/dist/registry/loader.js +22 -1
- package/dist/registry/tofu-gate.d.ts +41 -0
- package/dist/registry/tofu-gate.js +189 -0
- package/dist/registry/tofu.d.ts +111 -0
- package/dist/registry/tofu.js +173 -0
- package/dist/registry/types.d.ts +9 -1
- package/package.json +3 -1
- package/profiles/bst-internal-no-codex.yaml +5 -0
- package/profiles/bst-internal.yaml +7 -0
- package/scripts/tarball-smoke.sh +197 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rea status` — running-process introspection for `rea serve` (G5).
|
|
3
|
+
*
|
|
4
|
+
* `rea check` is the ON-DISK view: policy, HALT, recent audit entries. It
|
|
5
|
+
* works when no gateway is running.
|
|
6
|
+
*
|
|
7
|
+
* `rea status` is the LIVE view: is a gateway running for this cwd? What is
|
|
8
|
+
* its session id? What does the audit chain look like right now? Is HALT
|
|
9
|
+
* active?
|
|
10
|
+
*
|
|
11
|
+
* Detection strategy for "is serve running":
|
|
12
|
+
* 1. Read `.rea/serve.pid`.
|
|
13
|
+
* 2. If the pidfile exists, `kill(pid, 0)` to check liveness.
|
|
14
|
+
* 3. If kill throws ESRCH or EPERM, the pid is stale — treat as not-running
|
|
15
|
+
* and surface that nuance in the output.
|
|
16
|
+
*
|
|
17
|
+
* Output modes:
|
|
18
|
+
* - Default: human-pretty, matching the spacing used by `rea check`.
|
|
19
|
+
* - `--json`: canonical JSON object, composable with jq and future tooling.
|
|
20
|
+
*
|
|
21
|
+
* This command is read-only. It does NOT clean up stale pidfiles (the serve
|
|
22
|
+
* process is the only writer). It does NOT run the full audit verifier —
|
|
23
|
+
* `rea audit verify` is the authoritative check and is expensive on large
|
|
24
|
+
* chains; here we just report line count, last timestamp, and a cheap "last
|
|
25
|
+
* record's stored hash is non-empty" heuristic as an integrity smoke signal.
|
|
26
|
+
*/
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import { loadPolicy } from '../policy/loader.js';
|
|
29
|
+
import { AUDIT_FILE, HALT_FILE, POLICY_FILE, REA_DIR, SERVE_PID_FILE, SERVE_STATE_FILE, err, exitWithMissingPolicy, log, reaPath, } from './utils.js';
|
|
30
|
+
/**
|
|
31
|
+
* Tail window size for the audit summary. 64 KiB is more than enough to
|
|
32
|
+
* hold the last audit record (typical record ≪ 1 KiB) but small enough
|
|
33
|
+
* that reading it never spikes memory even on a multi-hundred-MB chain.
|
|
34
|
+
*/
|
|
35
|
+
const AUDIT_TAIL_WINDOW_BYTES = 64 * 1024;
|
|
36
|
+
/**
|
|
37
|
+
* Strip every ASCII control code (C0 plus DEL) from a string. Defense
|
|
38
|
+
* against ANSI/OSC escape injection when a disk-controlled field reaches
|
|
39
|
+
* the operator's terminal via `console.log` in pretty mode.
|
|
40
|
+
*
|
|
41
|
+
* This is strict: every byte in 0x00-0x1F plus 0x7F is replaced with `?`.
|
|
42
|
+
* That drops CR/LF/TAB inside fields, which is fine — the fields this
|
|
43
|
+
* helper guards (halt_reason, session_id, started_at, last_timestamp,
|
|
44
|
+
* profile) are short identifiers or trimmed reasons, not multi-line
|
|
45
|
+
* narratives. Preserving TAB/LF would reopen the ESC+... attack surface
|
|
46
|
+
* because ANSI sequences begin with ESC (0x1B).
|
|
47
|
+
*
|
|
48
|
+
* SECURITY: Only pretty-print paths call this — JSON mode must not, since
|
|
49
|
+
* JSON.stringify already escapes control chars safely (`\u0000`), and a
|
|
50
|
+
* double-pass would corrupt legitimate audit values for downstream jq
|
|
51
|
+
* consumers.
|
|
52
|
+
*
|
|
53
|
+
* Exported so unit tests can assert the exact sanitization behavior.
|
|
54
|
+
*/
|
|
55
|
+
export function sanitizeForTerminal(value) {
|
|
56
|
+
return value.replace(/[\x00-\x1f\x7f\u200b-\u200f\u202a-\u202e\u2028\u2029\u2066-\u2069]/g, '?');
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Null-safe wrapper for {@link sanitizeForTerminal} so call sites don't
|
|
60
|
+
* need a ternary at every disk-sourced field.
|
|
61
|
+
*/
|
|
62
|
+
function safePretty(value) {
|
|
63
|
+
if (value === null || value === undefined)
|
|
64
|
+
return null;
|
|
65
|
+
return sanitizeForTerminal(value);
|
|
66
|
+
}
|
|
67
|
+
/** Returns true if the OS confirms a live process at `pid`. */
|
|
68
|
+
function isProcessAlive(pid) {
|
|
69
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
70
|
+
return false;
|
|
71
|
+
try {
|
|
72
|
+
// Signal 0 tests existence without delivering a signal.
|
|
73
|
+
process.kill(pid, 0);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
const code = e.code;
|
|
78
|
+
// EPERM means the process exists but belongs to another user — for our
|
|
79
|
+
// purposes (was-it-started-on-this-machine), that still counts as alive.
|
|
80
|
+
// ESRCH means no such process.
|
|
81
|
+
if (code === 'EPERM')
|
|
82
|
+
return true;
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function readPidfile(baseDir) {
|
|
87
|
+
const p = reaPath(baseDir, SERVE_PID_FILE);
|
|
88
|
+
try {
|
|
89
|
+
const raw = fs.readFileSync(p, 'utf8').trim();
|
|
90
|
+
const n = Number.parseInt(raw, 10);
|
|
91
|
+
if (!Number.isInteger(n) || n <= 0)
|
|
92
|
+
return null;
|
|
93
|
+
return n;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function readServeState(baseDir) {
|
|
100
|
+
const p = reaPath(baseDir, SERVE_STATE_FILE);
|
|
101
|
+
try {
|
|
102
|
+
const raw = fs.readFileSync(p, 'utf8');
|
|
103
|
+
const parsed = JSON.parse(raw);
|
|
104
|
+
return {
|
|
105
|
+
session_id: typeof parsed.session_id === 'string' ? parsed.session_id : null,
|
|
106
|
+
started_at: typeof parsed.started_at === 'string' ? parsed.started_at : null,
|
|
107
|
+
metrics_port: typeof parsed.metrics_port === 'number' && Number.isInteger(parsed.metrics_port)
|
|
108
|
+
? parsed.metrics_port
|
|
109
|
+
: null,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return { session_id: null, started_at: null, metrics_port: null };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function probeServe(baseDir) {
|
|
117
|
+
const pid = readPidfile(baseDir);
|
|
118
|
+
if (pid === null) {
|
|
119
|
+
// No pidfile — serve isn't running (at least not via `rea serve`).
|
|
120
|
+
return {
|
|
121
|
+
running: false,
|
|
122
|
+
pid: null,
|
|
123
|
+
stale: false,
|
|
124
|
+
session_id: null,
|
|
125
|
+
started_at: null,
|
|
126
|
+
metrics_port: null,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const alive = isProcessAlive(pid);
|
|
130
|
+
const state = readServeState(baseDir);
|
|
131
|
+
return {
|
|
132
|
+
running: alive,
|
|
133
|
+
pid,
|
|
134
|
+
stale: !alive,
|
|
135
|
+
session_id: state.session_id,
|
|
136
|
+
started_at: state.started_at,
|
|
137
|
+
metrics_port: state.metrics_port,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Count newline bytes in the file via a streaming read. O(file-size) in
|
|
142
|
+
* wall-clock but O(chunk-size) in memory — production chains can reach
|
|
143
|
+
* hundreds of MB; we must never hold the full file in a Buffer.
|
|
144
|
+
*/
|
|
145
|
+
function countLinesStreaming(filePath) {
|
|
146
|
+
let count = 0;
|
|
147
|
+
let fd;
|
|
148
|
+
try {
|
|
149
|
+
fd = fs.openSync(filePath, 'r');
|
|
150
|
+
const buf = Buffer.alloc(64 * 1024);
|
|
151
|
+
let bytesRead = 0;
|
|
152
|
+
while ((bytesRead = fs.readSync(fd, buf, 0, buf.length, null)) > 0) {
|
|
153
|
+
for (let i = 0; i < bytesRead; i++) {
|
|
154
|
+
if (buf[i] === 0x0a)
|
|
155
|
+
count++;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// Partial result is still useful; return whatever we counted.
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
if (fd !== undefined) {
|
|
164
|
+
try {
|
|
165
|
+
fs.closeSync(fd);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
/* ignored */
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return count;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Read up to `windowBytes` from the end of the file. Uses `pread` via a
|
|
176
|
+
* positioned `readSync` so we never materialize more than the window into
|
|
177
|
+
* memory, regardless of file size. The window is intentionally generous
|
|
178
|
+
* (default 64 KiB) vs. a typical ~200-byte audit record so the tail line
|
|
179
|
+
* is always fully represented.
|
|
180
|
+
*/
|
|
181
|
+
function readTailBytes(filePath, windowBytes) {
|
|
182
|
+
let fd;
|
|
183
|
+
try {
|
|
184
|
+
fd = fs.openSync(filePath, 'r');
|
|
185
|
+
const stat = fs.fstatSync(fd);
|
|
186
|
+
if (stat.size === 0)
|
|
187
|
+
return '';
|
|
188
|
+
const toRead = Math.min(windowBytes, stat.size);
|
|
189
|
+
const buf = Buffer.alloc(toRead);
|
|
190
|
+
const start = stat.size - toRead;
|
|
191
|
+
fs.readSync(fd, buf, 0, toRead, start);
|
|
192
|
+
return buf.toString('utf8');
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return '';
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
if (fd !== undefined) {
|
|
199
|
+
try {
|
|
200
|
+
fs.closeSync(fd);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
/* ignored */
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Quickly compute audit stats without running the full verifier. Memory
|
|
210
|
+
* posture:
|
|
211
|
+
* - Line count is computed with a streaming newline scan (64 KiB chunk
|
|
212
|
+
* buffer, regardless of total file size).
|
|
213
|
+
* - `last_timestamp` + `tail_hash_looks_valid` come from a 64-KiB tail
|
|
214
|
+
* window read via `readSync` at a positive offset — we never
|
|
215
|
+
* materialize the full file.
|
|
216
|
+
*
|
|
217
|
+
* Missing / corrupt / empty files degrade to "present: false" or
|
|
218
|
+
* "lines: 0".
|
|
219
|
+
*/
|
|
220
|
+
function summarizeAudit(baseDir) {
|
|
221
|
+
const p = reaPath(baseDir, AUDIT_FILE);
|
|
222
|
+
if (!fs.existsSync(p)) {
|
|
223
|
+
return { present: false, lines: 0, last_timestamp: null, tail_hash_looks_valid: false };
|
|
224
|
+
}
|
|
225
|
+
// Streaming line count — O(file-size) CPU, O(chunk) memory.
|
|
226
|
+
// NOTE: countLinesStreaming and readTailBytes open the file independently.
|
|
227
|
+
// A concurrent append between the two opens can produce a `lines` count
|
|
228
|
+
// that is one higher than the tail record implies. This is a display-only
|
|
229
|
+
// function; the inconsistency is cosmetic and intentionally accepted.
|
|
230
|
+
const lineCount = countLinesStreaming(p);
|
|
231
|
+
// Tail-window scan for the last JSON record. If the last window isn't
|
|
232
|
+
// large enough to contain a full record (extremely rare: record >64 KiB),
|
|
233
|
+
// we degrade gracefully — the JSON parse just fails and we emit null.
|
|
234
|
+
const tailWindow = readTailBytes(p, AUDIT_TAIL_WINDOW_BYTES);
|
|
235
|
+
if (tailWindow.length === 0) {
|
|
236
|
+
return {
|
|
237
|
+
present: true,
|
|
238
|
+
lines: lineCount,
|
|
239
|
+
last_timestamp: null,
|
|
240
|
+
tail_hash_looks_valid: false,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
// Find the last complete line. The first line in the window may be a
|
|
244
|
+
// partial record (we sliced mid-line); ignore it by finding the last
|
|
245
|
+
// newline-terminated segment.
|
|
246
|
+
const windowLines = tailWindow.split('\n').filter((line) => line.length > 0);
|
|
247
|
+
const tail = windowLines[windowLines.length - 1];
|
|
248
|
+
let last_timestamp = null;
|
|
249
|
+
let tail_hash_looks_valid = false;
|
|
250
|
+
if (tail !== undefined) {
|
|
251
|
+
try {
|
|
252
|
+
const rec = JSON.parse(tail);
|
|
253
|
+
if (typeof rec.timestamp === 'string')
|
|
254
|
+
last_timestamp = rec.timestamp;
|
|
255
|
+
if (typeof rec.hash === 'string' && /^[0-9a-f]{64}$/i.test(rec.hash)) {
|
|
256
|
+
tail_hash_looks_valid = true;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
// Broken last line — leave both as default.
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return { present: true, lines: lineCount, last_timestamp, tail_hash_looks_valid };
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Build the canonical payload. Separate from print paths so the JSON and
|
|
267
|
+
* pretty outputs stay in lockstep.
|
|
268
|
+
*/
|
|
269
|
+
export function computeStatusPayload(baseDir) {
|
|
270
|
+
const policyPath = reaPath(baseDir, POLICY_FILE);
|
|
271
|
+
if (!fs.existsSync(policyPath)) {
|
|
272
|
+
exitWithMissingPolicy(policyPath);
|
|
273
|
+
}
|
|
274
|
+
const policy = loadPolicy(baseDir);
|
|
275
|
+
const haltPath = reaPath(baseDir, HALT_FILE);
|
|
276
|
+
const haltActive = fs.existsSync(haltPath);
|
|
277
|
+
let haltReason = null;
|
|
278
|
+
if (haltActive) {
|
|
279
|
+
try {
|
|
280
|
+
haltReason = fs.readFileSync(haltPath, 'utf8').trim();
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
haltReason = null;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
base_dir: baseDir,
|
|
288
|
+
serve: probeServe(baseDir),
|
|
289
|
+
policy: {
|
|
290
|
+
profile: policy.profile,
|
|
291
|
+
autonomy_level: policy.autonomy_level,
|
|
292
|
+
blocked_paths_count: policy.blocked_paths.length,
|
|
293
|
+
codex_required: policy.review?.codex_required !== false,
|
|
294
|
+
halt_active: haltActive,
|
|
295
|
+
halt_reason: haltReason,
|
|
296
|
+
},
|
|
297
|
+
audit: summarizeAudit(baseDir),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
function printPretty(payload) {
|
|
301
|
+
// Every terminal-bound string field flows through `safePretty` or
|
|
302
|
+
// `sanitizeForTerminal` to prevent ANSI/OSC escape injection. This
|
|
303
|
+
// includes `base_dir`: although it originates from `process.cwd()`, the
|
|
304
|
+
// filesystem path is operator-controlled and a maliciously named directory
|
|
305
|
+
// can embed ESC/OSC bytes that inject terminal sequences when printed.
|
|
306
|
+
const p = payload.policy;
|
|
307
|
+
const s = payload.serve;
|
|
308
|
+
const a = payload.audit;
|
|
309
|
+
const baseDir = sanitizeForTerminal(payload.base_dir);
|
|
310
|
+
const profile = sanitizeForTerminal(p.profile);
|
|
311
|
+
const autonomy = sanitizeForTerminal(p.autonomy_level);
|
|
312
|
+
const haltReason = safePretty(p.halt_reason);
|
|
313
|
+
const sessionId = safePretty(s.session_id);
|
|
314
|
+
const startedAt = safePretty(s.started_at);
|
|
315
|
+
const lastTimestamp = safePretty(a.last_timestamp);
|
|
316
|
+
console.log('');
|
|
317
|
+
log(`Status — ${baseDir}`);
|
|
318
|
+
console.log('');
|
|
319
|
+
console.log(' Policy');
|
|
320
|
+
console.log(` Profile: ${profile}`);
|
|
321
|
+
console.log(` Autonomy: ${autonomy}`);
|
|
322
|
+
console.log(` Blocked paths: ${p.blocked_paths_count} entries`);
|
|
323
|
+
console.log(` Codex required: ${p.codex_required ? 'yes' : 'no'}`);
|
|
324
|
+
if (p.halt_active) {
|
|
325
|
+
console.log(` HALT: ACTIVE`);
|
|
326
|
+
if (haltReason !== null) {
|
|
327
|
+
console.log(` ${haltReason}`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
console.log(` HALT: inactive`);
|
|
332
|
+
}
|
|
333
|
+
console.log('');
|
|
334
|
+
console.log(' rea serve');
|
|
335
|
+
if (!s.running) {
|
|
336
|
+
if (s.pid !== null && s.stale) {
|
|
337
|
+
console.log(` Running: no (stale pidfile — pid ${s.pid})`);
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
console.log(` Running: no`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
console.log(` Running: yes (pid ${s.pid ?? '?'})`);
|
|
345
|
+
if (sessionId !== null) {
|
|
346
|
+
console.log(` Session id: ${sessionId}`);
|
|
347
|
+
}
|
|
348
|
+
if (startedAt !== null) {
|
|
349
|
+
console.log(` Started at: ${startedAt}`);
|
|
350
|
+
}
|
|
351
|
+
if (s.metrics_port !== null) {
|
|
352
|
+
console.log(` Metrics endpoint: http://127.0.0.1:${s.metrics_port}/metrics`);
|
|
353
|
+
}
|
|
354
|
+
else {
|
|
355
|
+
console.log(` Metrics endpoint: disabled (set REA_METRICS_PORT to enable)`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
console.log('');
|
|
359
|
+
console.log(' Audit log');
|
|
360
|
+
if (!a.present) {
|
|
361
|
+
console.log(` State: not yet written`);
|
|
362
|
+
}
|
|
363
|
+
else if (a.lines === 0) {
|
|
364
|
+
console.log(` State: empty`);
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
console.log(` Lines: ${a.lines}`);
|
|
368
|
+
if (lastTimestamp !== null) {
|
|
369
|
+
console.log(` Last record at: ${lastTimestamp}`);
|
|
370
|
+
}
|
|
371
|
+
console.log(` Tail hash: ${a.tail_hash_looks_valid ? 'looks valid' : 'unexpected shape — run `rea audit verify`'}`);
|
|
372
|
+
}
|
|
373
|
+
console.log('');
|
|
374
|
+
}
|
|
375
|
+
function printJson(payload) {
|
|
376
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
|
|
377
|
+
}
|
|
378
|
+
export function runStatus(options = {}) {
|
|
379
|
+
const baseDir = process.cwd();
|
|
380
|
+
let payload;
|
|
381
|
+
try {
|
|
382
|
+
payload = computeStatusPayload(baseDir);
|
|
383
|
+
}
|
|
384
|
+
catch (e) {
|
|
385
|
+
// `exitWithMissingPolicy` already handles the missing-policy path; any
|
|
386
|
+
// other loadPolicy error reaches here.
|
|
387
|
+
err(`Failed to build status: ${e instanceof Error ? e.message : String(e)}`);
|
|
388
|
+
process.exit(1);
|
|
389
|
+
}
|
|
390
|
+
if (options.json === true) {
|
|
391
|
+
printJson(payload);
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
printPretty(payload);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
// Exported so tests can construct the expected directory without duplicating
|
|
398
|
+
// the path segment.
|
|
399
|
+
export const INTERNAL = { REA_DIR };
|
package/dist/cli/utils.d.ts
CHANGED
|
@@ -9,6 +9,10 @@ export declare const POLICY_FILE = "policy.yaml";
|
|
|
9
9
|
export declare const REGISTRY_FILE = "registry.yaml";
|
|
10
10
|
export declare const HALT_FILE = "HALT";
|
|
11
11
|
export declare const AUDIT_FILE = "audit.jsonl";
|
|
12
|
+
/** Pidfile written by `rea serve` for `rea status` introspection (G5). */
|
|
13
|
+
export declare const SERVE_PID_FILE = "serve.pid";
|
|
14
|
+
/** State file written by `rea serve` carrying session_id + start metadata (G5). */
|
|
15
|
+
export declare const SERVE_STATE_FILE = "serve.state.json";
|
|
12
16
|
export declare function reaPath(baseDir: string, ...segments: string[]): string;
|
|
13
17
|
/**
|
|
14
18
|
* Standard log prefix so users notice the transition from reagent → rea.
|
package/dist/cli/utils.js
CHANGED
|
@@ -23,6 +23,10 @@ export const POLICY_FILE = 'policy.yaml';
|
|
|
23
23
|
export const REGISTRY_FILE = 'registry.yaml';
|
|
24
24
|
export const HALT_FILE = 'HALT';
|
|
25
25
|
export const AUDIT_FILE = 'audit.jsonl';
|
|
26
|
+
/** Pidfile written by `rea serve` for `rea status` introspection (G5). */
|
|
27
|
+
export const SERVE_PID_FILE = 'serve.pid';
|
|
28
|
+
/** State file written by `rea serve` carrying session_id + start metadata (G5). */
|
|
29
|
+
export const SERVE_STATE_FILE = 'serve.state.json';
|
|
26
30
|
export function reaPath(baseDir, ...segments) {
|
|
27
31
|
return path.join(baseDir, REA_DIR, ...segments);
|
|
28
32
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit rotation (G1). Size- and age-based rotation for `.rea/audit.jsonl`
|
|
3
|
+
* that preserves hash-chain continuity across the rotation boundary.
|
|
4
|
+
*
|
|
5
|
+
* ## Triggers
|
|
6
|
+
*
|
|
7
|
+
* Rotation fires when EITHER threshold is crossed:
|
|
8
|
+
*
|
|
9
|
+
* - `max_bytes` — the current `audit.jsonl` is at or above this many bytes.
|
|
10
|
+
* Default when the policy block is present but `max_bytes` is unset:
|
|
11
|
+
* `DEFAULT_MAX_BYTES` (50 MiB).
|
|
12
|
+
* - `max_age_days` — the first record's `timestamp` is older than this many
|
|
13
|
+
* days. Default when unset: `DEFAULT_MAX_AGE_DAYS` (30).
|
|
14
|
+
*
|
|
15
|
+
* Back-compat: if the `audit.rotation` policy block is ABSENT entirely,
|
|
16
|
+
* rotation is DISABLED. Defaults only apply when the operator has opted in
|
|
17
|
+
* by declaring the block (even empty). This is deliberate — we do not want
|
|
18
|
+
* a 0.2.x install to observe new file-movement behavior on 0.3.0 upgrade
|
|
19
|
+
* without being asked.
|
|
20
|
+
*
|
|
21
|
+
* ## Rotation marker
|
|
22
|
+
*
|
|
23
|
+
* On rotation, the current file is renamed to `audit-YYYYMMDD-HHMMSS.jsonl`
|
|
24
|
+
* in the same directory. A fresh `audit.jsonl` is created containing EXACTLY
|
|
25
|
+
* one record: a rotation marker.
|
|
26
|
+
*
|
|
27
|
+
* tool_name: 'audit.rotation'
|
|
28
|
+
* server_name: 'rea'
|
|
29
|
+
* status: 'allowed'
|
|
30
|
+
* tier: 'read'
|
|
31
|
+
* autonomy_level: 'system'
|
|
32
|
+
* prev_hash: hash of the LAST record in the rotated file
|
|
33
|
+
* metadata.rotated_from: the rotated filename (basename)
|
|
34
|
+
* metadata.rotated_at: ISO-8601 instant of rotation
|
|
35
|
+
*
|
|
36
|
+
* The marker's `prev_hash` is the chain bridge — an operator verifying the
|
|
37
|
+
* chain with `rea audit verify --since <rotated-file>` walks rotated →
|
|
38
|
+
* marker → current and every transition must line up.
|
|
39
|
+
*
|
|
40
|
+
* ## Concurrency
|
|
41
|
+
*
|
|
42
|
+
* `maybeRotate` is called BEFORE the per-append lock is acquired. It takes
|
|
43
|
+
* its own short-lived lock on `.rea/` to perform the rename + marker write
|
|
44
|
+
* atomically. Callers that beat the rotator to the lock simply append to
|
|
45
|
+
* the (now fresh) file — correctness is preserved because the rotation
|
|
46
|
+
* marker is a legitimate chain anchor.
|
|
47
|
+
*/
|
|
48
|
+
import type { Policy, AuditRotationPolicy } from '../../policy/types.js';
|
|
49
|
+
/** 50 MiB. Only applied when the operator has declared `audit.rotation`. */
|
|
50
|
+
export declare const DEFAULT_MAX_BYTES: number;
|
|
51
|
+
/** 30 days. Only applied when the operator has declared `audit.rotation`. */
|
|
52
|
+
export declare const DEFAULT_MAX_AGE_DAYS = 30;
|
|
53
|
+
export declare const ROTATION_TOOL_NAME = "audit.rotation";
|
|
54
|
+
export declare const ROTATION_SERVER_NAME = "rea";
|
|
55
|
+
export interface RotationResult {
|
|
56
|
+
rotated: boolean;
|
|
57
|
+
/** Absolute path of the rotated file (the `audit-TIMESTAMP.jsonl` file). */
|
|
58
|
+
rotatedTo?: string;
|
|
59
|
+
}
|
|
60
|
+
/** Resolve effective thresholds from policy. `undefined` thresholds disable that trigger. */
|
|
61
|
+
interface EffectiveThresholds {
|
|
62
|
+
maxBytes: number | undefined;
|
|
63
|
+
maxAgeMs: number | undefined;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Compute the effective rotation thresholds from policy. If the operator has
|
|
67
|
+
* NOT declared an `audit.rotation` block, BOTH thresholds are undefined and
|
|
68
|
+
* rotation is disabled (back-compat with 0.2.x).
|
|
69
|
+
*
|
|
70
|
+
* If the block IS declared but individual knobs are missing, apply the
|
|
71
|
+
* documented defaults.
|
|
72
|
+
*/
|
|
73
|
+
declare function effectiveThresholds(policy: Policy | undefined): EffectiveThresholds;
|
|
74
|
+
/**
|
|
75
|
+
* Build the rotation timestamp filename. UTC for sortability.
|
|
76
|
+
* Format: `audit-YYYYMMDD-HHMMSS.jsonl`. Collisions (two rotations in the
|
|
77
|
+
* same second) are resolved by appending `-1`, `-2`, etc.
|
|
78
|
+
*/
|
|
79
|
+
export declare function rotationFilename(at: Date): string;
|
|
80
|
+
/**
|
|
81
|
+
* Decide whether the current audit file has crossed any rotation threshold.
|
|
82
|
+
* Exported for testing.
|
|
83
|
+
*/
|
|
84
|
+
export declare function shouldRotate(auditFile: string, thresholds: EffectiveThresholds, now?: Date): Promise<boolean>;
|
|
85
|
+
/**
|
|
86
|
+
* Perform the rotation unconditionally. Assumes the caller has already
|
|
87
|
+
* determined rotation is warranted and holds (or is about to acquire) any
|
|
88
|
+
* outer locks. `performRotation` takes its own lock on `.rea/` to make the
|
|
89
|
+
* rename + marker write atomic w.r.t. other append-path lockers.
|
|
90
|
+
*
|
|
91
|
+
* Returns `{ rotated: false }` if the audit file is empty or missing — an
|
|
92
|
+
* empty file is a no-op by design (see `rea audit rotate` empty-case).
|
|
93
|
+
*/
|
|
94
|
+
export declare function performRotation(auditFile: string, now?: Date): Promise<RotationResult>;
|
|
95
|
+
/**
|
|
96
|
+
* Called by the append path BEFORE acquiring its own lock. Cheap when no
|
|
97
|
+
* rotation is due (one stat, maybe one 64 KiB read for age check); idempotent
|
|
98
|
+
* when rotation IS due (performRotation re-checks under the lock).
|
|
99
|
+
*
|
|
100
|
+
* Never throws. On any error, logs to stderr and returns `rotated: false`
|
|
101
|
+
* — a broken rotator must NOT break the audit append.
|
|
102
|
+
*/
|
|
103
|
+
export declare function maybeRotate(auditFile: string, policy: Policy | undefined, now?: Date): Promise<RotationResult>;
|
|
104
|
+
/**
|
|
105
|
+
* CLI-invoked force rotation (`rea audit rotate`). Unlike `maybeRotate` this
|
|
106
|
+
* DOES ignore thresholds — the operator asked explicitly — but empty files
|
|
107
|
+
* are still a no-op because rotating an empty chain produces a marker with
|
|
108
|
+
* no predecessor.
|
|
109
|
+
*/
|
|
110
|
+
export declare function forceRotate(auditFile: string, now?: Date): Promise<RotationResult>;
|
|
111
|
+
/**
|
|
112
|
+
* Exposed for tests/callers that already know the policy shape. Tests that
|
|
113
|
+
* want to stub thresholds can call `performRotation` directly.
|
|
114
|
+
*/
|
|
115
|
+
export { effectiveThresholds as _effectiveThresholds };
|
|
116
|
+
export type { EffectiveThresholds, AuditRotationPolicy };
|