@goodandready/dsh-approval-gate 0.1.21 → 0.1.22

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/lib/audit-log.js CHANGED
@@ -1,345 +1,348 @@
1
- // lib/audit-log.js
2
- // Isolated append-only audit logger for dsh-approval-gate (#56, #53, #54, #55).
3
- // Logs gate decisions to ~/.dsh/approval-gate-audit.jsonl out-of-band to prevent prompt injection.
4
- // Features: fail-closed write verification (#53), single-pass reverse rotation up to 5MB (#54),
5
- // and non-erasable tombstones for tamper resistance (#55).
6
-
7
- import crypto from "node:crypto";
8
- import fs from "node:fs";
9
- import os from "node:os";
10
- import path from "node:path";
11
- import { computeFingerprint } from "./fingerprint.js";
12
- import { redactCredentials } from "./redact.js";
13
-
14
- export const DEFAULT_AUDIT_LOG_RELATIVE = path.join(".dsh", "approval-gate-audit.jsonl");
15
- export const DEFAULT_MAX_AUDIT_BYTES = 5 * 1024 * 1024; // 5 MB (#54)
16
- export const DEFAULT_MAX_AUDIT_LINES = 5000;
17
- export const DEFAULT_RETAIN_LINES = 2500;
18
- export const SCAN_CHUNK_SIZE = 64 * 1024; // 64 KB single-pass chunk
19
-
20
- /**
21
- * Pure function: single-pass reverse scan rotation for string or buffer content (#54).
22
- * Scans from the tail backwards to avoid write amplification and full array allocation.
23
- *
24
- * @param {string} content
25
- * @param {number} [maxBytes=DEFAULT_MAX_AUDIT_BYTES]
26
- * @param {number} [maxLines=DEFAULT_MAX_AUDIT_LINES]
27
- * @param {number} [retainLines=DEFAULT_RETAIN_LINES]
28
- * @returns {{ rotated: boolean, content: string, previousBytes: number, previousLinesCount: number, retainedLinesCount: number }}
29
- */
30
- export function auditRotateContent(
31
- content,
32
- maxBytes = DEFAULT_MAX_AUDIT_BYTES,
33
- maxLines = DEFAULT_MAX_AUDIT_LINES,
34
- retainLines = DEFAULT_RETAIN_LINES
35
- ) {
36
- if (typeof content !== "string" || content.length === 0) {
37
- return { rotated: false, content: "", previousBytes: 0, previousLinesCount: 0, retainedLinesCount: 0 };
38
- }
39
-
40
- const byteLength = Buffer.byteLength(content, "utf8");
41
- const allLines = content.split("\n");
42
- const nonEmptyLines = allLines[allLines.length - 1] === "" ? allLines.slice(0, -1) : allLines;
43
- const lineCount = nonEmptyLines.length;
44
-
45
- if (byteLength < maxBytes && lineCount < maxLines) {
46
- return { rotated: false, content, previousBytes: byteLength, previousLinesCount: lineCount, retainedLinesCount: lineCount };
47
- }
48
-
49
- let end = content.length;
50
- if (end > 0 && content.charCodeAt(end - 1) === 10) {
51
- end--; // skip trailing newline for line scanning
52
- }
53
-
54
- let targetCutoff = 0;
55
- let linesSeen = 0;
56
- for (let i = end - 1; i >= 0; i--) {
57
- if (content.charCodeAt(i) === 10) {
58
- linesSeen++;
59
- if (linesSeen === retainLines) {
60
- targetCutoff = i + 1;
61
- break;
62
- }
63
- }
64
- }
65
- if (linesSeen < retainLines) {
66
- targetCutoff = 0;
67
- }
68
-
69
- const retained = content.slice(targetCutoff);
70
- return {
71
- rotated: true,
72
- content: retained,
73
- previousBytes: byteLength,
74
- previousLinesCount: lineCount,
75
- retainedLinesCount: Math.min(lineCount, retainLines),
76
- };
77
- }
78
-
79
- export class AuditLogger {
80
- /**
81
- * @param {{
82
- * logPath?: string,
83
- * disabled?: boolean,
84
- * failClosed?: boolean,
85
- * maxBytes?: number,
86
- * maxLines?: number,
87
- * retainLines?: number
88
- * }} [options]
89
- */
90
- constructor(options = {}) {
91
- this.disabled = options.disabled === true;
92
- this.failClosed = options.failClosed === true;
93
- this.maxBytes = typeof options.maxBytes === "number" && options.maxBytes > 0 ? options.maxBytes : DEFAULT_MAX_AUDIT_BYTES;
94
- this.maxLines = typeof options.maxLines === "number" && options.maxLines > 0 ? options.maxLines : DEFAULT_MAX_AUDIT_LINES;
95
- this.retainLines = typeof options.retainLines === "number" && options.retainLines > 0 ? options.retainLines : DEFAULT_RETAIN_LINES;
96
- this.lastError = null;
97
-
98
- if (options.logPath) {
99
- this.logPath = options.logPath;
100
- } else {
101
- const home = os.homedir ? os.homedir() : (process.env.HOME || process.env.USERPROFILE || "");
102
- this.logPath = home ? path.join(home, DEFAULT_AUDIT_LOG_RELATIVE) : "";
103
- }
104
- }
105
-
106
- /**
107
- * Write an audit record to the isolated log file. Returns boolean (#53).
108
- *
109
- * @param {{
110
- * sessionId?: string,
111
- * execution?: any,
112
- * decision: { verdict: string, stage?: string, reason?: string, detail?: string, dryRun?: boolean, ruleId?: string },
113
- * dryRun?: boolean
114
- * }} record
115
- * @returns {boolean} True if append succeeded or disabled, false if write failed
116
- */
117
- appendAuditLine(record) {
118
- if (this.disabled || !this.logPath) return true;
119
-
120
- try {
121
- const now = Date.now();
122
- const execution = record.execution || {};
123
- const decision = record.decision || {};
124
- const tool = String(execution.name || execution.tool || "").trim();
125
- const rawCmd = execution.arguments && typeof execution.arguments.command === "string"
126
- ? execution.arguments.command
127
- : "";
128
- const redactedCmd = rawCmd ? redactCredentials(rawCmd) : undefined;
129
- const fp = computeFingerprint(execution);
130
-
131
- const entry = {
132
- timestamp: now,
133
- iso: new Date(now).toISOString(),
134
- sessionId: record.sessionId || "default",
135
- tool,
136
- command: redactedCmd,
137
- verdict: decision.verdict,
138
- stage: decision.stage,
139
- reason: decision.reason,
140
- ruleId: decision.ruleId,
141
- detail: decision.detail,
142
- fingerprint: fp,
143
- dryRun: Boolean(record.dryRun || decision.dryRun),
144
- };
145
-
146
- const line = JSON.stringify(entry) + "\n";
147
- const dir = path.dirname(this.logPath);
148
- if (!fs.existsSync(dir)) {
149
- fs.mkdirSync(dir, { recursive: true });
150
- }
151
-
152
- fs.appendFileSync(this.logPath, line, "utf8");
153
- this.lastError = null;
154
-
155
- // Check if rotation is needed (#54)
156
- this._checkRotation();
157
- return true;
158
- } catch (err) {
159
- this.lastError = err;
160
- if (this.failClosed) {
161
- return false;
162
- }
163
- return false;
164
- }
165
- }
166
-
167
- /**
168
- * Alias for backward compatibility.
169
- * @param {any} record
170
- * @returns {boolean}
171
- */
172
- log(record) {
173
- return this.appendAuditLine(record);
174
- }
175
-
176
- /**
177
- * Single-pass reverse scan rotation (#54).
178
- * @private
179
- */
180
- _checkRotation() {
181
- if (!this.logPath || !fs.existsSync(this.logPath)) return;
182
- try {
183
- const stat = fs.statSync(this.logPath);
184
- if (stat.size >= this.maxBytes) {
185
- this.rotate();
186
- }
187
- } catch {
188
- // Rotation check failure must not crash logger
189
- }
190
- }
191
-
192
- /**
193
- * Explicitly rotate audit log using single-pass reverse scan and tombstone (#54, #55).
194
- * @returns {boolean}
195
- */
196
- rotate() {
197
- if (!this.logPath || !fs.existsSync(this.logPath)) return false;
198
-
199
- try {
200
- const raw = fs.readFileSync(this.logPath, "utf8");
201
- const prevSha256 = crypto.createHash("sha256").update(raw, "utf8").digest("hex");
202
- // Force rotation to retainLines
203
- const rot = auditRotateContent(raw, 0, 0, this.retainLines);
204
-
205
- const now = Date.now();
206
- const tombstone = {
207
- tombstone: true,
208
- timestamp: now,
209
- iso: new Date(now).toISOString(),
210
- reason: "rotation",
211
- clearedBy: "system:rotator",
212
- previousSha256: prevSha256,
213
- previousBytes: rot.previousBytes,
214
- previousLinesCount: rot.previousLinesCount,
215
- retainedLinesCount: rot.retainedLinesCount,
216
- };
217
-
218
- const tombstoneLine = JSON.stringify(tombstone) + "\n";
219
- const newContent = tombstoneLine + rot.content;
220
-
221
- const tmpPath = this.logPath + ".tmp." + now;
222
- fs.writeFileSync(tmpPath, newContent, "utf8");
223
- fs.renameSync(tmpPath, this.logPath);
224
- return true;
225
- } catch (err) {
226
- this.lastError = err;
227
- return false;
228
- }
229
- }
230
-
231
- /**
232
- * Clear the audit log with a non-erasable tombstone (#55).
233
- * Instead of silent deletion, creates an immutable tombstone record preserving hash and event chain.
234
- *
235
- * @param {{ reason?: string, clearedBy?: string }} [options]
236
- * @returns {{
237
- * tombstone: boolean,
238
- * timestamp: number,
239
- * iso: string,
240
- * reason: string,
241
- * clearedBy: string,
242
- * previousSha256: string,
243
- * previousBytes: number,
244
- * previousLinesCount: number
245
- * }}
246
- */
247
- clear(options = {}) {
248
- const reason = options.reason || "manual_clear";
249
- const clearedBy = options.clearedBy || "admin";
250
- let prevSha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
251
- let prevBytes = 0;
252
- let prevLines = 0;
253
-
254
- if (this.logPath && fs.existsSync(this.logPath)) {
255
- try {
256
- const raw = fs.readFileSync(this.logPath, "utf8");
257
- prevBytes = Buffer.byteLength(raw, "utf8");
258
- prevSha256 = crypto.createHash("sha256").update(raw, "utf8").digest("hex");
259
- prevLines = raw.split("\n").filter((l) => l.trim().length > 0).length;
260
- } catch {
261
- // Read error
262
- }
263
- }
264
-
265
- const now = Date.now();
266
- const tombstone = {
267
- tombstone: true,
268
- timestamp: now,
269
- iso: new Date(now).toISOString(),
270
- reason,
271
- clearedBy,
272
- previousSha256: prevSha256,
273
- previousBytes: prevBytes,
274
- previousLinesCount: prevLines,
275
- };
276
-
277
- if (this.logPath) {
278
- const dir = path.dirname(this.logPath);
279
- if (!fs.existsSync(dir)) {
280
- fs.mkdirSync(dir, { recursive: true });
281
- }
282
- const line = JSON.stringify(tombstone) + "\n";
283
- fs.writeFileSync(this.logPath, line, "utf8");
284
- }
285
-
286
- return tombstone;
287
- }
288
-
289
- /**
290
- * Read all tombstones from the log file (#55).
291
- * @returns {Array<any>}
292
- */
293
- getTombstones() {
294
- if (!this.logPath || !fs.existsSync(this.logPath)) return [];
295
- try {
296
- const raw = fs.readFileSync(this.logPath, "utf8");
297
- const lines = raw.split("\n").filter((l) => l.trim().length > 0);
298
- const tombstones = [];
299
- for (const line of lines) {
300
- try {
301
- const parsed = JSON.parse(line);
302
- if (parsed && parsed.tombstone === true) {
303
- tombstones.push(parsed);
304
- }
305
- } catch {
306
- // ignore corrupted lines
307
- }
308
- }
309
- return tombstones;
310
- } catch {
311
- return [];
312
- }
313
- }
314
-
315
- /**
316
- * Read recent entries from the audit log.
317
- * @param {{ includeTombstones?: boolean, limit?: number }} [options]
318
- * @returns {Array<any>}
319
- */
320
- readEntries(options = {}) {
321
- if (!this.logPath || !fs.existsSync(this.logPath)) return [];
322
- const includeTombstones = options.includeTombstones !== false;
323
- const limit = typeof options.limit === "number" && options.limit > 0 ? options.limit : 100;
324
-
325
- try {
326
- const raw = fs.readFileSync(this.logPath, "utf8");
327
- const lines = raw.split("\n").filter((l) => l.trim().length > 0);
328
- const entries = [];
329
- for (let i = lines.length - 1; i >= 0 && entries.length < limit; i--) {
330
- try {
331
- const parsed = JSON.parse(lines[i]);
332
- if (!includeTombstones && parsed.tombstone === true) {
333
- continue;
334
- }
335
- entries.push(parsed);
336
- } catch {
337
- // ignore
338
- }
339
- }
340
- return entries.reverse();
341
- } catch {
342
- return [];
343
- }
344
- }
345
- }
1
+ // lib/audit-log.js
2
+ // Isolated append-only audit logger for dsh-approval-gate (#56, #53, #54, #55).
3
+ // Logs gate decisions to ~/.dsh/approval-gate-audit.jsonl out-of-band to prevent prompt injection.
4
+ // Features: fail-closed write verification (#53), single-pass reverse rotation up to 5MB (#54),
5
+ // and non-erasable tombstones for tamper resistance (#55).
6
+
7
+ import crypto from "node:crypto";
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import { computeFingerprint } from "./fingerprint.js";
12
+ import { redactCredentials } from "./redact.js";
13
+
14
+ export const DEFAULT_AUDIT_LOG_RELATIVE = path.join(".dsh", "approval-gate-audit.jsonl");
15
+ export const DEFAULT_MAX_AUDIT_BYTES = 5 * 1024 * 1024; // 5 MB (#54)
16
+ export const DEFAULT_MAX_AUDIT_LINES = 5000;
17
+ export const DEFAULT_RETAIN_LINES = 2500;
18
+ export const SCAN_CHUNK_SIZE = 64 * 1024; // 64 KB single-pass chunk
19
+
20
+ /**
21
+ * Pure function: single-pass reverse scan rotation for string or buffer content (#54).
22
+ * Scans from the tail backwards to avoid write amplification and full array allocation.
23
+ *
24
+ * @param {string} content
25
+ * @param {number} [maxBytes=DEFAULT_MAX_AUDIT_BYTES]
26
+ * @param {number} [maxLines=DEFAULT_MAX_AUDIT_LINES]
27
+ * @param {number} [retainLines=DEFAULT_RETAIN_LINES]
28
+ * @returns {{ rotated: boolean, content: string, previousBytes: number, previousLinesCount: number, retainedLinesCount: number }}
29
+ */
30
+ export function auditRotateContent(
31
+ content,
32
+ maxBytes = DEFAULT_MAX_AUDIT_BYTES,
33
+ maxLines = DEFAULT_MAX_AUDIT_LINES,
34
+ retainLines = DEFAULT_RETAIN_LINES
35
+ ) {
36
+ if (typeof content !== "string" || content.length === 0) {
37
+ return { rotated: false, content: "", previousBytes: 0, previousLinesCount: 0, retainedLinesCount: 0 };
38
+ }
39
+
40
+ const byteLength = Buffer.byteLength(content, "utf8");
41
+ const allLines = content.split("\n");
42
+ const nonEmptyLines = allLines[allLines.length - 1] === "" ? allLines.slice(0, -1) : allLines;
43
+ const lineCount = nonEmptyLines.length;
44
+
45
+ if (byteLength < maxBytes && lineCount < maxLines) {
46
+ return { rotated: false, content, previousBytes: byteLength, previousLinesCount: lineCount, retainedLinesCount: lineCount };
47
+ }
48
+
49
+ let end = content.length;
50
+ if (end > 0 && content.charCodeAt(end - 1) === 10) {
51
+ end--; // skip trailing newline for line scanning
52
+ }
53
+
54
+ let targetCutoff = 0;
55
+ let linesSeen = 0;
56
+ for (let i = end - 1; i >= 0; i--) {
57
+ if (content.charCodeAt(i) === 10) {
58
+ linesSeen++;
59
+ if (linesSeen === retainLines) {
60
+ targetCutoff = i + 1;
61
+ break;
62
+ }
63
+ }
64
+ }
65
+ if (linesSeen < retainLines) {
66
+ targetCutoff = 0;
67
+ }
68
+
69
+ const retained = content.slice(targetCutoff);
70
+ return {
71
+ rotated: true,
72
+ content: retained,
73
+ previousBytes: byteLength,
74
+ previousLinesCount: lineCount,
75
+ retainedLinesCount: Math.min(lineCount, retainLines),
76
+ };
77
+ }
78
+
79
+ export class AuditLogger {
80
+ /**
81
+ * @param {{
82
+ * logPath?: string,
83
+ * disabled?: boolean,
84
+ * failClosed?: boolean,
85
+ * maxBytes?: number,
86
+ * maxLines?: number,
87
+ * retainLines?: number
88
+ * }} [options]
89
+ */
90
+ constructor(options = {}) {
91
+ this.disabled = options.disabled === true;
92
+ this.failClosed = options.failClosed === true;
93
+ this.maxBytes = typeof options.maxBytes === "number" && options.maxBytes > 0 ? options.maxBytes : DEFAULT_MAX_AUDIT_BYTES;
94
+ this.maxLines = typeof options.maxLines === "number" && options.maxLines > 0 ? options.maxLines : DEFAULT_MAX_AUDIT_LINES;
95
+ this.retainLines = typeof options.retainLines === "number" && options.retainLines > 0 ? options.retainLines : DEFAULT_RETAIN_LINES;
96
+ this.lastError = null;
97
+
98
+ if (options.logPath) {
99
+ this.logPath = options.logPath;
100
+ } else {
101
+ const home = os.homedir ? os.homedir() : (process.env.HOME || process.env.USERPROFILE || "");
102
+ this.logPath = home ? path.join(home, DEFAULT_AUDIT_LOG_RELATIVE) : "";
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Write an audit record to the isolated log file. Returns boolean (#53).
108
+ *
109
+ * @param {{
110
+ * sessionId?: string,
111
+ * execution?: any,
112
+ * decision: { verdict: string, stage?: string, reason?: string, detail?: string, dryRun?: boolean, ruleId?: string },
113
+ * dryRun?: boolean
114
+ * }} record
115
+ * @returns {boolean} True if append succeeded or disabled, false if write failed
116
+ */
117
+ appendAuditLine(record) {
118
+ if (this.disabled || !this.logPath) return true;
119
+
120
+ try {
121
+ const now = Date.now();
122
+ const execution = record.execution || {};
123
+ const decision = record.decision || {};
124
+ const tool = String(execution.name || execution.tool || "").trim();
125
+ const rawCmd = execution.arguments && typeof execution.arguments.command === "string"
126
+ ? execution.arguments.command
127
+ : "";
128
+ const redactedCmd = rawCmd ? redactCredentials(rawCmd) : undefined;
129
+ const fp = computeFingerprint(execution);
130
+
131
+ const entry = {
132
+ timestamp: now,
133
+ iso: new Date(now).toISOString(),
134
+ sessionId: record.sessionId || "default",
135
+ tool,
136
+ command: redactedCmd,
137
+ verdict: decision.verdict,
138
+ stage: decision.stage,
139
+ reason: decision.reason,
140
+ ruleId: decision.ruleId,
141
+ detail: decision.detail,
142
+ fingerprint: fp,
143
+ dryRun: Boolean(record.dryRun || decision.dryRun),
144
+ callId: record.callId || decision.callId || null,
145
+ agentId: record.agentId || decision.agentId || null,
146
+ subagentId: record.subagentId || decision.subagentId || null,
147
+ };
148
+
149
+ const line = JSON.stringify(entry) + "\n";
150
+ const dir = path.dirname(this.logPath);
151
+ if (!fs.existsSync(dir)) {
152
+ fs.mkdirSync(dir, { recursive: true });
153
+ }
154
+
155
+ fs.appendFileSync(this.logPath, line, "utf8");
156
+ this.lastError = null;
157
+
158
+ // Check if rotation is needed (#54)
159
+ this._checkRotation();
160
+ return true;
161
+ } catch (err) {
162
+ this.lastError = err;
163
+ if (this.failClosed) {
164
+ return false;
165
+ }
166
+ return false;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Alias for backward compatibility.
172
+ * @param {any} record
173
+ * @returns {boolean}
174
+ */
175
+ log(record) {
176
+ return this.appendAuditLine(record);
177
+ }
178
+
179
+ /**
180
+ * Single-pass reverse scan rotation (#54).
181
+ * @private
182
+ */
183
+ _checkRotation() {
184
+ if (!this.logPath || !fs.existsSync(this.logPath)) return;
185
+ try {
186
+ const stat = fs.statSync(this.logPath);
187
+ if (stat.size >= this.maxBytes) {
188
+ this.rotate();
189
+ }
190
+ } catch {
191
+ // Rotation check failure must not crash logger
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Explicitly rotate audit log using single-pass reverse scan and tombstone (#54, #55).
197
+ * @returns {boolean}
198
+ */
199
+ rotate() {
200
+ if (!this.logPath || !fs.existsSync(this.logPath)) return false;
201
+
202
+ try {
203
+ const raw = fs.readFileSync(this.logPath, "utf8");
204
+ const prevSha256 = crypto.createHash("sha256").update(raw, "utf8").digest("hex");
205
+ // Force rotation to retainLines
206
+ const rot = auditRotateContent(raw, 0, 0, this.retainLines);
207
+
208
+ const now = Date.now();
209
+ const tombstone = {
210
+ tombstone: true,
211
+ timestamp: now,
212
+ iso: new Date(now).toISOString(),
213
+ reason: "rotation",
214
+ clearedBy: "system:rotator",
215
+ previousSha256: prevSha256,
216
+ previousBytes: rot.previousBytes,
217
+ previousLinesCount: rot.previousLinesCount,
218
+ retainedLinesCount: rot.retainedLinesCount,
219
+ };
220
+
221
+ const tombstoneLine = JSON.stringify(tombstone) + "\n";
222
+ const newContent = tombstoneLine + rot.content;
223
+
224
+ const tmpPath = this.logPath + ".tmp." + now;
225
+ fs.writeFileSync(tmpPath, newContent, "utf8");
226
+ fs.renameSync(tmpPath, this.logPath);
227
+ return true;
228
+ } catch (err) {
229
+ this.lastError = err;
230
+ return false;
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Clear the audit log with a non-erasable tombstone (#55).
236
+ * Instead of silent deletion, creates an immutable tombstone record preserving hash and event chain.
237
+ *
238
+ * @param {{ reason?: string, clearedBy?: string }} [options]
239
+ * @returns {{
240
+ * tombstone: boolean,
241
+ * timestamp: number,
242
+ * iso: string,
243
+ * reason: string,
244
+ * clearedBy: string,
245
+ * previousSha256: string,
246
+ * previousBytes: number,
247
+ * previousLinesCount: number
248
+ * }}
249
+ */
250
+ clear(options = {}) {
251
+ const reason = options.reason || "manual_clear";
252
+ const clearedBy = options.clearedBy || "admin";
253
+ let prevSha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
254
+ let prevBytes = 0;
255
+ let prevLines = 0;
256
+
257
+ if (this.logPath && fs.existsSync(this.logPath)) {
258
+ try {
259
+ const raw = fs.readFileSync(this.logPath, "utf8");
260
+ prevBytes = Buffer.byteLength(raw, "utf8");
261
+ prevSha256 = crypto.createHash("sha256").update(raw, "utf8").digest("hex");
262
+ prevLines = raw.split("\n").filter((l) => l.trim().length > 0).length;
263
+ } catch {
264
+ // Read error
265
+ }
266
+ }
267
+
268
+ const now = Date.now();
269
+ const tombstone = {
270
+ tombstone: true,
271
+ timestamp: now,
272
+ iso: new Date(now).toISOString(),
273
+ reason,
274
+ clearedBy,
275
+ previousSha256: prevSha256,
276
+ previousBytes: prevBytes,
277
+ previousLinesCount: prevLines,
278
+ };
279
+
280
+ if (this.logPath) {
281
+ const dir = path.dirname(this.logPath);
282
+ if (!fs.existsSync(dir)) {
283
+ fs.mkdirSync(dir, { recursive: true });
284
+ }
285
+ const line = JSON.stringify(tombstone) + "\n";
286
+ fs.writeFileSync(this.logPath, line, "utf8");
287
+ }
288
+
289
+ return tombstone;
290
+ }
291
+
292
+ /**
293
+ * Read all tombstones from the log file (#55).
294
+ * @returns {Array<any>}
295
+ */
296
+ getTombstones() {
297
+ if (!this.logPath || !fs.existsSync(this.logPath)) return [];
298
+ try {
299
+ const raw = fs.readFileSync(this.logPath, "utf8");
300
+ const lines = raw.split("\n").filter((l) => l.trim().length > 0);
301
+ const tombstones = [];
302
+ for (const line of lines) {
303
+ try {
304
+ const parsed = JSON.parse(line);
305
+ if (parsed && parsed.tombstone === true) {
306
+ tombstones.push(parsed);
307
+ }
308
+ } catch {
309
+ // ignore corrupted lines
310
+ }
311
+ }
312
+ return tombstones;
313
+ } catch {
314
+ return [];
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Read recent entries from the audit log.
320
+ * @param {{ includeTombstones?: boolean, limit?: number }} [options]
321
+ * @returns {Array<any>}
322
+ */
323
+ readEntries(options = {}) {
324
+ if (!this.logPath || !fs.existsSync(this.logPath)) return [];
325
+ const includeTombstones = options.includeTombstones !== false;
326
+ const limit = typeof options.limit === "number" && options.limit > 0 ? options.limit : 100;
327
+
328
+ try {
329
+ const raw = fs.readFileSync(this.logPath, "utf8");
330
+ const lines = raw.split("\n").filter((l) => l.trim().length > 0);
331
+ const entries = [];
332
+ for (let i = lines.length - 1; i >= 0 && entries.length < limit; i--) {
333
+ try {
334
+ const parsed = JSON.parse(lines[i]);
335
+ if (!includeTombstones && parsed.tombstone === true) {
336
+ continue;
337
+ }
338
+ entries.push(parsed);
339
+ } catch {
340
+ // ignore
341
+ }
342
+ }
343
+ return entries.reverse();
344
+ } catch {
345
+ return [];
346
+ }
347
+ }
348
+ }