@bookedsolid/rea 0.2.0 → 0.3.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/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/index.js +17 -0
- package/dist/gateway/audit/rotator.d.ts +116 -0
- package/dist/gateway/audit/rotator.js +289 -0
- package/dist/gateway/middleware/audit.d.ts +14 -0
- package/dist/gateway/middleware/audit.js +77 -57
- package/dist/policy/loader.d.ts +34 -0
- package/dist/policy/loader.js +19 -0
- package/dist/policy/types.d.ts +24 -0
- package/package.json +4 -2
|
@@ -0,0 +1,289 @@
|
|
|
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 fs from 'node:fs/promises';
|
|
49
|
+
import path from 'node:path';
|
|
50
|
+
import { Tier, InvocationStatus } from '../../policy/types.js';
|
|
51
|
+
import { computeHash, readLastRecord, withAuditLock } from '../../audit/fs.js';
|
|
52
|
+
/** 50 MiB. Only applied when the operator has declared `audit.rotation`. */
|
|
53
|
+
export const DEFAULT_MAX_BYTES = 50 * 1024 * 1024;
|
|
54
|
+
/** 30 days. Only applied when the operator has declared `audit.rotation`. */
|
|
55
|
+
export const DEFAULT_MAX_AGE_DAYS = 30;
|
|
56
|
+
export const ROTATION_TOOL_NAME = 'audit.rotation';
|
|
57
|
+
export const ROTATION_SERVER_NAME = 'rea';
|
|
58
|
+
/**
|
|
59
|
+
* Compute the effective rotation thresholds from policy. If the operator has
|
|
60
|
+
* NOT declared an `audit.rotation` block, BOTH thresholds are undefined and
|
|
61
|
+
* rotation is disabled (back-compat with 0.2.x).
|
|
62
|
+
*
|
|
63
|
+
* If the block IS declared but individual knobs are missing, apply the
|
|
64
|
+
* documented defaults.
|
|
65
|
+
*/
|
|
66
|
+
function effectiveThresholds(policy) {
|
|
67
|
+
const rot = policy?.audit?.rotation;
|
|
68
|
+
if (rot === undefined) {
|
|
69
|
+
return { maxBytes: undefined, maxAgeMs: undefined };
|
|
70
|
+
}
|
|
71
|
+
// An explicit `audit.rotation: {}` block opts in to both defaults.
|
|
72
|
+
const maxBytes = rot.max_bytes ?? DEFAULT_MAX_BYTES;
|
|
73
|
+
const maxAgeDays = rot.max_age_days ?? DEFAULT_MAX_AGE_DAYS;
|
|
74
|
+
return { maxBytes, maxAgeMs: maxAgeDays * 24 * 60 * 60 * 1000 };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Build the rotation timestamp filename. UTC for sortability.
|
|
78
|
+
* Format: `audit-YYYYMMDD-HHMMSS.jsonl`. Collisions (two rotations in the
|
|
79
|
+
* same second) are resolved by appending `-1`, `-2`, etc.
|
|
80
|
+
*/
|
|
81
|
+
export function rotationFilename(at) {
|
|
82
|
+
const y = at.getUTCFullYear().toString().padStart(4, '0');
|
|
83
|
+
const m = (at.getUTCMonth() + 1).toString().padStart(2, '0');
|
|
84
|
+
const d = at.getUTCDate().toString().padStart(2, '0');
|
|
85
|
+
const hh = at.getUTCHours().toString().padStart(2, '0');
|
|
86
|
+
const mm = at.getUTCMinutes().toString().padStart(2, '0');
|
|
87
|
+
const ss = at.getUTCSeconds().toString().padStart(2, '0');
|
|
88
|
+
return `audit-${y}${m}${d}-${hh}${mm}${ss}.jsonl`;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Probe the first record's timestamp WITHOUT loading the whole file into
|
|
92
|
+
* memory as a JSON blob. We read up to the first newline and parse just
|
|
93
|
+
* that line. Returns `undefined` if the file is empty / unreadable / the
|
|
94
|
+
* first line isn't valid JSON with a usable `timestamp` field.
|
|
95
|
+
*/
|
|
96
|
+
async function readFirstTimestamp(auditFile) {
|
|
97
|
+
let fh;
|
|
98
|
+
try {
|
|
99
|
+
fh = await fs.open(auditFile, 'r');
|
|
100
|
+
// 64 KiB is enough for the first record under any realistic schema.
|
|
101
|
+
const buf = Buffer.alloc(64 * 1024);
|
|
102
|
+
const { bytesRead } = await fh.read(buf, 0, buf.length, 0);
|
|
103
|
+
if (bytesRead === 0)
|
|
104
|
+
return undefined;
|
|
105
|
+
const chunk = buf.slice(0, bytesRead).toString('utf8');
|
|
106
|
+
const newline = chunk.indexOf('\n');
|
|
107
|
+
const firstLine = newline === -1 ? chunk : chunk.slice(0, newline);
|
|
108
|
+
if (firstLine.length === 0)
|
|
109
|
+
return undefined;
|
|
110
|
+
const parsed = JSON.parse(firstLine);
|
|
111
|
+
if (typeof parsed.timestamp !== 'string')
|
|
112
|
+
return undefined;
|
|
113
|
+
const ts = Date.parse(parsed.timestamp);
|
|
114
|
+
if (Number.isNaN(ts))
|
|
115
|
+
return undefined;
|
|
116
|
+
return new Date(ts);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
if (fh)
|
|
123
|
+
await fh.close();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Decide whether the current audit file has crossed any rotation threshold.
|
|
128
|
+
* Exported for testing.
|
|
129
|
+
*/
|
|
130
|
+
export async function shouldRotate(auditFile, thresholds, now = new Date()) {
|
|
131
|
+
if (thresholds.maxBytes === undefined && thresholds.maxAgeMs === undefined) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
let size;
|
|
135
|
+
try {
|
|
136
|
+
const stat = await fs.stat(auditFile);
|
|
137
|
+
if (!stat.isFile())
|
|
138
|
+
return false;
|
|
139
|
+
size = stat.size;
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
if (err.code === 'ENOENT')
|
|
143
|
+
return false;
|
|
144
|
+
throw err;
|
|
145
|
+
}
|
|
146
|
+
// Empty files never rotate — rotating an empty file would create a chain
|
|
147
|
+
// anchored on genesis with a dangling predecessor.
|
|
148
|
+
if (size === 0)
|
|
149
|
+
return false;
|
|
150
|
+
if (thresholds.maxBytes !== undefined && size >= thresholds.maxBytes) {
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
if (thresholds.maxAgeMs !== undefined) {
|
|
154
|
+
const firstTs = await readFirstTimestamp(auditFile);
|
|
155
|
+
if (firstTs !== undefined) {
|
|
156
|
+
const ageMs = now.getTime() - firstTs.getTime();
|
|
157
|
+
if (ageMs >= thresholds.maxAgeMs)
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Pick a rotation filename that doesn't collide with an existing file.
|
|
165
|
+
* Returns the absolute path.
|
|
166
|
+
*/
|
|
167
|
+
async function pickRotationPath(reaDir, at) {
|
|
168
|
+
const base = rotationFilename(at);
|
|
169
|
+
const baseNoExt = base.replace(/\.jsonl$/, '');
|
|
170
|
+
let candidate = path.join(reaDir, base);
|
|
171
|
+
let suffix = 1;
|
|
172
|
+
while (true) {
|
|
173
|
+
try {
|
|
174
|
+
await fs.access(candidate);
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
if (err.code === 'ENOENT') {
|
|
178
|
+
return candidate;
|
|
179
|
+
}
|
|
180
|
+
throw err;
|
|
181
|
+
}
|
|
182
|
+
candidate = path.join(reaDir, `${baseNoExt}-${suffix}.jsonl`);
|
|
183
|
+
suffix += 1;
|
|
184
|
+
if (suffix > 1000) {
|
|
185
|
+
throw new Error(`Unable to pick rotation filename in ${reaDir} — 1000 collisions`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Perform the rotation unconditionally. Assumes the caller has already
|
|
191
|
+
* determined rotation is warranted and holds (or is about to acquire) any
|
|
192
|
+
* outer locks. `performRotation` takes its own lock on `.rea/` to make the
|
|
193
|
+
* rename + marker write atomic w.r.t. other append-path lockers.
|
|
194
|
+
*
|
|
195
|
+
* Returns `{ rotated: false }` if the audit file is empty or missing — an
|
|
196
|
+
* empty file is a no-op by design (see `rea audit rotate` empty-case).
|
|
197
|
+
*/
|
|
198
|
+
export async function performRotation(auditFile, now = new Date()) {
|
|
199
|
+
const reaDir = path.dirname(auditFile);
|
|
200
|
+
// Ensure the parent exists so withAuditLock can place a lock file. The
|
|
201
|
+
// caller normally creates this; we mkdir defensively for the force-rotate
|
|
202
|
+
// path (`rea audit rotate` on a green-field install).
|
|
203
|
+
await fs.mkdir(reaDir, { recursive: true });
|
|
204
|
+
return withAuditLock(auditFile, async () => {
|
|
205
|
+
// Re-check the file under the lock. Another writer may have rotated
|
|
206
|
+
// between the caller's decision and our lock acquisition.
|
|
207
|
+
let size;
|
|
208
|
+
try {
|
|
209
|
+
const stat = await fs.stat(auditFile);
|
|
210
|
+
if (!stat.isFile())
|
|
211
|
+
return { rotated: false };
|
|
212
|
+
size = stat.size;
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
if (err.code === 'ENOENT')
|
|
216
|
+
return { rotated: false };
|
|
217
|
+
throw err;
|
|
218
|
+
}
|
|
219
|
+
if (size === 0)
|
|
220
|
+
return { rotated: false };
|
|
221
|
+
// Pull the last record's hash BEFORE renaming — so we can anchor the
|
|
222
|
+
// marker's prev_hash on the old chain's tail. readLastRecord also
|
|
223
|
+
// performs partial-write recovery under our lock (idempotent).
|
|
224
|
+
const { hash: tailHash } = await readLastRecord(auditFile);
|
|
225
|
+
const rotatedPath = await pickRotationPath(reaDir, now);
|
|
226
|
+
await fs.rename(auditFile, rotatedPath);
|
|
227
|
+
// Write the rotation marker into a fresh audit.jsonl. The marker's
|
|
228
|
+
// prev_hash is the old tail's hash — operators can walk rotated →
|
|
229
|
+
// marker and the chain holds.
|
|
230
|
+
const markerBase = {
|
|
231
|
+
timestamp: now.toISOString(),
|
|
232
|
+
session_id: 'system',
|
|
233
|
+
tool_name: ROTATION_TOOL_NAME,
|
|
234
|
+
server_name: ROTATION_SERVER_NAME,
|
|
235
|
+
tier: Tier.Read,
|
|
236
|
+
status: InvocationStatus.Allowed,
|
|
237
|
+
autonomy_level: 'system',
|
|
238
|
+
duration_ms: 0,
|
|
239
|
+
prev_hash: tailHash,
|
|
240
|
+
metadata: {
|
|
241
|
+
rotated_from: path.basename(rotatedPath),
|
|
242
|
+
rotated_at: now.toISOString(),
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
const markerHash = computeHash(markerBase);
|
|
246
|
+
const marker = { ...markerBase, hash: markerHash };
|
|
247
|
+
const line = JSON.stringify(marker) + '\n';
|
|
248
|
+
await fs.writeFile(auditFile, line, { flag: 'w' });
|
|
249
|
+
return { rotated: true, rotatedTo: rotatedPath };
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Called by the append path BEFORE acquiring its own lock. Cheap when no
|
|
254
|
+
* rotation is due (one stat, maybe one 64 KiB read for age check); idempotent
|
|
255
|
+
* when rotation IS due (performRotation re-checks under the lock).
|
|
256
|
+
*
|
|
257
|
+
* Never throws. On any error, logs to stderr and returns `rotated: false`
|
|
258
|
+
* — a broken rotator must NOT break the audit append.
|
|
259
|
+
*/
|
|
260
|
+
export async function maybeRotate(auditFile, policy, now = new Date()) {
|
|
261
|
+
try {
|
|
262
|
+
const thresholds = effectiveThresholds(policy);
|
|
263
|
+
if (thresholds.maxBytes === undefined && thresholds.maxAgeMs === undefined) {
|
|
264
|
+
return { rotated: false };
|
|
265
|
+
}
|
|
266
|
+
const due = await shouldRotate(auditFile, thresholds, now);
|
|
267
|
+
if (!due)
|
|
268
|
+
return { rotated: false };
|
|
269
|
+
return await performRotation(auditFile, now);
|
|
270
|
+
}
|
|
271
|
+
catch (err) {
|
|
272
|
+
console.error('[rea] AUDIT ROTATION FAILED:', err instanceof Error ? err.message : String(err));
|
|
273
|
+
return { rotated: false };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* CLI-invoked force rotation (`rea audit rotate`). Unlike `maybeRotate` this
|
|
278
|
+
* DOES ignore thresholds — the operator asked explicitly — but empty files
|
|
279
|
+
* are still a no-op because rotating an empty chain produces a marker with
|
|
280
|
+
* no predecessor.
|
|
281
|
+
*/
|
|
282
|
+
export async function forceRotate(auditFile, now = new Date()) {
|
|
283
|
+
return performRotation(auditFile, now);
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Exposed for tests/callers that already know the policy shape. Tests that
|
|
287
|
+
* want to stub thresholds can call `performRotation` directly.
|
|
288
|
+
*/
|
|
289
|
+
export { effectiveThresholds as _effectiveThresholds };
|
|
@@ -8,5 +8,19 @@ import type { Middleware } from './chain.js';
|
|
|
8
8
|
* SECURITY: Wraps next() in try/finally to ensure audit runs even on middleware exceptions.
|
|
9
9
|
* SECURITY: Placed as outermost middleware so audit records ALL invocations, including denials.
|
|
10
10
|
* PERFORMANCE: All fs operations are async to avoid blocking the event loop.
|
|
11
|
+
*
|
|
12
|
+
* CONCURRENCY (G1):
|
|
13
|
+
* - Per-process: the writeQueue below serializes writes within the Node process.
|
|
14
|
+
* - Cross-process: each write acquires a `proper-lockfile` lock on `.rea/`.
|
|
15
|
+
* Stale locks are reclaimed after 10s. Lock-acquisition failure falls back
|
|
16
|
+
* to the current best-effort behavior — the tool call proceeds and the
|
|
17
|
+
* failure is logged. Breaking the invocation because the auditor failed
|
|
18
|
+
* would let an audit outage take down the gateway.
|
|
19
|
+
*
|
|
20
|
+
* ROTATION (G1):
|
|
21
|
+
* - `maybeRotate` runs before each write's lock acquisition. Rotation writes
|
|
22
|
+
* a marker record whose `prev_hash` preserves hash-chain continuity across
|
|
23
|
+
* the rotation boundary. When no `audit.rotation` block is set in policy,
|
|
24
|
+
* rotation is a no-op — 0.2.x behavior is preserved.
|
|
11
25
|
*/
|
|
12
26
|
export declare function createAuditMiddleware(baseDir: string, policy?: Policy): Middleware;
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import crypto from 'node:crypto';
|
|
4
3
|
import { Tier, InvocationStatus } from '../../policy/types.js';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
return crypto.createHash('sha256').update(payload).digest('hex');
|
|
8
|
-
}
|
|
4
|
+
import { computeHash, fsyncFile, readLastRecord, withAuditLock, } from '../../audit/fs.js';
|
|
5
|
+
import { maybeRotate } from '../audit/rotator.js';
|
|
9
6
|
/**
|
|
10
7
|
* Post-execution middleware: appends a hash-chained JSONL audit record.
|
|
11
8
|
*
|
|
@@ -14,15 +11,34 @@ function computeHash(record) {
|
|
|
14
11
|
* SECURITY: Wraps next() in try/finally to ensure audit runs even on middleware exceptions.
|
|
15
12
|
* SECURITY: Placed as outermost middleware so audit records ALL invocations, including denials.
|
|
16
13
|
* PERFORMANCE: All fs operations are async to avoid blocking the event loop.
|
|
14
|
+
*
|
|
15
|
+
* CONCURRENCY (G1):
|
|
16
|
+
* - Per-process: the writeQueue below serializes writes within the Node process.
|
|
17
|
+
* - Cross-process: each write acquires a `proper-lockfile` lock on `.rea/`.
|
|
18
|
+
* Stale locks are reclaimed after 10s. Lock-acquisition failure falls back
|
|
19
|
+
* to the current best-effort behavior — the tool call proceeds and the
|
|
20
|
+
* failure is logged. Breaking the invocation because the auditor failed
|
|
21
|
+
* would let an audit outage take down the gateway.
|
|
22
|
+
*
|
|
23
|
+
* ROTATION (G1):
|
|
24
|
+
* - `maybeRotate` runs before each write's lock acquisition. Rotation writes
|
|
25
|
+
* a marker record whose `prev_hash` preserves hash-chain continuity across
|
|
26
|
+
* the rotation boundary. When no `audit.rotation` block is set in policy,
|
|
27
|
+
* rotation is a no-op — 0.2.x behavior is preserved.
|
|
17
28
|
*/
|
|
18
29
|
export function createAuditMiddleware(baseDir, policy) {
|
|
19
30
|
// REA writes to a single .rea/audit.jsonl file (not dated per-day files).
|
|
20
31
|
const reaDir = path.join(baseDir, '.rea');
|
|
21
32
|
const auditFile = path.join(reaDir, 'audit.jsonl');
|
|
22
|
-
let prevHash = '0000000000000000000000000000000000000000000000000000000000000000';
|
|
23
33
|
let dirEnsured = false;
|
|
24
34
|
// SECURITY: Use a write queue to serialize audit writes, ensuring the hash chain is linear.
|
|
25
35
|
let writeQueue = Promise.resolve();
|
|
36
|
+
async function ensureDir() {
|
|
37
|
+
if (!dirEnsured) {
|
|
38
|
+
await fs.mkdir(reaDir, { recursive: true });
|
|
39
|
+
dirEnsured = true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
26
42
|
return async (ctx, next) => {
|
|
27
43
|
let nextError;
|
|
28
44
|
try {
|
|
@@ -41,63 +57,67 @@ export function createAuditMiddleware(baseDir, policy) {
|
|
|
41
57
|
const duration_ms = Date.now() - ctx.start_time;
|
|
42
58
|
const autonomyLevel = ctx.metadata.autonomy_level ?? policy?.autonomy_level ?? 'unknown';
|
|
43
59
|
// Serialize audit writes via a queue to maintain hash chain linearity under concurrency.
|
|
44
|
-
// Each write awaits the previous one before
|
|
60
|
+
// Each write awaits the previous one before running its lock-scoped append.
|
|
45
61
|
const writePromise = writeQueue.then(async () => {
|
|
46
62
|
try {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
63
|
+
await ensureDir();
|
|
64
|
+
// G1: Attempt rotation BEFORE acquiring the append lock. No-op when the
|
|
65
|
+
// policy's audit.rotation block is absent. Errors are swallowed inside
|
|
66
|
+
// maybeRotate so rotation can never take down the gateway.
|
|
67
|
+
await maybeRotate(auditFile, policy);
|
|
68
|
+
await withAuditLock(auditFile, async () => {
|
|
69
|
+
const { hash: prevHash } = await readLastRecord(auditFile);
|
|
70
|
+
const now = new Date().toISOString();
|
|
71
|
+
const recordBase = {
|
|
72
|
+
timestamp: now,
|
|
73
|
+
session_id: ctx.session_id,
|
|
74
|
+
tool_name: ctx.tool_name,
|
|
75
|
+
server_name: ctx.server_name,
|
|
76
|
+
tier: ctx.tier ?? Tier.Write,
|
|
77
|
+
status: ctx.status,
|
|
78
|
+
autonomy_level: autonomyLevel,
|
|
79
|
+
duration_ms,
|
|
80
|
+
prev_hash: prevHash,
|
|
81
|
+
};
|
|
82
|
+
if (ctx.error) {
|
|
83
|
+
recordBase.error = ctx.error;
|
|
84
|
+
}
|
|
85
|
+
if (ctx.redacted_fields?.length) {
|
|
86
|
+
recordBase.redacted_fields = ctx.redacted_fields;
|
|
87
|
+
}
|
|
88
|
+
// Attach caller-supplied metadata when the middleware context carries any.
|
|
89
|
+
// The `autonomy_level` key is reserved for internal bookkeeping (see above)
|
|
90
|
+
// and is excluded from the exported metadata payload.
|
|
91
|
+
if (ctx.metadata !== undefined) {
|
|
92
|
+
const exported = {};
|
|
93
|
+
for (const [k, v] of Object.entries(ctx.metadata)) {
|
|
94
|
+
if (k === 'autonomy_level')
|
|
95
|
+
continue;
|
|
96
|
+
exported[k] = v;
|
|
97
|
+
}
|
|
98
|
+
if (Object.keys(exported).length > 0) {
|
|
99
|
+
recordBase.metadata = exported;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const hash = computeHash(recordBase);
|
|
103
|
+
const record = { ...recordBase, hash };
|
|
104
|
+
const line = JSON.stringify(record) + '\n';
|
|
105
|
+
try {
|
|
106
|
+
await fs.appendFile(auditFile, line);
|
|
74
107
|
}
|
|
75
|
-
|
|
76
|
-
|
|
108
|
+
catch {
|
|
109
|
+
// Directory may have been deleted externally — retry once with mkdir
|
|
110
|
+
dirEnsured = false;
|
|
111
|
+
await ensureDir();
|
|
112
|
+
await fs.appendFile(auditFile, line);
|
|
77
113
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const record = { ...recordBase, hash };
|
|
81
|
-
prevHash = hash;
|
|
82
|
-
const line = JSON.stringify(record) + '\n';
|
|
83
|
-
// Ensure .rea dir exists (cached, with retry on failure)
|
|
84
|
-
if (!dirEnsured) {
|
|
85
|
-
await fs.mkdir(reaDir, { recursive: true });
|
|
86
|
-
dirEnsured = true;
|
|
87
|
-
}
|
|
88
|
-
try {
|
|
89
|
-
await fs.appendFile(auditFile, line);
|
|
90
|
-
}
|
|
91
|
-
catch {
|
|
92
|
-
// Directory may have been deleted externally — retry once with mkdir
|
|
93
|
-
dirEnsured = false;
|
|
94
|
-
await fs.mkdir(reaDir, { recursive: true });
|
|
95
|
-
dirEnsured = true;
|
|
96
|
-
await fs.appendFile(auditFile, line);
|
|
97
|
-
}
|
|
114
|
+
await fsyncFile(auditFile);
|
|
115
|
+
});
|
|
98
116
|
}
|
|
99
117
|
catch (auditErr) {
|
|
100
|
-
// SECURITY: Never crash the gateway on audit failure — log to stderr
|
|
118
|
+
// SECURITY: Never crash the gateway on audit failure — log to stderr.
|
|
119
|
+
// This catches lock-acquisition failures, EEXIST-without-stale, and
|
|
120
|
+
// any other I/O failure. The tool call itself continues.
|
|
101
121
|
dirEnsured = false;
|
|
102
122
|
console.error('[rea] AUDIT WRITE FAILED:', auditErr instanceof Error ? auditErr.message : auditErr);
|
|
103
123
|
}
|
package/dist/policy/loader.d.ts
CHANGED
|
@@ -60,6 +60,28 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
60
60
|
flags?: string | undefined;
|
|
61
61
|
}[] | undefined;
|
|
62
62
|
}>>;
|
|
63
|
+
audit: z.ZodOptional<z.ZodObject<{
|
|
64
|
+
rotation: z.ZodOptional<z.ZodObject<{
|
|
65
|
+
max_bytes: z.ZodOptional<z.ZodNumber>;
|
|
66
|
+
max_age_days: z.ZodOptional<z.ZodNumber>;
|
|
67
|
+
}, "strict", z.ZodTypeAny, {
|
|
68
|
+
max_bytes?: number | undefined;
|
|
69
|
+
max_age_days?: number | undefined;
|
|
70
|
+
}, {
|
|
71
|
+
max_bytes?: number | undefined;
|
|
72
|
+
max_age_days?: number | undefined;
|
|
73
|
+
}>>;
|
|
74
|
+
}, "strict", z.ZodTypeAny, {
|
|
75
|
+
rotation?: {
|
|
76
|
+
max_bytes?: number | undefined;
|
|
77
|
+
max_age_days?: number | undefined;
|
|
78
|
+
} | undefined;
|
|
79
|
+
}, {
|
|
80
|
+
rotation?: {
|
|
81
|
+
max_bytes?: number | undefined;
|
|
82
|
+
max_age_days?: number | undefined;
|
|
83
|
+
} | undefined;
|
|
84
|
+
}>>;
|
|
63
85
|
}, "strict", z.ZodTypeAny, {
|
|
64
86
|
version: string;
|
|
65
87
|
profile: string;
|
|
@@ -87,6 +109,12 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
87
109
|
flags?: string | undefined;
|
|
88
110
|
}[] | undefined;
|
|
89
111
|
} | undefined;
|
|
112
|
+
audit?: {
|
|
113
|
+
rotation?: {
|
|
114
|
+
max_bytes?: number | undefined;
|
|
115
|
+
max_age_days?: number | undefined;
|
|
116
|
+
} | undefined;
|
|
117
|
+
} | undefined;
|
|
90
118
|
}, {
|
|
91
119
|
version: string;
|
|
92
120
|
profile: string;
|
|
@@ -114,6 +142,12 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
114
142
|
flags?: string | undefined;
|
|
115
143
|
}[] | undefined;
|
|
116
144
|
} | undefined;
|
|
145
|
+
audit?: {
|
|
146
|
+
rotation?: {
|
|
147
|
+
max_bytes?: number | undefined;
|
|
148
|
+
max_age_days?: number | undefined;
|
|
149
|
+
} | undefined;
|
|
150
|
+
} | undefined;
|
|
117
151
|
}>;
|
|
118
152
|
/**
|
|
119
153
|
* Async policy loader with TTL cache and mtime-based invalidation.
|
package/dist/policy/loader.js
CHANGED
|
@@ -46,6 +46,24 @@ const RedactPolicySchema = z
|
|
|
46
46
|
patterns: z.array(UserRedactPatternSchema).optional(),
|
|
47
47
|
})
|
|
48
48
|
.strict();
|
|
49
|
+
/**
|
|
50
|
+
* G1: audit rotation thresholds. Both knobs optional; a policy that omits the
|
|
51
|
+
* `audit` block (or the `audit.rotation` sub-block) retains 0.2.x behavior
|
|
52
|
+
* with no rotation. Defaults are NOT baked into the schema — the rotator
|
|
53
|
+
* resolves them at consumption time so absence remains distinguishable from
|
|
54
|
+
* an explicit value.
|
|
55
|
+
*/
|
|
56
|
+
const AuditRotationPolicySchema = z
|
|
57
|
+
.object({
|
|
58
|
+
max_bytes: z.number().int().positive().optional(),
|
|
59
|
+
max_age_days: z.number().int().positive().optional(),
|
|
60
|
+
})
|
|
61
|
+
.strict();
|
|
62
|
+
const AuditPolicySchema = z
|
|
63
|
+
.object({
|
|
64
|
+
rotation: AuditRotationPolicySchema.optional(),
|
|
65
|
+
})
|
|
66
|
+
.strict();
|
|
49
67
|
const PolicySchema = z
|
|
50
68
|
.object({
|
|
51
69
|
version: z.string(),
|
|
@@ -62,6 +80,7 @@ const PolicySchema = z
|
|
|
62
80
|
context_protection: ContextProtectionSchema.optional(),
|
|
63
81
|
review: ReviewPolicySchema.optional(),
|
|
64
82
|
redact: RedactPolicySchema.optional(),
|
|
83
|
+
audit: AuditPolicySchema.optional(),
|
|
65
84
|
})
|
|
66
85
|
.strict();
|
|
67
86
|
const DEFAULT_CACHE_TTL_MS = 30_000;
|
package/dist/policy/types.d.ts
CHANGED
|
@@ -54,6 +54,29 @@ export interface RedactPolicy {
|
|
|
54
54
|
match_timeout_ms?: number;
|
|
55
55
|
patterns?: UserRedactPattern[];
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Audit rotation knobs (G1). Both thresholds are optional; absence of the
|
|
59
|
+
* block leaves rotation inactive (back-compat with 0.2.x behavior).
|
|
60
|
+
*
|
|
61
|
+
* `max_bytes` — when `.rea/audit.jsonl` crosses this size the next append
|
|
62
|
+
* triggers a rotation (size-based). Typical operator setting: 50 MiB.
|
|
63
|
+
*
|
|
64
|
+
* `max_age_days` — when the current `audit.jsonl`'s oldest record is older
|
|
65
|
+
* than this many days, the next append triggers a rotation (age-based). Both
|
|
66
|
+
* triggers are evaluated independently; either crossing threshold rotates.
|
|
67
|
+
*
|
|
68
|
+
* Rotation renames the current file to `audit-YYYYMMDD-HHMMSS.jsonl` in the
|
|
69
|
+
* same directory and seeds a fresh `audit.jsonl` with a single rotation
|
|
70
|
+
* marker record whose `prev_hash` equals the SHA-256 of the last record in
|
|
71
|
+
* the rotated file — preserving hash-chain continuity across the boundary.
|
|
72
|
+
*/
|
|
73
|
+
export interface AuditRotationPolicy {
|
|
74
|
+
max_bytes?: number;
|
|
75
|
+
max_age_days?: number;
|
|
76
|
+
}
|
|
77
|
+
export interface AuditPolicy {
|
|
78
|
+
rotation?: AuditRotationPolicy;
|
|
79
|
+
}
|
|
57
80
|
export interface Policy {
|
|
58
81
|
version: string;
|
|
59
82
|
profile: string;
|
|
@@ -69,4 +92,5 @@ export interface Policy {
|
|
|
69
92
|
context_protection?: ContextProtection;
|
|
70
93
|
review?: ReviewPolicy;
|
|
71
94
|
redact?: RedactPolicy;
|
|
95
|
+
audit?: AuditPolicy;
|
|
72
96
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bookedsolid/rea",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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)",
|
|
@@ -70,19 +70,21 @@
|
|
|
70
70
|
"@clack/prompts": "^1.2.0",
|
|
71
71
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
72
72
|
"commander": "^14.0.3",
|
|
73
|
+
"proper-lockfile": "^4.1.2",
|
|
74
|
+
"safe-regex": "^2.1.1",
|
|
73
75
|
"yaml": "^2.7.0",
|
|
74
76
|
"zod": "^3.23.0"
|
|
75
77
|
},
|
|
76
78
|
"devDependencies": {
|
|
77
79
|
"@changesets/cli": "^2.30.0",
|
|
78
80
|
"@types/node": "^25.5.2",
|
|
81
|
+
"@types/proper-lockfile": "^4.1.4",
|
|
79
82
|
"@types/safe-regex": "^1.1.6",
|
|
80
83
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
81
84
|
"@typescript-eslint/parser": "^8.0.0",
|
|
82
85
|
"@vitest/coverage-v8": "^3.2.4",
|
|
83
86
|
"eslint": "^10.2.0",
|
|
84
87
|
"prettier": "^3.8.1",
|
|
85
|
-
"safe-regex": "^2.1.1",
|
|
86
88
|
"typescript": "^5.8.0",
|
|
87
89
|
"vitest": "^3.1.0"
|
|
88
90
|
},
|