@goodandready/dsh-approval-gate 0.1.14 → 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 + inspectExecution hard denials + protected files + retry exhaustion)
5
- // P1: Session Grants (scoped temporary allowances)
4
+ // P0: Hard Deny (Bands Band 0 + Declarative Rules Deny + inspectExecution hard denials + protected files + retry exhaustion + fail-closed audit)
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)
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";
@@ -15,6 +15,57 @@ import { VerdictCache } from "./cache.js";
15
15
  import { RetryExhaustionGuard } from "./retry-exhaustion.js";
16
16
  import { QualityLedger } from "./ledger.js";
17
17
  import { AuditLogger } from "./audit-log.js";
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
+ }
18
69
 
19
70
  export class SecurityEngine {
20
71
  /**
@@ -25,6 +76,9 @@ export class SecurityEngine {
25
76
  * retryGuard?: RetryExhaustionGuard,
26
77
  * ledger?: QualityLedger,
27
78
  * auditLogger?: AuditLogger,
79
+ * declarativeRules?: DeclarativeRulesEngine,
80
+ * counters?: BlockCounters,
81
+ * auditFailClosed?: boolean,
28
82
  * inspectConfig?: { toolName?: string, fileWriteTools?: string[], workspaceDir?: string },
29
83
  * classifier?: ((execution: any, context?: any) => { verdict: "allow"|"deny", reason?: string }),
30
84
  * grantRegistry?: { decide: (tool: string, args: any, context?: any) => "allow"|"no-match" },
@@ -42,10 +96,20 @@ export class SecurityEngine {
42
96
  this.defaultVerdict = options.defaultVerdict || "allow";
43
97
 
44
98
  this.dryRun = Boolean(options.dryRun || options.shadowMode);
99
+ this.auditFailClosed = options.auditFailClosed === true;
45
100
  this.cache = options.cache !== undefined ? options.cache : new VerdictCache();
46
101
  this.retryGuard = options.retryGuard !== undefined ? options.retryGuard : new RetryExhaustionGuard();
47
102
  this.ledger = options.ledger !== undefined ? options.ledger : new QualityLedger();
48
- 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
+ }
110
+ this.declarativeRules = options.declarativeRules || new DeclarativeRulesEngine({
111
+ workspaceDir: this.inspectConfig?.workspaceDir,
112
+ });
49
113
  }
50
114
 
51
115
  /**
@@ -53,7 +117,7 @@ export class SecurityEngine {
53
117
  *
54
118
  * @param {{ name: string, arguments?: any }} execution - Tool call
55
119
  * @param {{ sessionId?: string, knobs?: any, cwd?: string }} [context] - Context options
56
- * @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 }}
57
121
  */
58
122
  decide(execution, context = {}) {
59
123
  try {
@@ -71,7 +135,8 @@ export class SecurityEngine {
71
135
  };
72
136
  const exhDecision = { verdict: "deny", stage: "P0", reason: "retry-exhaustion", detail: exhHit.detail, hit: exhHit };
73
137
  if (this.ledger) this.ledger.record(sessionId, "exhaustion", { reason: "retry-exhaustion", tool: toolName });
74
- 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 });
75
140
 
76
141
  if (this.dryRun) {
77
142
  return { verdict: "allow", stage: "P0", dryRun: true, originalVerdict: "deny", reason: "retry-exhaustion", detail: exhHit.detail, hit: exhHit };
@@ -96,13 +161,14 @@ export class SecurityEngine {
96
161
  cached: true,
97
162
  };
98
163
  }
164
+ if (this.counters) this.counters.record(cached, execution);
99
165
  return cached;
100
166
  }
101
167
  }
102
168
 
103
- const decision = this._evaluateWaterfall(execution, context, sessionId, toolName, command);
169
+ let decision = this._evaluateWaterfall(execution, context, sessionId, toolName, command);
104
170
 
105
- // Post-decision hooks: ledger, retryGuard, cache, audit
171
+ // Post-decision hooks: ledger, retryGuard, cache
106
172
  if (decision.verdict === "deny") {
107
173
  if (this.retryGuard) this.retryGuard.recordVerdict(sessionId, execution, "deny");
108
174
  if (this.ledger) this.ledger.record(sessionId, "deny", { reason: decision.reason, detail: decision.detail, tool: toolName });
@@ -115,8 +181,30 @@ export class SecurityEngine {
115
181
  if (this.ledger) this.ledger.record(sessionId, "ask", { reason: decision.reason, detail: decision.detail, tool: toolName });
116
182
  }
117
183
 
184
+ // Audit Logger (#56) with fail-closed audit verification (#53)
118
185
  if (this.auditLogger) {
119
- 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);
120
208
  }
