@goodandready/dsh-approval-gate 0.1.15 → 0.1.16
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 +8 -0
- package/lib/audit-log.js +277 -8
- package/lib/counters.js +144 -0
- package/lib/engine.js +134 -16
- package/lib/index.js +521 -480
- package/lib/messages.js +294 -282
- package/package.json +1 -1
package/lib/engine.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// lib/engine.js
|
|
2
2
|
// 5-tier Security Decision Engine for dsh-approval-gate.
|
|
3
3
|
// Waterfall:
|
|
4
|
-
// P0: Hard Deny (Bands Band 0 + Declarative Rules Deny + inspectExecution hard denials + protected files + retry exhaustion)
|
|
4
|
+
// P0: Hard Deny (Bands Band 0 + Declarative Rules Deny + inspectExecution hard denials + protected files + retry exhaustion + fail-closed audit)
|
|
5
5
|
// P1: Session Grants & Declarative Allow (scoped temporary allowances and whitelists)
|
|
6
6
|
// P2: Static Rules / Safe Allow (Bands Band 1 + safe read-only commands)
|
|
7
7
|
// P3: Optional Semantic Classifier
|
|
8
|
-
// P4: Human Ask Fallback (uncertain syntax / ambiguous commands / tripped breaker / declarative ask)
|
|
8
|
+
// P4: Human Ask Fallback (uncertain syntax / ambiguous commands / tripped breaker / declarative ask / lockedAsk)
|
|
9
9
|
|
|
10
10
|
import { BandsEngine } from "./bands.js";
|
|
11
11
|
import { CircuitBreaker } from "./breaker.js";
|
|
@@ -16,6 +16,56 @@ import { RetryExhaustionGuard } from "./retry-exhaustion.js";
|
|
|
16
16
|
import { QualityLedger } from "./ledger.js";
|
|
17
17
|
import { AuditLogger } from "./audit-log.js";
|
|
18
18
|
import { DeclarativeRulesEngine } from "./rules-loader.js";
|
|
19
|
+
import { BlockCounters } from "./counters.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Determine whether an operation belongs to a critical risk category requiring lockedAsk (#52).
|
|
23
|
+
* Locked ask operations can never be auto-approved by unattended timeouts or fallback heuristics.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} [toolName]
|
|
26
|
+
* @param {string} [command]
|
|
27
|
+
* @param {any} [execution]
|
|
28
|
+
* @returns {boolean}
|
|
29
|
+
*/
|
|
30
|
+
export function isCriticalOperation(toolName, command, execution) {
|
|
31
|
+
const cmd = String(command || "").trim();
|
|
32
|
+
const tool = String(toolName || "").trim();
|
|
33
|
+
if (!cmd && !tool) return false;
|
|
34
|
+
|
|
35
|
+
// 1. Root and privilege escalation
|
|
36
|
+
if (/^(sudo|su|doas)\b/i.test(cmd)) return true;
|
|
37
|
+
if (/\b(chmod\s+(-[a-zA-Z]*\s+)?777|chown\s+(-[a-zA-Z]*\s+)?root)\b/i.test(cmd)) return true;
|
|
38
|
+
|
|
39
|
+
// 2. Recursive or bulk destructive deletion
|
|
40
|
+
if (/\brm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s+|--recursive\s+)/i.test(cmd)) return true;
|
|
41
|
+
if (/\b(shred|wipefs)\b/i.test(cmd)) return true;
|
|
42
|
+
|
|
43
|
+
// 3. Sensitive credentials and SSH access
|
|
44
|
+
if (/(\.ssh\/(id_|authorized_keys|known_hosts)|\.gnupg\/|\/etc\/shadow)/i.test(cmd)) return true;
|
|
45
|
+
|
|
46
|
+
// 4. Raw disk and filesystem manipulation
|
|
47
|
+
if (/\b(mkfs|fdisk|parted)\b/i.test(cmd)) return true;
|
|
48
|
+
if (/\bdd\s+.*of=\/dev\/(sd[a-z]|nvme|vd[a-z]|hd[a-z]|loop)/i.test(cmd)) return true;
|
|
49
|
+
|
|
50
|
+
// 5. Remote shell piping and reverse shells
|
|
51
|
+
if (/curl\s+.*\|\s*(bash|sh|python|perl)/i.test(cmd)) return true;
|
|
52
|
+
if (/\b(nc|ncat|netcat)\s+.*-e\s+/i.test(cmd)) return true;
|
|
53
|
+
if (/>&\s*\/dev\/tcp\//i.test(cmd)) return true;
|
|
54
|
+
|
|
55
|
+
// 6. System halts and service shutdowns
|
|
56
|
+
if (/\b(shutdown|reboot|poweroff|halt|init\s+0)\b/i.test(cmd)) return true;
|
|
57
|
+
if (/systemctl\s+(stop|disable|mask)\s+(sshd|systemd|ufw|iptables)/i.test(cmd)) return true;
|
|
58
|
+
|
|
59
|
+
// 7. Protected file writes via tools
|
|
60
|
+
if (tool === "write_to_file" || tool === "edit" || tool === "replace_file_content" || tool === "str_replace_editor") {
|
|
61
|
+
const target = execution && execution.arguments && (execution.arguments.TargetFile || execution.arguments.target_file || execution.arguments.path || "");
|
|
62
|
+
if (typeof target === "string" && /(\/etc\/|\.ssh\/|\/boot\/|\/sys\/|\/proc\/)/i.test(target)) {
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
19
69
|
|
|
20
70
|
export class SecurityEngine {
|
|
21
71
|
/**
|
|
@@ -27,6 +77,8 @@ export class SecurityEngine {
|
|
|
27
77
|
* ledger?: QualityLedger,
|
|
28
78
|
* auditLogger?: AuditLogger,
|
|
29
79
|
* declarativeRules?: DeclarativeRulesEngine,
|
|
80
|
+
* counters?: BlockCounters,
|
|
81
|
+
* auditFailClosed?: boolean,
|
|
30
82
|
* inspectConfig?: { toolName?: string, fileWriteTools?: string[], workspaceDir?: string },
|
|
31
83
|
* classifier?: ((execution: any, context?: any) => { verdict: "allow"|"deny", reason?: string }),
|
|
32
84
|
* grantRegistry?: { decide: (tool: string, args: any, context?: any) => "allow"|"no-match" },
|
|
@@ -44,10 +96,17 @@ export class SecurityEngine {
|
|
|
44
96
|
this.defaultVerdict = options.defaultVerdict || "allow";
|
|
45
97
|
|
|
46
98
|
this.dryRun = Boolean(options.dryRun || options.shadowMode);
|
|
99
|
+
this.auditFailClosed = options.auditFailClosed === true;
|
|
47
100
|
this.cache = options.cache !== undefined ? options.cache : new VerdictCache();
|
|
48
101
|
this.retryGuard = options.retryGuard !== undefined ? options.retryGuard : new RetryExhaustionGuard();
|
|
49
102
|
this.ledger = options.ledger !== undefined ? options.ledger : new QualityLedger();
|
|
50
|
-
this.
|
|
103
|
+
this.counters = options.counters || new BlockCounters();
|
|
104
|
+
this.auditLogger = options.auditLogger !== undefined ? options.auditLogger : new AuditLogger({
|
|
105
|
+
failClosed: this.auditFailClosed,
|
|
106
|
+
});
|
|
107
|
+
if (this.auditFailClosed && this.auditLogger) {
|
|
108
|
+
this.auditLogger.failClosed = true;
|
|
109
|
+
}
|
|
51
110
|
this.declarativeRules = options.declarativeRules || new DeclarativeRulesEngine({
|
|
52
111
|
workspaceDir: this.inspectConfig?.workspaceDir,
|
|
53
112
|
});
|
|
@@ -58,7 +117,7 @@ export class SecurityEngine {
|
|
|
58
117
|
*
|
|
59
118
|
* @param {{ name: string, arguments?: any }} execution - Tool call
|
|
60
119
|
* @param {{ sessionId?: string, knobs?: any, cwd?: string }} [context] - Context options
|
|
61
|
-
* @returns {{ verdict: "allow" | "deny" | "ask", stage: "P0" | "P1" | "P2" | "P3" | "P4", reason: string, detail?: string, hit?: any, dryRun?: boolean, cached?: boolean }}
|
|
120
|
+
* @returns {{ verdict: "allow" | "deny" | "ask", stage: "P0" | "P1" | "P2" | "P3" | "P4", reason: string, detail?: string, hit?: any, dryRun?: boolean, cached?: boolean, lockedAsk?: boolean, lockedReason?: string, auditFailed?: boolean }}
|
|
62
121
|
*/
|
|
63
122
|
decide(execution, context = {}) {
|
|
64
123
|
try {
|
|
@@ -76,7 +135,8 @@ export class SecurityEngine {
|
|
|
76
135
|
};
|
|
77
136
|
const exhDecision = { verdict: "deny", stage: "P0", reason: "retry-exhaustion", detail: exhHit.detail, hit: exhHit };
|
|
78
137
|
if (this.ledger) this.ledger.record(sessionId, "exhaustion", { reason: "retry-exhaustion", tool: toolName });
|
|
79
|
-
if (this.
|
|
138
|
+
if (this.counters) this.counters.record(exhDecision, execution);
|
|
139
|
+
if (this.auditLogger) this.auditLogger.appendAuditLine({ sessionId, execution, decision: exhDecision, dryRun: this.dryRun });
|
|
80
140
|
|
|
81
141
|
if (this.dryRun) {
|
|
82
142
|
return { verdict: "allow", stage: "P0", dryRun: true, originalVerdict: "deny", reason: "retry-exhaustion", detail: exhHit.detail, hit: exhHit };
|
|
@@ -101,13 +161,14 @@ export class SecurityEngine {
|
|
|
101
161
|
cached: true,
|
|
102
162
|
};
|
|
103
163
|
}
|
|
164
|
+
if (this.counters) this.counters.record(cached, execution);
|
|
104
165
|
return cached;
|
|
105
166
|
}
|
|
106
167
|
}
|
|
107
168
|
|
|
108
|
-
|
|
169
|
+
let decision = this._evaluateWaterfall(execution, context, sessionId, toolName, command);
|
|
109
170
|
|
|
110
|
-
// Post-decision hooks: ledger, retryGuard, cache
|
|
171
|
+
// Post-decision hooks: ledger, retryGuard, cache
|
|
111
172
|
if (decision.verdict === "deny") {
|
|
112
173
|
if (this.retryGuard) this.retryGuard.recordVerdict(sessionId, execution, "deny");
|
|
113
174
|
if (this.ledger) this.ledger.record(sessionId, "deny", { reason: decision.reason, detail: decision.detail, tool: toolName });
|
|
@@ -120,8 +181,30 @@ export class SecurityEngine {
|
|
|
120
181
|
if (this.ledger) this.ledger.record(sessionId, "ask", { reason: decision.reason, detail: decision.detail, tool: toolName });
|
|
121
182
|
}
|
|
122
183
|
|
|
184
|
+
// Audit Logger (#56) with fail-closed audit verification (#53)
|
|
123
185
|
if (this.auditLogger) {
|
|
124
|
-
this.auditLogger.
|
|
186
|
+
const auditOk = this.auditLogger.appendAuditLine({ sessionId, execution, decision, dryRun: this.dryRun });
|
|
187
|
+
if (!auditOk && this.auditFailClosed && !this.dryRun) {
|
|
188
|
+
const auditDenyHit = {
|
|
189
|
+
deny: true,
|
|
190
|
+
reason: "fail-closed-audit",
|
|
191
|
+
detail: "Audit log write failed. Execution blocked by fail-closed audit policy (#53).",
|
|
192
|
+
snippet: command || toolName,
|
|
193
|
+
};
|
|
194
|
+
decision = {
|
|
195
|
+
verdict: "deny",
|
|
196
|
+
stage: "P0",
|
|
197
|
+
reason: "fail-closed-audit",
|
|
198
|
+
detail: auditDenyHit.detail,
|
|
199
|
+
hit: auditDenyHit,
|
|
200
|
+
auditFailed: true,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Record block counters (#48)
|
|
206
|
+
if (this.counters) {
|
|
207
|
+
this.counters.record(decision, execution);
|
|
125
208
|
}
|
|
126
209
|
|
|
127
210
|
// Shadow Mode (#128)
|
|
@@ -134,18 +217,21 @@ export class SecurityEngine {
|
|
|
134
217
|
reason: decision.reason,
|
|
135
218
|
detail: decision.detail,
|
|
136
219
|
hit: decision.hit,
|
|
220
|
+
lockedAsk: decision.lockedAsk,
|
|
137
221
|
};
|
|
138
222
|
}
|
|
139
223
|
|
|
140
224
|
return decision;
|
|
141
225
|
} catch (err) {
|
|
142
|
-
|
|
226
|
+
const errDecision = {
|
|
143
227
|
verdict: "ask",
|
|
144
228
|
stage: "P4",
|
|
145
229
|
reason: "inspect-error",
|
|
146
230
|
detail: String(err && err.message || err),
|
|
147
231
|
hit: { ask: true, reason: "inspect-error", detail: String(err && err.message || err) },
|
|
148
232
|
};
|
|
233
|
+
if (this.counters) this.counters.record(errDecision, execution);
|
|
234
|
+
return errDecision;
|
|
149
235
|
}
|
|
150
236
|
}
|
|
151
237
|
|
|
@@ -173,11 +259,13 @@ export class SecurityEngine {
|
|
|
173
259
|
}
|
|
174
260
|
if (decl.verdict === "allow") {
|
|
175
261
|
this.breaker.resetConsecutive(sessionId);
|
|
176
|
-
return { verdict: "allow", stage: "P1", reason: decl.reason || "declarative-allow", hit: null };
|
|
262
|
+
return { verdict: "allow", stage: "P1", reason: decl.reason || "declarative-allow", hit: null, ruleId: decl.ruleId };
|
|
177
263
|
}
|
|
178
264
|
if (decl.verdict === "ask") {
|
|
179
265
|
const hit = { ask: true, reason: decl.reason || "declarative-ask", detail: command, snippet: command, ruleId: decl.ruleId };
|
|
180
|
-
|
|
266
|
+
const res = { verdict: "ask", stage: "P4", reason: decl.reason || "declarative-ask", detail: command, hit, ruleId: decl.ruleId };
|
|
267
|
+
this._applyLockedAskIfCritical(res, toolName, command, execution);
|
|
268
|
+
return res;
|
|
181
269
|
}
|
|
182
270
|
}
|
|
183
271
|
}
|
|
@@ -198,7 +286,7 @@ export class SecurityEngine {
|
|
|
198
286
|
}
|
|
199
287
|
}
|
|
200
288
|
|
|
201
|
-
// If Circuit Breaker is tripped,
|
|
289
|
+
// If Circuit Breaker is tripped, force P4 Human Ask
|
|
202
290
|
if (this.breaker.isTripped(sessionId)) {
|
|
203
291
|
if (this.ledger) this.ledger.record(sessionId, "breaker", { reason: "circuit-breaker-tripped", tool: toolName });
|
|
204
292
|
const breakerHit = {
|
|
@@ -207,18 +295,22 @@ export class SecurityEngine {
|
|
|
207
295
|
detail: "Circuit breaker tripped due to repeated security denials. Manual confirmation required.",
|
|
208
296
|
snippet: command || toolName,
|
|
209
297
|
};
|
|
210
|
-
|
|
298
|
+
const res = {
|
|
211
299
|
verdict: "ask",
|
|
212
300
|
stage: "P4",
|
|
213
301
|
reason: "circuit-breaker-tripped",
|
|
214
302
|
detail: breakerHit.detail,
|
|
215
303
|
hit: breakerHit,
|
|
216
304
|
};
|
|
305
|
+
this._applyLockedAskIfCritical(res, toolName, command, execution);
|
|
306
|
+
return res;
|
|
217
307
|
}
|
|
218
308
|
|
|
219
309
|
// P4: Human Ask if syntax is unparseable or uncertain
|
|
220
310
|
if (hit.ask) {
|
|
221
|
-
|
|
311
|
+
const res = { verdict: "ask", stage: "P4", reason: hit.reason || "uncertain-syntax", detail: hit.detail || hit.snippet, hit };
|
|
312
|
+
this._applyLockedAskIfCritical(res, toolName, command, execution);
|
|
313
|
+
return res;
|
|
222
314
|
}
|
|
223
315
|
|
|
224
316
|
// P2: Static Rules / Safe Allow
|
|
@@ -243,18 +335,36 @@ export class SecurityEngine {
|
|
|
243
335
|
} catch (err) {
|
|
244
336
|
// Classifier error fails closed to P4 Ask (#111: does not trip breaker)
|
|
245
337
|
if (this.ledger) this.ledger.record(sessionId, "error", { reason: "classifier-error", detail: String(err && err.message || err), tool: toolName });
|
|
246
|
-
|
|
338
|
+
const res = { verdict: "ask", stage: "P4", reason: "classifier-error", detail: String(err && err.message || err), hit: null };
|
|
339
|
+
this._applyLockedAskIfCritical(res, toolName, command, execution);
|
|
340
|
+
return res;
|
|
247
341
|
}
|
|
248
342
|
}
|
|
249
343
|
|
|
250
344
|
// Default verdict for unclassified commands (#163)
|
|
251
345
|
if (this.defaultVerdict === "ask") {
|
|
252
346
|
const askHit = { ask: true, reason: "unmatched-ask", snippet: command || toolName };
|
|
253
|
-
|
|
347
|
+
const res = { verdict: "ask", stage: "P4", reason: "unmatched-ask", hit: askHit };
|
|
348
|
+
this._applyLockedAskIfCritical(res, toolName, command, execution);
|
|
349
|
+
return res;
|
|
254
350
|
}
|
|
255
351
|
return { verdict: "allow", stage: "P2", reason: "unmatched-allow", hit };
|
|
256
352
|
}
|
|
257
353
|
|
|
354
|
+
/**
|
|
355
|
+
* Helper to check and mark lockedAsk for critical operations (#52).
|
|
356
|
+
* @private
|
|
357
|
+
*/
|
|
358
|
+
_applyLockedAskIfCritical(decision, toolName, command, execution) {
|
|
359
|
+
if (decision && decision.verdict === "ask" && isCriticalOperation(toolName, command, execution)) {
|
|
360
|
+
decision.lockedAsk = true;
|
|
361
|
+
decision.lockedReason = "critical-operation";
|
|
362
|
+
if (decision.hit) {
|
|
363
|
+
decision.hit.lockedAsk = true;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
258
368
|
addGrant(options) {
|
|
259
369
|
if (this.grantRegistry && typeof this.grantRegistry.addGrant === "function") {
|
|
260
370
|
return this.grantRegistry.addGrant(options);
|
|
@@ -281,6 +391,14 @@ export class SecurityEngine {
|
|
|
281
391
|
if (this.cache) this.cache.clear();
|
|
282
392
|
}
|
|
283
393
|
|
|
394
|
+
getCounters() {
|
|
395
|
+
return this.counters ? this.counters.getSnapshot() : null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
resetCounters() {
|
|
399
|
+
if (this.counters) this.counters.reset();
|
|
400
|
+
}
|
|
401
|
+
|
|
284
402
|
resume(sessionId, reason) {
|
|
285
403
|
if (this.retryGuard) this.retryGuard.reset(sessionId);
|
|
286
404
|
if (this.cache) this.cache.clearSession(sessionId);
|