@goodandready/dsh-approval-gate 0.1.15 → 0.1.17
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/CHANGELOG.md +154 -138
- package/lib/audit-log.js +277 -8
- package/lib/classifier.js +392 -0
- package/lib/counters.js +144 -0
- package/lib/engine.js +464 -289
- package/lib/index.js +557 -480
- package/lib/messages.js +312 -282
- package/package.json +55 -55
package/lib/audit-log.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// lib/audit-log.js
|
|
2
|
-
// Isolated append-only audit logger for dsh-approval-gate (#56).
|
|
2
|
+
// Isolated append-only audit logger for dsh-approval-gate (#56, #53, #54, #55).
|
|
3
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).
|
|
4
6
|
|
|
7
|
+
import crypto from "node:crypto";
|
|
5
8
|
import fs from "node:fs";
|
|
6
9
|
import os from "node:os";
|
|
7
10
|
import path from "node:path";
|
|
@@ -9,13 +12,89 @@ import { computeFingerprint } from "./fingerprint.js";
|
|
|
9
12
|
import { redactCredentials } from "./redact.js";
|
|
10
13
|
|
|
11
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
|
+
}
|
|
12
78
|
|
|
13
79
|
export class AuditLogger {
|
|
14
80
|
/**
|
|
15
|
-
* @param {{
|
|
81
|
+
* @param {{
|
|
82
|
+
* logPath?: string,
|
|
83
|
+
* disabled?: boolean,
|
|
84
|
+
* failClosed?: boolean,
|
|
85
|
+
* maxBytes?: number,
|
|
86
|
+
* maxLines?: number,
|
|
87
|
+
* retainLines?: number
|
|
88
|
+
* }} [options]
|
|
16
89
|
*/
|
|
17
90
|
constructor(options = {}) {
|
|
18
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
|
+
|
|
19
98
|
if (options.logPath) {
|
|
20
99
|
this.logPath = options.logPath;
|
|
21
100
|
} else {
|
|
@@ -25,17 +104,18 @@ export class AuditLogger {
|
|
|
25
104
|
}
|
|
26
105
|
|
|
27
106
|
/**
|
|
28
|
-
* Write an audit record to the isolated log file.
|
|
107
|
+
* Write an audit record to the isolated log file. Returns boolean (#53).
|
|
29
108
|
*
|
|
30
109
|
* @param {{
|
|
31
110
|
* sessionId?: string,
|
|
32
111
|
* execution?: any,
|
|
33
|
-
* decision: { verdict: string, stage?: string, reason?: string, detail?: string, dryRun?: boolean },
|
|
112
|
+
* decision: { verdict: string, stage?: string, reason?: string, detail?: string, dryRun?: boolean, ruleId?: string },
|
|
34
113
|
* dryRun?: boolean
|
|
35
114
|
* }} record
|
|
115
|
+
* @returns {boolean} True if append succeeded or disabled, false if write failed
|
|
36
116
|
*/
|
|
37
|
-
|
|
38
|
-
if (this.disabled || !this.logPath) return;
|
|
117
|
+
appendAuditLine(record) {
|
|
118
|
+
if (this.disabled || !this.logPath) return true;
|
|
39
119
|
|
|
40
120
|
try {
|
|
41
121
|
const now = Date.now();
|
|
@@ -57,6 +137,7 @@ export class AuditLogger {
|
|
|
57
137
|
verdict: decision.verdict,
|
|
58
138
|
stage: decision.stage,
|
|
59
139
|
reason: decision.reason,
|
|
140
|
+
ruleId: decision.ruleId,
|
|
60
141
|
detail: decision.detail,
|
|
61
142
|
fingerprint: fp,
|
|
62
143
|
dryRun: Boolean(record.dryRun || decision.dryRun),
|
|
@@ -67,10 +148,198 @@ export class AuditLogger {
|
|
|
67
148
|
if (!fs.existsSync(dir)) {
|
|
68
149
|
fs.mkdirSync(dir, { recursive: true });
|
|
69
150
|
}
|
|
151
|
+
|
|
70
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();
|
|
71
341
|
} catch {
|
|
72
|
-
|
|
73
|
-
// but never leak errors into agent prompt.
|
|
342
|
+
return [];
|
|
74
343
|
}
|
|
75
344
|
}
|
|
76
345
|
}
|