@goodandready/dsh-approval-gate 0.1.16 → 0.1.18

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,407 +1,512 @@
1
- // lib/engine.js
2
- // 5-tier Security Decision Engine for dsh-approval-gate.
3
- // Waterfall:
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
- // P2: Static Rules / Safe Allow (Bands Band 1 + safe read-only commands)
7
- // P3: Optional Semantic Classifier
8
- // P4: Human Ask Fallback (uncertain syntax / ambiguous commands / tripped breaker / declarative ask / lockedAsk)
9
-
10
- import { BandsEngine } from "./bands.js";
11
- import { CircuitBreaker } from "./breaker.js";
12
- import { SessionGrantStore } from "./grants.js";
13
- import { inspectExecution } from "./inspect.js";
14
- import { VerdictCache } from "./cache.js";
15
- import { RetryExhaustionGuard } from "./retry-exhaustion.js";
16
- import { QualityLedger } from "./ledger.js";
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
- }
69
-
70
- export class SecurityEngine {
71
- /**
72
- * @param {{
73
- * bands?: BandsEngine,
74
- * breaker?: CircuitBreaker,
75
- * cache?: VerdictCache,
76
- * retryGuard?: RetryExhaustionGuard,
77
- * ledger?: QualityLedger,
78
- * auditLogger?: AuditLogger,
79
- * declarativeRules?: DeclarativeRulesEngine,
80
- * counters?: BlockCounters,
81
- * auditFailClosed?: boolean,
82
- * inspectConfig?: { toolName?: string, fileWriteTools?: string[], workspaceDir?: string },
83
- * classifier?: ((execution: any, context?: any) => { verdict: "allow"|"deny", reason?: string }),
84
- * grantRegistry?: { decide: (tool: string, args: any, context?: any) => "allow"|"no-match" },
85
- * dryRun?: boolean,
86
- * shadowMode?: boolean,
87
- * defaultVerdict?: "allow" | "ask"
88
- * }} [options]
89
- */
90
- constructor(options = {}) {
91
- this.bands = options.bands || new BandsEngine(options);
92
- this.breaker = options.breaker || new CircuitBreaker(options);
93
- this.inspectConfig = options.inspectConfig || {};
94
- this.classifier = options.classifier || null;
95
- this.grantRegistry = options.grantRegistry || new SessionGrantStore(options);
96
- this.defaultVerdict = options.defaultVerdict || "allow";
97
-
98
- this.dryRun = Boolean(options.dryRun || options.shadowMode);
99
- this.auditFailClosed = options.auditFailClosed === true;
100
- this.cache = options.cache !== undefined ? options.cache : new VerdictCache();
101
- this.retryGuard = options.retryGuard !== undefined ? options.retryGuard : new RetryExhaustionGuard();
102
- this.ledger = options.ledger !== undefined ? options.ledger : new QualityLedger();
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
- });
113
- }
114
-
115
- /**
116
- * Evaluates an execution request through the P0-P4 waterfall.
117
- *
118
- * @param {{ name: string, arguments?: any }} execution - Tool call
119
- * @param {{ sessionId?: string, knobs?: any, cwd?: string }} [context] - Context options
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 }}
121
- */
122
- decide(execution, context = {}) {
123
- try {
124
- const sessionId = context.sessionId || "default";
125
- const toolName = execution && execution.name;
126
- const command = execution && execution.arguments && execution.arguments.command;
127
-
128
- // Check Retry Exhaustion guard (#96)
129
- if (this.retryGuard && this.retryGuard.isExhausted(sessionId)) {
130
- const exhHit = {
131
- deny: true,
132
- reason: "retry-exhaustion",
133
- detail: "Retry exhaustion: consecutive blocked attempts exceeded threshold",
134
- snippet: command || toolName,
135
- };
136
- const exhDecision = { verdict: "deny", stage: "P0", reason: "retry-exhaustion", detail: exhHit.detail, hit: exhHit };
137
- if (this.ledger) this.ledger.record(sessionId, "exhaustion", { reason: "retry-exhaustion", tool: toolName });
138
- if (this.counters) this.counters.record(exhDecision, execution);
139
- if (this.auditLogger) this.auditLogger.appendAuditLine({ sessionId, execution, decision: exhDecision, dryRun: this.dryRun });
140
-
141
- if (this.dryRun) {
142
- return { verdict: "allow", stage: "P0", dryRun: true, originalVerdict: "deny", reason: "retry-exhaustion", detail: exhHit.detail, hit: exhHit };
143
- }
144
- return exhDecision;
145
- }
146
-
147
- // Check Verdict Cache (#63, #119)
148
- const cacheKey = this.cache ? this.cache.makeKey(execution, context) : null;
149
- if (this.cache && cacheKey) {
150
- const cached = this.cache.get(cacheKey);
151
- if (cached) {
152
- if (this.dryRun && (cached.verdict === "deny" || cached.verdict === "ask")) {
153
- return {
154
- verdict: "allow",
155
- stage: cached.stage,
156
- dryRun: true,
157
- originalVerdict: cached.verdict,
158
- reason: cached.reason,
159
- detail: cached.detail,
160
- hit: cached.hit,
161
- cached: true,
162
- };
163
- }
164
- if (this.counters) this.counters.record(cached, execution);
165
- return cached;
166
- }
167
- }
168
-
169
- let decision = this._evaluateWaterfall(execution, context, sessionId, toolName, command);
170
-
171
- // Post-decision hooks: ledger, retryGuard, cache
172
- if (decision.verdict === "deny") {
173
- if (this.retryGuard) this.retryGuard.recordVerdict(sessionId, execution, "deny");
174
- if (this.ledger) this.ledger.record(sessionId, "deny", { reason: decision.reason, detail: decision.detail, tool: toolName });
175
- if (this.cache && cacheKey) this.cache.set(cacheKey, decision, { sessionId });
176
- } else if (decision.verdict === "allow") {
177
- if (this.retryGuard) this.retryGuard.recordVerdict(sessionId, execution, "allow");
178
- if (this.ledger) this.ledger.record(sessionId, "allow", { reason: decision.reason, detail: decision.detail, tool: toolName });
179
- if (this.cache && cacheKey) this.cache.set(cacheKey, decision, { sessionId });
180
- } else if (decision.verdict === "ask") {
181
- if (this.ledger) this.ledger.record(sessionId, "ask", { reason: decision.reason, detail: decision.detail, tool: toolName });
182
- }
183
-
184
- // Audit Logger (#56) with fail-closed audit verification (#53)
185
- if (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);
208
- }
209
-
210
- // Shadow Mode (#128)
211
- if (this.dryRun && (decision.verdict === "deny" || decision.verdict === "ask")) {
212
- return {
213
- verdict: "allow",
214
- stage: decision.stage,
215
- dryRun: true,
216
- originalVerdict: decision.verdict,
217
- reason: decision.reason,
218
- detail: decision.detail,
219
- hit: decision.hit,
220
- lockedAsk: decision.lockedAsk,
221
- };
222
- }
223
-
224
- return decision;
225
- } catch (err) {
226
- const errDecision = {
227
- verdict: "ask",
228
- stage: "P4",
229
- reason: "inspect-error",
230
- detail: String(err && err.message || err),
231
- hit: { ask: true, reason: "inspect-error", detail: String(err && err.message || err) },
232
- };
233
- if (this.counters) this.counters.record(errDecision, execution);
234
- return errDecision;
235
- }
236
- }
237
-
238
- /**
239
- * Internal evaluation of P0-P4 waterfall.
240
- * @private
241
- */
242
- _evaluateWaterfall(execution, context, sessionId, toolName, command) {
243
- // Fast-path Band 0 Hard Deny
244
- const bandVerdict = this.bands.evaluate(toolName, command);
245
- if (bandVerdict === "deny") {
246
- this.breaker.countDeny(sessionId);
247
- const hit = { deny: true, reason: "band-hard-deny", detail: command, snippet: command };
248
- return { verdict: "deny", stage: "P0", reason: "band-hard-deny", detail: command, hit };
249
- }
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
-
273
- // Inspect execution for hard denials and syntax checks
274
- const hit = inspectExecution(execution, this.inspectConfig);
275
- if (hit.deny) {
276
- this.breaker.countDeny(sessionId);
277
- return { verdict: "deny", stage: "P0", reason: hit.reason || "hard-deny", detail: hit.detail || hit.snippet, hit };
278
- }
279
-
280
- // P1: Session Grants
281
- if (this.grantRegistry && typeof this.grantRegistry.decide === "function") {
282
- const grantMatch = this.grantRegistry.decide(toolName, execution.arguments, context);
283
- if (grantMatch === "allow") {
284
- this.breaker.resetConsecutive(sessionId);
285
- return { verdict: "allow", stage: "P1", reason: "session-grant", hit };
286
- }
287
- }
288
-
289
- // If Circuit Breaker is tripped, force P4 Human Ask
290
- if (this.breaker.isTripped(sessionId)) {
291
- if (this.ledger) this.ledger.record(sessionId, "breaker", { reason: "circuit-breaker-tripped", tool: toolName });
292
- const breakerHit = {
293
- ask: true,
294
- reason: "circuit-breaker-tripped",
295
- detail: "Circuit breaker tripped due to repeated security denials. Manual confirmation required.",
296
- snippet: command || toolName,
297
- };
298
- const res = {
299
- verdict: "ask",
300
- stage: "P4",
301
- reason: "circuit-breaker-tripped",
302
- detail: breakerHit.detail,
303
- hit: breakerHit,
304
- };
305
- this._applyLockedAskIfCritical(res, toolName, command, execution);
306
- return res;
307
- }
308
-
309
- // P4: Human Ask if syntax is unparseable or uncertain
310
- if (hit.ask) {
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;
314
- }
315
-
316
- // P2: Static Rules / Safe Allow
317
- if (bandVerdict === "allow") {
318
- this.breaker.resetConsecutive(sessionId);
319
- return { verdict: "allow", stage: "P2", reason: "safe-allow", hit };
320
- }
321
-
322
- // P3: Optional Semantic Classifier
323
- if (this.classifier && typeof this.classifier === "function") {
324
- try {
325
- const cls = this.classifier(execution, context);
326
- if (cls && cls.verdict === "allow") {
327
- this.breaker.resetConsecutive(sessionId);
328
- return { verdict: "allow", stage: "P3", reason: cls.reason || "classifier-allow", hit };
329
- }
330
- if (cls && cls.verdict === "deny") {
331
- this.breaker.countDeny(sessionId);
332
- const clsHit = { deny: true, reason: cls.reason || "classifier-deny", snippet: command || toolName };
333
- return { verdict: "deny", stage: "P3", reason: cls.reason || "classifier-deny", detail: cls.reason, hit: clsHit };
334
- }
335
- } catch (err) {
336
- // Classifier error fails closed to P4 Ask (#111: does not trip breaker)
337
- if (this.ledger) this.ledger.record(sessionId, "error", { reason: "classifier-error", detail: String(err && err.message || err), tool: toolName });
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;
341
- }
342
- }
343
-
344
- // Default verdict for unclassified commands (#163)
345
- if (this.defaultVerdict === "ask") {
346
- const askHit = { ask: true, reason: "unmatched-ask", snippet: command || toolName };
347
- const res = { verdict: "ask", stage: "P4", reason: "unmatched-ask", hit: askHit };
348
- this._applyLockedAskIfCritical(res, toolName, command, execution);
349
- return res;
350
- }
351
- return { verdict: "allow", stage: "P2", reason: "unmatched-allow", hit };
352
- }
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
-
368
- addGrant(options) {
369
- if (this.grantRegistry && typeof this.grantRegistry.addGrant === "function") {
370
- return this.grantRegistry.addGrant(options);
371
- }
372
- return null;
373
- }
374
-
375
- getGrants(sessionId) {
376
- if (this.grantRegistry && typeof this.grantRegistry.getGrants === "function") {
377
- return this.grantRegistry.getGrants(sessionId);
378
- }
379
- return [];
380
- }
381
-
382
- getLedgerSummary(sessionId) {
383
- return this.ledger ? this.ledger.getSummary(sessionId) : null;
384
- }
385
-
386
- isRetryExhausted(sessionId) {
387
- return this.retryGuard ? this.retryGuard.isExhausted(sessionId) : false;
388
- }
389
-
390
- clearCache() {
391
- if (this.cache) this.cache.clear();
392
- }
393
-
394
- getCounters() {
395
- return this.counters ? this.counters.getSnapshot() : null;
396
- }
397
-
398
- resetCounters() {
399
- if (this.counters) this.counters.reset();
400
- }
401
-
402
- resume(sessionId, reason) {
403
- if (this.retryGuard) this.retryGuard.reset(sessionId);
404
- if (this.cache) this.cache.clearSession(sessionId);
405
- return this.breaker.resume(sessionId, reason);
406
- }
407
- }
1
+ // lib/engine.js
2
+ // 5-tier Security Decision Engine for dsh-approval-gate.
3
+ // Waterfall:
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
+ // P2: Static Rules / Safe Allow (Bands Band 1 + safe read-only commands)
7
+ // P3: Optional Semantic Classifier
8
+ // P4: Human Ask Fallback (uncertain syntax / ambiguous commands / tripped breaker / declarative ask / lockedAsk)
9
+
10
+ import { BandsEngine } from "./bands.js";
11
+ import { CircuitBreaker } from "./breaker.js";
12
+ import { SessionGrantStore } from "./grants.js";
13
+ import { inspectExecution } from "./inspect.js";
14
+ import { VerdictCache } from "./cache.js";
15
+ import { RetryExhaustionGuard } from "./retry-exhaustion.js";
16
+ import { QualityLedger } from "./ledger.js";
17
+ import { AuditLogger } from "./audit-log.js";
18
+ import { DeclarativeRulesEngine } from "./rules-loader.js";
19
+ import { BlockCounters } from "./counters.js";
20
+ import { ContractGuard } from "./contract-guard.js";
21
+ import { OverengineeringGuard } from "./overengineering.js";
22
+ import { extractFilePath, DEFAULT_FILE_WRITE_TOOLS } from "./dispatcher.js";
23
+
24
+ /**
25
+ * Determine whether an operation belongs to a critical risk category requiring lockedAsk (#52).
26
+ * Locked ask operations can never be auto-approved by unattended timeouts or fallback heuristics.
27
+ *
28
+ * @param {string} [toolName]
29
+ * @param {string} [command]
30
+ * @param {any} [execution]
31
+ * @returns {boolean}
32
+ */
33
+ export function isCriticalOperation(toolName, command, execution) {
34
+ const cmd = String(command || "").trim();
35
+ const tool = String(toolName || "").trim();
36
+ if (!cmd && !tool) return false;
37
+
38
+ // 1. Root and privilege escalation
39
+ if (/^(sudo|su|doas)\b/i.test(cmd)) return true;
40
+ if (/\b(chmod\s+(-[a-zA-Z]*\s+)?777|chown\s+(-[a-zA-Z]*\s+)?root)\b/i.test(cmd)) return true;
41
+
42
+ // 2. Recursive or bulk destructive deletion
43
+ if (/\brm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s+|--recursive\s+)/i.test(cmd)) return true;
44
+ if (/\b(shred|wipefs)\b/i.test(cmd)) return true;
45
+
46
+ // 3. Sensitive credentials and SSH access
47
+ if (/(\.ssh\/(id_|authorized_keys|known_hosts)|\.gnupg\/|\/etc\/shadow)/i.test(cmd)) return true;
48
+
49
+ // 4. Raw disk and filesystem manipulation
50
+ if (/\b(mkfs|fdisk|parted)\b/i.test(cmd)) return true;
51
+ if (/\bdd\s+.*of=\/dev\/(sd[a-z]|nvme|vd[a-z]|hd[a-z]|loop)/i.test(cmd)) return true;
52
+
53
+ // 5. Remote shell piping and reverse shells
54
+ if (/curl\s+.*\|\s*(bash|sh|python|perl)/i.test(cmd)) return true;
55
+ if (/\b(nc|ncat|netcat)\s+.*-e\s+/i.test(cmd)) return true;
56
+ if (/>&\s*\/dev\/tcp\//i.test(cmd)) return true;
57
+
58
+ // 6. System halts and service shutdowns
59
+ if (/\b(shutdown|reboot|poweroff|halt|init\s+0)\b/i.test(cmd)) return true;
60
+ if (/systemctl\s+(stop|disable|mask)\s+(sshd|systemd|ufw|iptables)/i.test(cmd)) return true;
61
+
62
+ // 7. Protected file writes via tools
63
+ if (tool === "write_to_file" || tool === "edit" || tool === "replace_file_content" || tool === "str_replace_editor") {
64
+ const target = execution && execution.arguments && (execution.arguments.TargetFile || execution.arguments.target_file || execution.arguments.path || "");
65
+ if (typeof target === "string" && /(\/etc\/|\.ssh\/|\/boot\/|\/sys\/|\/proc\/)/i.test(target)) {
66
+ return true;
67
+ }
68
+ }
69
+
70
+ return false;
71
+ }
72
+
73
+ export class SecurityEngine {
74
+ /**
75
+ * @param {{
76
+ * bands?: BandsEngine,
77
+ * breaker?: CircuitBreaker,
78
+ * cache?: VerdictCache,
79
+ * retryGuard?: RetryExhaustionGuard,
80
+ * ledger?: QualityLedger,
81
+ * auditLogger?: AuditLogger,
82
+ * declarativeRules?: DeclarativeRulesEngine,
83
+ * counters?: BlockCounters,
84
+ * auditFailClosed?: boolean,
85
+ * timeoutPolicy?: "strict" | "permissive",
86
+ * inspectConfig?: { toolName?: string, fileWriteTools?: string[], workspaceDir?: string },
87
+ * classifier?: ((execution: any, context?: any) => { verdict: "allow"|"deny", reason?: string }),
88
+ * grantRegistry?: { decide: (tool: string, args: any, context?: any) => "allow"|"no-match" },
89
+ * dryRun?: boolean,
90
+ * shadowMode?: boolean,
91
+ * defaultVerdict?: "allow" | "ask"
92
+ * }} [options]
93
+ */
94
+ constructor(options = {}) {
95
+ this.bands = options.bands || new BandsEngine(options);
96
+ this.breaker = options.breaker || new CircuitBreaker(options);
97
+ this.inspectConfig = options.inspectConfig || {};
98
+ this.classifier = options.classifier || null;
99
+ this.timeoutPolicy = options.timeoutPolicy === "permissive" ? "permissive" : "strict";
100
+ this.grantRegistry = options.grantRegistry || new SessionGrantStore(options);
101
+ this.defaultVerdict = options.defaultVerdict || "allow";
102
+
103
+ this.dryRun = Boolean(options.dryRun || options.shadowMode);
104
+ this.auditFailClosed = options.auditFailClosed === true;
105
+ this.cache = options.cache !== undefined ? options.cache : new VerdictCache();
106
+ this.retryGuard = options.retryGuard !== undefined ? options.retryGuard : new RetryExhaustionGuard();
107
+ this.ledger = options.ledger !== undefined ? options.ledger : new QualityLedger();
108
+ this.counters = options.counters || new BlockCounters();
109
+ this.auditLogger = options.auditLogger !== undefined ? options.auditLogger : new AuditLogger({
110
+ failClosed: this.auditFailClosed,
111
+ });
112
+ if (this.auditFailClosed && this.auditLogger) {
113
+ this.auditLogger.failClosed = true;
114
+ }
115
+ this.declarativeRules = options.declarativeRules || new DeclarativeRulesEngine({
116
+ workspaceDir: this.inspectConfig?.workspaceDir,
117
+ });
118
+ this.contractGuard = options.contractGuard !== undefined
119
+ ? options.contractGuard
120
+ : (options.taskContract ? new ContractGuard(options.taskContract) : null);
121
+ this.overengineeringGuard = options.overengineeringGuard !== undefined
122
+ ? options.overengineeringGuard
123
+ : (options.maxFilesPerSession !== undefined || options.maxCreatedFilesPerSession !== undefined
124
+ ? new OverengineeringGuard(options)
125
+ : null);
126
+ }
127
+
128
+ /**
129
+ * Evaluates an execution request through the P0-P4 waterfall.
130
+ *
131
+ * @param {{ name: string, arguments?: any }} execution - Tool call
132
+ * @param {{ sessionId?: string, knobs?: any, cwd?: string }} [context] - Context options
133
+ * @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 }}
134
+ */
135
+ decide(execution, context = {}) {
136
+ try {
137
+ const sessionId = context.sessionId || "default";
138
+ const toolName = execution && execution.name;
139
+ const command = execution && execution.arguments && execution.arguments.command;
140
+
141
+ // Check Retry Exhaustion guard (#96)
142
+ if (this.retryGuard && this.retryGuard.isExhausted(sessionId)) {
143
+ const exhHit = {
144
+ deny: true,
145
+ reason: "retry-exhaustion",
146
+ detail: "Retry exhaustion: consecutive blocked attempts exceeded threshold",
147
+ snippet: command || toolName,
148
+ };
149
+ const exhDecision = { verdict: "deny", stage: "P0", reason: "retry-exhaustion", detail: exhHit.detail, hit: exhHit };
150
+ if (this.ledger) this.ledger.record(sessionId, "exhaustion", { reason: "retry-exhaustion", tool: toolName });
151
+ if (this.counters) this.counters.record(exhDecision, execution);
152
+ if (this.auditLogger) this.auditLogger.appendAuditLine({ sessionId, execution, decision: exhDecision, dryRun: this.dryRun });
153
+
154
+ if (this.dryRun) {
155
+ return { verdict: "allow", stage: "P0", dryRun: true, originalVerdict: "deny", reason: "retry-exhaustion", detail: exhHit.detail, hit: exhHit };
156
+ }
157
+ return exhDecision;
158
+ }
159
+
160
+ // Check Verdict Cache (#63, #119)
161
+ const cacheKey = this.cache ? this.cache.makeKey(execution, context) : null;
162
+ if (this.cache && cacheKey) {
163
+ const cached = this.cache.get(cacheKey);
164
+ if (cached) {
165
+ if (this.dryRun && (cached.verdict === "deny" || cached.verdict === "ask")) {
166
+ return {
167
+ verdict: "allow",
168
+ stage: cached.stage,
169
+ dryRun: true,
170
+ originalVerdict: cached.verdict,
171
+ reason: cached.reason,
172
+ detail: cached.detail,
173
+ hit: cached.hit,
174
+ cached: true,
175
+ };
176
+ }
177
+ if (this.counters) this.counters.record(cached, execution);
178
+ return cached;
179
+ }
180
+ }
181
+
182
+ let decision = this._evaluateWaterfall(execution, context, sessionId, toolName, command);
183
+
184
+ // Post-decision hooks: ledger, retryGuard, cache
185
+ if (decision.verdict === "deny") {
186
+ if (this.retryGuard) this.retryGuard.recordVerdict(sessionId, execution, "deny");
187
+ if (this.ledger) this.ledger.record(sessionId, "deny", { reason: decision.reason, detail: decision.detail, tool: toolName });
188
+ if (this.cache && cacheKey) this.cache.set(cacheKey, decision, { sessionId });
189
+ } else if (decision.verdict === "allow") {
190
+ if (this.retryGuard) this.retryGuard.recordVerdict(sessionId, execution, "allow");
191
+ if (this.ledger) this.ledger.record(sessionId, "allow", { reason: decision.reason, detail: decision.detail, tool: toolName });
192
+ if (this.cache && cacheKey) this.cache.set(cacheKey, decision, { sessionId });
193
+ } else if (decision.verdict === "ask") {
194
+ if (this.ledger) this.ledger.record(sessionId, "ask", { reason: decision.reason, detail: decision.detail, tool: toolName });
195
+ }
196
+
197
+ // Audit Logger (#56) with fail-closed audit verification (#53)
198
+ if (this.auditLogger) {
199
+ const auditOk = this.auditLogger.appendAuditLine({ sessionId, execution, decision, dryRun: this.dryRun });
200
+ if (!auditOk && this.auditFailClosed && !this.dryRun) {
201
+ const auditDenyHit = {
202
+ deny: true,
203
+ reason: "fail-closed-audit",
204
+ detail: "Audit log write failed. Execution blocked by fail-closed audit policy (#53).",
205
+ snippet: command || toolName,
206
+ };
207
+ decision = {
208
+ verdict: "deny",
209
+ stage: "P0",
210
+ reason: "fail-closed-audit",
211
+ detail: auditDenyHit.detail,
212
+ hit: auditDenyHit,
213
+ auditFailed: true,
214
+ };
215
+ }
216
+ }
217
+
218
+ // Record block counters (#48)
219
+ if (this.counters) {
220
+ this.counters.record(decision, execution);
221
+ }
222
+
223
+ // Shadow Mode (#128)
224
+ if (this.dryRun && (decision.verdict === "deny" || decision.verdict === "ask")) {
225
+ return {
226
+ verdict: "allow",
227
+ stage: decision.stage,
228
+ dryRun: true,
229
+ originalVerdict: decision.verdict,
230
+ reason: decision.reason,
231
+ detail: decision.detail,
232
+ hit: decision.hit,
233
+ lockedAsk: decision.lockedAsk,
234
+ };
235
+ }
236
+
237
+ return decision;
238
+ } catch (err) {
239
+ const errDecision = {
240
+ verdict: "ask",
241
+ stage: "P4",
242
+ reason: "inspect-error",
243
+ detail: String(err && err.message || err),
244
+ hit: { ask: true, reason: "inspect-error", detail: String(err && err.message || err) },
245
+ };
246
+ if (this.counters) this.counters.record(errDecision, execution);
247
+ return errDecision;
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Asynchronous evaluation supporting async classifiers (#129, #118).
253
+ *
254
+ * @param {{ name: string, arguments?: any }} execution
255
+ * @param {{ sessionId?: string, knobs?: any, cwd?: string }} [context]
256
+ * @returns {Promise<any>}
257
+ */
258
+ async decideAsync(execution, context = {}) {
259
+ if (this.classifier) {
260
+ let resolvedVerdict = null;
261
+ let caughtError = null;
262
+ try {
263
+ if (typeof this.classifier.classify === "function") {
264
+ resolvedVerdict = await this.classifier.classify(execution, context);
265
+ } else if (typeof this.classifier === "function") {
266
+ const res = this.classifier(execution, context);
267
+ resolvedVerdict = res && typeof res.then === "function" ? await res : res;
268
+ }
269
+ } catch (err) {
270
+ caughtError = err;
271
+ }
272
+
273
+ const originalClassifier = this.classifier;
274
+ if (caughtError) {
275
+ this.classifier = () => { throw caughtError; };
276
+ } else {
277
+ this.classifier = () => resolvedVerdict;
278
+ }
279
+ try {
280
+ return this.decide(execution, context);
281
+ } finally {
282
+ this.classifier = originalClassifier;
283
+ }
284
+ }
285
+ return this.decide(execution, context);
286
+ }
287
+
288
+ /**
289
+ * Internal evaluation of P0-P4 waterfall.
290
+ * @private
291
+ */
292
+ _evaluateWaterfall(execution, context, sessionId, toolName, command) {
293
+ // Fast-path Band 0 Hard Deny
294
+ const bandVerdict = this.bands.evaluate(toolName, command);
295
+ if (bandVerdict === "deny") {
296
+ this.breaker.countDeny(sessionId);
297
+ const hit = { deny: true, reason: "band-hard-deny", detail: command, snippet: command };
298
+ return { verdict: "deny", stage: "P0", reason: "band-hard-deny", detail: command, hit };
299
+ }
300
+
301
+ // Declarative YAML Rules evaluation (#38, #45, #44)
302
+ if (this.declarativeRules && command) {
303
+ const decl = this.declarativeRules.evaluate(toolName, command, context);
304
+ if (decl.match) {
305
+ if (decl.verdict === "deny") {
306
+ this.breaker.countDeny(sessionId);
307
+ const hit = { deny: true, reason: decl.reason || "declarative-deny", detail: command, snippet: command, ruleId: decl.ruleId };
308
+ return { verdict: "deny", stage: "P0", reason: decl.reason || "declarative-deny", detail: command, hit };
309
+ }
310
+ if (decl.verdict === "allow") {
311
+ this.breaker.resetConsecutive(sessionId);
312
+ return { verdict: "allow", stage: "P1", reason: decl.reason || "declarative-allow", hit: null, ruleId: decl.ruleId };
313
+ }
314
+ if (decl.verdict === "ask") {
315
+ const hit = { ask: true, reason: decl.reason || "declarative-ask", detail: command, snippet: command, ruleId: decl.ruleId };
316
+ const res = { verdict: "ask", stage: "P4", reason: decl.reason || "declarative-ask", detail: command, hit, ruleId: decl.ruleId };
317
+ this._applyLockedAskIfCritical(res, toolName, command, execution);
318
+ return res;
319
+ }
320
+ }
321
+ }
322
+
323
+ // Inspect execution for hard denials and syntax checks
324
+ const hit = inspectExecution(execution, this.inspectConfig);
325
+ if (hit.deny) {
326
+ this.breaker.countDeny(sessionId);
327
+ return { verdict: "deny", stage: "P0", reason: hit.reason || "hard-deny", detail: hit.detail || hit.snippet, hit };
328
+ }
329
+
330
+ // Task Contract Guard (#87)
331
+ if (this.contractGuard) {
332
+ const cg = this.contractGuard.check(toolName, command, execution);
333
+ if (!cg.allowed) {
334
+ const cgHit = {
335
+ ask: true,
336
+ reason: cg.reason || "contract-violation",
337
+ detail: cg.detail,
338
+ snippet: cg.snippet || command || toolName,
339
+ };
340
+ const res = { verdict: "ask", stage: "P4", reason: cg.reason || "contract-violation", detail: cg.detail, hit: cgHit };
341
+ this._applyLockedAskIfCritical(res, toolName, command, execution);
342
+ return res;
343
+ }
344
+ }
345
+
346
+ // Overengineering Guard (#89)
347
+ const fileTools = new Set(this.inspectConfig?.fileWriteTools || DEFAULT_FILE_WRITE_TOOLS);
348
+ if (this.overengineeringGuard && typeof toolName === "string" && fileTools.has(toolName)) {
349
+ const targetPath = extractFilePath(execution ? execution.arguments : undefined);
350
+ if (targetPath) {
351
+ const og = this.overengineeringGuard.checkAndRecord(sessionId, targetPath);
352
+ if (!og.allowed) {
353
+ const ogHit = {
354
+ ask: true,
355
+ reason: og.reason || "overengineering-file-limit",
356
+ detail: og.detail,
357
+ snippet: targetPath,
358
+ };
359
+ const res = { verdict: "ask", stage: "P4", reason: og.reason || "overengineering-file-limit", detail: og.detail, hit: ogHit };
360
+ this._applyLockedAskIfCritical(res, toolName, command, execution);
361
+ return res;
362
+ }
363
+ }
364
+ }
365
+
366
+ // P1: Session Grants
367
+ if (this.grantRegistry && typeof this.grantRegistry.decide === "function") {
368
+ const grantMatch = this.grantRegistry.decide(toolName, execution.arguments, context);
369
+ if (grantMatch === "allow") {
370
+ this.breaker.resetConsecutive(sessionId);
371
+ return { verdict: "allow", stage: "P1", reason: "session-grant", hit };
372
+ }
373
+ }
374
+
375
+ // If Circuit Breaker is tripped, force P4 Human Ask
376
+ if (this.breaker.isTripped(sessionId)) {
377
+ if (this.ledger) this.ledger.record(sessionId, "breaker", { reason: "circuit-breaker-tripped", tool: toolName });
378
+ const breakerHit = {
379
+ ask: true,
380
+ reason: "circuit-breaker-tripped",
381
+ detail: "Circuit breaker tripped due to repeated security denials. Manual confirmation required.",
382
+ snippet: command || toolName,
383
+ };
384
+ const res = {
385
+ verdict: "ask",
386
+ stage: "P4",
387
+ reason: "circuit-breaker-tripped",
388
+ detail: breakerHit.detail,
389
+ hit: breakerHit,
390
+ };
391
+ this._applyLockedAskIfCritical(res, toolName, command, execution);
392
+ return res;
393
+ }
394
+
395
+ // P4: Human Ask if syntax is unparseable or uncertain
396
+ if (hit.ask) {
397
+ const res = { verdict: "ask", stage: "P4", reason: hit.reason || "uncertain-syntax", detail: hit.detail || hit.snippet, hit };
398
+ this._applyLockedAskIfCritical(res, toolName, command, execution);
399
+ return res;
400
+ }
401
+
402
+ // P2: Static Rules / Safe Allow
403
+ if (bandVerdict === "allow") {
404
+ this.breaker.resetConsecutive(sessionId);
405
+ return { verdict: "allow", stage: "P2", reason: "safe-allow", hit };
406
+ }
407
+
408
+ // P3: Optional Semantic Classifier
409
+ if (this.classifier) {
410
+ try {
411
+ const cls = typeof this.classifier === "function"
412
+ ? this.classifier(execution, context)
413
+ : (typeof this.classifier.classify === "function" ? this.classifier.classify(execution, context) : null);
414
+ if (cls && cls.verdict === "allow") {
415
+ this.breaker.resetConsecutive(sessionId);
416
+ return { verdict: "allow", stage: "P3", reason: cls.reason || "classifier-allow", hit };
417
+ }
418
+ if (cls && cls.verdict === "deny") {
419
+ this.breaker.countDeny(sessionId);
420
+ const clsHit = { deny: true, reason: cls.reason || "classifier-deny", snippet: command || toolName };
421
+ return { verdict: "deny", stage: "P3", reason: cls.reason || "classifier-deny", detail: cls.reason, hit: clsHit };
422
+ }
423
+ if (cls && cls.verdict === "ask") {
424
+ const res = { verdict: "ask", stage: "P4", reason: cls.reason || "classifier-ask", detail: cls.detail || cls.reason, hit: null };
425
+ this._applyLockedAskIfCritical(res, toolName, command, execution);
426
+ return res;
427
+ }
428
+ } catch (err) {
429
+ // Classifier error fails closed to P4 Ask or handles timeoutPolicy (#118, #111)
430
+ if (this.ledger) this.ledger.record(sessionId, "error", { reason: "classifier-error", detail: String(err && err.message || err), tool: toolName });
431
+ const isTimeout = CircuitBreaker.isFaultOrTimeout(err) || Boolean(err && (err.isTimeout || err.timeout));
432
+ if (isTimeout && this.timeoutPolicy === "permissive") {
433
+ const critical = isCriticalOperation(toolName, command, execution);
434
+ if (!critical && bandVerdict !== "deny") {
435
+ return { verdict: "allow", stage: "P3", reason: "classifier-timeout-fallback-permissive", hit: null };
436
+ }
437
+ const res = { verdict: "ask", stage: "P4", reason: "classifier-timeout-fallback-strict", detail: String(err && err.message || err), hit: null };
438
+ this._applyLockedAskIfCritical(res, toolName, command, execution);
439
+ return res;
440
+ }
441
+ const reason = isTimeout ? "classifier-timeout-strict" : "classifier-error";
442
+ const res = { verdict: "ask", stage: "P4", reason, detail: String(err && err.message || err), hit: null };
443
+ this._applyLockedAskIfCritical(res, toolName, command, execution);
444
+ return res;
445
+ }
446
+ }
447
+
448
+ // Default verdict for unclassified commands (#163)
449
+ if (this.defaultVerdict === "ask") {
450
+ const askHit = { ask: true, reason: "unmatched-ask", snippet: command || toolName };
451
+ const res = { verdict: "ask", stage: "P4", reason: "unmatched-ask", hit: askHit };
452
+ this._applyLockedAskIfCritical(res, toolName, command, execution);
453
+ return res;
454
+ }
455
+ return { verdict: "allow", stage: "P2", reason: "unmatched-allow", hit };
456
+ }
457
+
458
+ /**
459
+ * Helper to check and mark lockedAsk for critical operations (#52).
460
+ * @private
461
+ */
462
+ _applyLockedAskIfCritical(decision, toolName, command, execution) {
463
+ if (decision && decision.verdict === "ask" && isCriticalOperation(toolName, command, execution)) {
464
+ decision.lockedAsk = true;
465
+ decision.lockedReason = "critical-operation";
466
+ if (decision.hit) {
467
+ decision.hit.lockedAsk = true;
468
+ }
469
+ }
470
+ }
471
+
472
+ addGrant(options) {
473
+ if (this.grantRegistry && typeof this.grantRegistry.addGrant === "function") {
474
+ return this.grantRegistry.addGrant(options);
475
+ }
476
+ return null;
477
+ }
478
+
479
+ getGrants(sessionId) {
480
+ if (this.grantRegistry && typeof this.grantRegistry.getGrants === "function") {
481
+ return this.grantRegistry.getGrants(sessionId);
482
+ }
483
+ return [];
484
+ }
485
+
486
+ getLedgerSummary(sessionId) {
487
+ return this.ledger ? this.ledger.getSummary(sessionId) : null;
488
+ }
489
+
490
+ isRetryExhausted(sessionId) {
491
+ return this.retryGuard ? this.retryGuard.isExhausted(sessionId) : false;
492
+ }
493
+
494
+ clearCache() {
495
+ if (this.cache) this.cache.clear();
496
+ }
497
+
498
+ getCounters() {
499
+ return this.counters ? this.counters.getSnapshot() : null;
500
+ }
501
+
502
+ resetCounters() {
503
+ if (this.counters) this.counters.reset();
504
+ }
505
+
506
+ resume(sessionId, reason) {
507
+ if (this.retryGuard) this.retryGuard.reset(sessionId);
508
+ if (this.overengineeringGuard) this.overengineeringGuard.reset(sessionId);
509
+ if (this.cache) this.cache.clearSession(sessionId);
510
+ return this.breaker.resume(sessionId, reason);
511
+ }
512
+ }