@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.
@@ -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
- function computeHash(record) {
6
- const payload = JSON.stringify(record);
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 computing its hash, ensuring a linear chain.
60
+ // Each write awaits the previous one before running its lock-scoped append.
45
61
  const writePromise = writeQueue.then(async () => {
46
62
  try {
47
- const now = new Date().toISOString();
48
- const recordBase = {
49
- timestamp: now,
50
- session_id: ctx.session_id,
51
- tool_name: ctx.tool_name,
52
- server_name: ctx.server_name,
53
- tier: ctx.tier ?? Tier.Write,
54
- status: ctx.status,
55
- autonomy_level: autonomyLevel,
56
- duration_ms,
57
- prev_hash: prevHash,
58
- };
59
- if (ctx.error) {
60
- recordBase.error = ctx.error;
61
- }
62
- if (ctx.redacted_fields?.length) {
63
- recordBase.redacted_fields = ctx.redacted_fields;
64
- }
65
- // Attach caller-supplied metadata when the middleware context carries any.
66
- // The `autonomy_level` key is reserved for internal bookkeeping (see above)
67
- // and is excluded from the exported metadata payload.
68
- if (ctx.metadata !== undefined) {
69
- const exported = {};
70
- for (const [k, v] of Object.entries(ctx.metadata)) {
71
- if (k === 'autonomy_level')
72
- continue;
73
- exported[k] = v;
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
- if (Object.keys(exported).length > 0) {
76
- recordBase.metadata = exported;
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
- const hash = computeHash(recordBase);
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
  }
@@ -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.
@@ -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;
@@ -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.2.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
  },