121
209
 
122
210
  // Shadow Mode (#128)
@@ -129,18 +217,21 @@ export class SecurityEngine {
129
217
  reason: decision.reason,
130
218
  detail: decision.detail,
131
219
  hit: decision.hit,
220
+ lockedAsk: decision.lockedAsk,
132
221
  };
133
222
  }
134
223
 
135
224
  return decision;
136
225
  } catch (err) {
137
- return {
226
+ const errDecision = {
138
227
  verdict: "ask",
139
228
  stage: "P4",
140
229
  reason: "inspect-error",
141
230
  detail: String(err && err.message || err),
142
231
  hit: { ask: true, reason: "inspect-error", detail: String(err && err.message || err) },
143
232
  };
233
+ if (this.counters) this.counters.record(errDecision, execution);
234
+ return errDecision;
144
235
  }
145
236
  }
146
237
 
@@ -157,6 +248,28 @@ export class SecurityEngine {
157
248
  return { verdict: "deny", stage: "P0", reason: "band-hard-deny", detail: command, hit };
158
249
  }
159
250
 
251
+ // Declarative YAML Rules evaluation (#38, #45, #44)
252
+ if (this.declarativeRules && command) {
253
+ const decl = this.declarativeRules.evaluate(toolName, command, context);
254
+ if (decl.match) {
255
+ if (decl.verdict === "deny") {
256
+ this.breaker.countDeny(sessionId);
257
+ const hit = { deny: true, reason: decl.reason || "declarative-deny", detail: command, snippet: command, ruleId: decl.ruleId };
258
+ return { verdict: "deny", stage: "P0", reason: decl.reason || "declarative-deny", detail: command, hit };
259
+ }
260
+ if (decl.verdict === "allow") {
261
+ this.breaker.resetConsecutive(sessionId);
262
+ return { verdict: "allow", stage: "P1", reason: decl.reason || "declarative-allow", hit: null, ruleId: decl.ruleId };
263
+ }
264
+ if (decl.verdict === "ask") {
265
+ const hit = { ask: true, reason: decl.reason || "declarative-ask", detail: command, snippet: command, ruleId: decl.ruleId };
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;
269
+ }
270
+ }
271
+ }
272
+
160
273
  // Inspect execution for hard denials and syntax checks
161
274
  const hit = inspectExecution(execution, this.inspectConfig);
162
275
  if (hit.deny) {
@@ -173,7 +286,7 @@ export class SecurityEngine {
173
286
  }
174
287
  }
175
288
 
176
- // If Circuit Breaker is tripped, suspend auto-mode and force P4 Human Ask
289
+ // If Circuit Breaker is tripped, force P4 Human Ask
177
290
  if (this.breaker.isTripped(sessionId)) {
178
291
  if (this.ledger) this.ledger.record(sessionId, "breaker", { reason: "circuit-breaker-tripped", tool: toolName });
179
292
  const breakerHit = {
@@ -182,18 +295,22 @@ export class SecurityEngine {
182
295
  detail: "Circuit breaker tripped due to repeated security denials. Manual confirmation required.",
183
296
  snippet: command || toolName,
184
297
  };
185
- return {
298
+ const res = {
186
299
  verdict: "ask",
187
300
  stage: "P4",
188
301
  reason: "circuit-breaker-tripped",
189
302
  detail: breakerHit.detail,
190
303
  hit: breakerHit,
191
304
  };
305
+ this._applyLockedAskIfCritical(res, toolName, command, execution);
306
+ return res;
192
307
  }
193
308
 
194
309
  // P4: Human Ask if syntax is unparseable or uncertain
195
310
  if (hit.ask) {
196
- 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;
197
314
  }
198
315
 
199
316
  // P2: Static Rules / Safe Allow
@@ -218,18 +335,36 @@ export class SecurityEngine {
218
335
  } catch (err) {
219
336
  // Classifier error fails closed to P4 Ask (#111: does not trip breaker)
220
337
  if (this.ledger) this.ledger.record(sessionId, "error", { reason: "classifier-error", detail: String(err && err.message || err), tool: toolName });
221
- 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;
222
341
  }
223
342
  }
224
343
 
225
344
  // Default verdict for unclassified commands (#163)
226
345
  if (this.defaultVerdict === "ask") {
227
346
  const askHit = { ask: true, reason: "unmatched-ask", snippet: command || toolName };
228
- 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;
229
350
  }
230
351
  return { verdict: "allow", stage: "P2", reason: "unmatched-allow", hit };
231
352
  }
