@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/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.auditLogger = options.auditLogger !== undefined ? options.auditLogger : new AuditLogger();
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.auditLogger) this.auditLogger.log({ sessionId, execution, decision: exhDecision, dryRun: this.dryRun });
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
- const decision = this._evaluateWaterfall(execution, context, sessionId, toolName, command);
169
+ let decision = this._evaluateWaterfall(execution, context, sessionId, toolName, command);
109
170
 
110
- // Post-decision hooks: ledger, retryGuard, cache, audit
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.log({ sessionId, execution, decision, dryRun: this.dryRun });
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
- return {
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
- return { verdict: "ask", stage: "P4", reason: decl.reason || "declarative-ask", detail: command, hit };
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, suspend auto-mode and force P4 Human Ask
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
- return {
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
- return { verdict: "ask", stage: "P4", reason: hit.reason || "uncertain-syntax", detail: hit.detail || hit.snippet, hit };
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
- return { verdict: "ask", stage: "P4", reason: "classifier-error", detail: String(err && err.message || err), hit: null };
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
- return { verdict: "ask", stage: "P4", reason: "unmatched-ask", hit: askHit };
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);