232
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
+
233
368
  addGrant(options) {
234
369
  if (this.grantRegistry && typeof this.grantRegistry.addGrant === "function") {
235
370
  return this.grantRegistry.addGrant(options);
@@ -256,6 +391,14 @@ export class SecurityEngine {
256
391
  if (this.cache) this.cache.clear();
257
392
  }
258
393
 
394
+ getCounters() {
395
+ return this.counters ? this.counters.getSnapshot() : null;
396
+ }
397
+
398
+ resetCounters() {
399
+ if (this.counters) this.counters.reset();
400
+ }
401
+
259
402
  resume(sessionId, reason) {
260
403
  if (this.retryGuard) this.retryGuard.reset(sessionId);
261
404
  if (this.cache) this.cache.clearSession(sessionId);
@@ -0,0 +1,122 @@
1
+ // lib/hotwords.js
2
+ // High-speed keyword pre-filter (Hotwords / Lexicon Matcher) for dsh-approval-gate (#93).
3
+ // Provides sub-microsecond pre-check to bypass heavy AST/regex parsing for clean commands.
4
+
5
+ export const DEFAULT_HOTWORDS = Object.freeze([
6
+ "rm",
7
+ "mkfs",
8
+ "fdisk",
9
+ "parted",
10
+ "dd",
11
+ "shadow",
12
+ "sudoers",
13
+ "passwd",
14
+ "kill",
15
+ "pkill",
16
+ "killall",
17
+ "systemctl",
18
+ "service",
19
+ "curl",
20
+ "wget",
21
+ "bash",
22
+ "sh",
23
+ "zsh",
24
+ "nc",
25
+ "netcat",
26
+ "ncat",
27
+ "socat",
28
+ "chmod",
29
+ "chown",
30
+ "eval",
31
+ "printenv",
32
+ "drop",
33
+ "truncate",
34
+ "format",
35
+ "sudo",
36
+ "su",
37
+ "ssh",
38
+ ]);
39
+
40
+ /**
41
+ * Splits text into normalized lowercase word tokens.
42
+ *
43
+ * @param {string} text
44
+ * @returns {string[]}
45
+ */
46
+ export function extractWords(text) {
47
+ if (typeof text !== "string") return [];
48
+ // Split on whitespace and common shell operators/delimiters
49
+ const tokens = text.toLowerCase().split(/[^a-z0-9_-]+/);
50
+ return tokens.filter((t) => t.length > 0);
51
+ }
52
+
53
+ export class HotwordsMatcher {
54
+ /**
55
+ * @param {string[]} [customHotwords]
56
+ */
57
+ constructor(customHotwords) {
58
+ /** @type {Set<string>} */
59
+ this.lexicon = new Set();
60
+ const words = Array.isArray(customHotwords) && customHotwords.length > 0
61
+ ? customHotwords
62
+ : DEFAULT_HOTWORDS;
63
+
64
+ for (const w of words) {
65
+ if (typeof w === "string" && w.trim()) {
66
+ this.lexicon.add(w.trim().toLowerCase());
67
+ }
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Add new words to lexicon.
73
+ * @param {string | string[]} words
74
+ */
75
+ add(words) {
76
+ const list = Array.isArray(words) ? words : [words];
77
+ for (const w of list) {
78
+ if (typeof w === "string" && w.trim()) {
79
+ this.lexicon.add(w.trim().toLowerCase());
80
+ }
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Checks if text contains any word present in the hotwords lexicon.
86
+ *
87
+ * @param {string} text
88
+ * @returns {boolean} True if any high-risk keyword matched
89
+ */
90
+ hasMatch(text) {
91
+ if (!text || typeof text !== "string") return false;
92
+ const words = extractWords(text);
93
+ for (let i = 0; i < words.length; i += 1) {
94
+ if (this.lexicon.has(words[i])) {
95
+ return true;
96
+ }
97
+ }
98
+ return false;
99
+ }
100
+
101
+ /**
102
+ * Returns list of matching hotwords found in text.
103
+ *
104
+ * @param {string} text
105
+ * @returns {string[]}
106
+ */
107
+ findMatches(text) {
108
+ if (!text || typeof text !== "string") return [];
109
+ const words = extractWords(text);
110
+ const matches = [];
111
+ for (let i = 0; i < words.length; i += 1) {
112
+ if (this.lexicon.has(words[i]) && !matches.includes(words[i])) {
113
+ matches.push(words[i]);
114
+ }
115
+ }
116
+ return matches;
117
+ }
118
+
119
+ get size() {
120
+ return this.lexicon.size;
121
+ }
122
+ }