@goodandready/dsh-approval-gate 0.1.16 → 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/lib/engine.js CHANGED
@@ -1,407 +1,464 @@
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
+
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
+ * timeoutPolicy?: "strict" | "permissive",
83
+ * inspectConfig?: { toolName?: string, fileWriteTools?: string[], workspaceDir?: string },
84
+ * classifier?: ((execution: any, context?: any) => { verdict: "allow"|"deny", reason?: string }),
85
+ * grantRegistry?: { decide: (tool: string, args: any, context?: any) => "allow"|"no-match" },
86
+ * dryRun?: boolean,
87
+ * shadowMode?: boolean,
88
+ * defaultVerdict?: "allow" | "ask"
89
+ * }} [options]
90
+ */
91
+ constructor(options = {}) {
92
+ this.bands = options.bands || new BandsEngine(options);
93
+ this.breaker = options.breaker || new CircuitBreaker(options);
94
+ this.inspectConfig = options.inspectConfig || {};
95
+ this.classifier = options.classifier || null;
96
+ this.timeoutPolicy = options.timeoutPolicy === "permissive" ? "permissive" : "strict";
97
+ this.grantRegistry = options.grantRegistry || new SessionGrantStore(options);
98
+ this.defaultVerdict = options.defaultVerdict || "allow";
99
+
100
+ this.dryRun = Boolean(options.dryRun || options.shadowMode);
101
+ this.auditFailClosed = options.auditFailClosed === true;
102
+ this.cache = options.cache !== undefined ? options.cache : new VerdictCache();
103
+ this.retryGuard = options.retryGuard !== undefined ? options.retryGuard : new RetryExhaustionGuard();
104
+ this.ledger = options.ledger !== undefined ? options.ledger : new QualityLedger();
105
+ this.counters = options.counters || new BlockCounters();
106
+ this.auditLogger = options.auditLogger !== undefined ? options.auditLogger : new AuditLogger({
107
+ failClosed: this.auditFailClosed,
108
+ });
109
+ if (this.auditFailClosed && this.auditLogger) {
110
+ this.auditLogger.failClosed = true;
111
+ }
112
+ this.declarativeRules = options.declarativeRules || new DeclarativeRulesEngine({
113
+ workspaceDir: this.inspectConfig?.workspaceDir,
114
+ });
115
+ }
116
+
117
+ /**
118
+ * Evaluates an execution request through the P0-P4 waterfall.
119
+ *
120
+ * @param {{ name: string, arguments?: any }} execution - Tool call
121
+ * @param {{ sessionId?: string, knobs?: any, cwd?: string }} [context] - Context options
122
+ * @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 }}
123
+ */
124
+ decide(execution, context = {}) {
125
+ try {
126
+ const sessionId = context.sessionId || "default";
127
+ const toolName = execution && execution.name;
128
+ const command = execution && execution.arguments && execution.arguments.command;
129
+
130
+ // Check Retry Exhaustion guard (#96)
131
+ if (this.retryGuard && this.retryGuard.isExhausted(sessionId)) {
132
+ const exhHit = {
133
+ deny: true,
134
+ reason: "retry-exhaustion",
135
+ detail: "Retry exhaustion: consecutive blocked attempts exceeded threshold",
136
+ snippet: command || toolName,
137
+ };
138
+ const exhDecision = { verdict: "deny", stage: "P0", reason: "retry-exhaustion", detail: exhHit.detail, hit: exhHit };
139
+ if (this.ledger) this.ledger.record(sessionId, "exhaustion", { reason: "retry-exhaustion", tool: toolName });
140
+ if (this.counters) this.counters.record(exhDecision, execution);
141
+ if (this.auditLogger) this.auditLogger.appendAuditLine({ sessionId, execution, decision: exhDecision, dryRun: this.dryRun });
142
+
143
+ if (this.dryRun) {
144
+ return { verdict: "allow", stage: "P0", dryRun: true, originalVerdict: "deny", reason: "retry-exhaustion", detail: exhHit.detail, hit: exhHit };
145
+ }
146
+ return exhDecision;
147
+ }
148
+
149
+ // Check Verdict Cache (#63, #119)
150
+ const cacheKey = this.cache ? this.cache.makeKey(execution, context) : null;
151
+ if (this.cache && cacheKey) {
152
+ const cached = this.cache.get(cacheKey);
153
+ if (cached) {
154
+ if (this.dryRun && (cached.verdict === "deny" || cached.verdict === "ask")) {
155
+ return {
156
+ verdict: "allow",
157
+ stage: cached.stage,
158
+ dryRun: true,
159
+ originalVerdict: cached.verdict,
160
+ reason: cached.reason,
161
+ detail: cached.detail,
162
+ hit: cached.hit,
163
+ cached: true,
164
+ };
165
+ }
166
+ if (this.counters) this.counters.record(cached, execution);
167
+ return cached;
168
+ }
169
+ }
170
+
171
+ let decision = this._evaluateWaterfall(execution, context, sessionId, toolName, command);
172
+
173
+ // Post-decision hooks: ledger, retryGuard, cache
174
+ if (decision.verdict === "deny") {
175
+ if (this.retryGuard) this.retryGuard.recordVerdict(sessionId, execution, "deny");
176
+ if (this.ledger) this.ledger.record(sessionId, "deny", { reason: decision.reason, detail: decision.detail, tool: toolName });
177
+ if (this.cache && cacheKey) this.cache.set(cacheKey, decision, { sessionId });
178
+ } else if (decision.verdict === "allow") {
179
+ if (this.retryGuard) this.retryGuard.recordVerdict(sessionId, execution, "allow");
180
+ if (this.ledger) this.ledger.record(sessionId, "allow", { reason: decision.reason, detail: decision.detail, tool: toolName });
181
+ if (this.cache && cacheKey) this.cache.set(cacheKey, decision, { sessionId });
182
+ } else if (decision.verdict === "ask") {
183
+ if (this.ledger) this.ledger.record(sessionId, "ask", { reason: decision.reason, detail: decision.detail, tool: toolName });
184
+ }
185
+
186
+ // Audit Logger (#56) with fail-closed audit verification (#53)
187
+ if (this.auditLogger) {
188
+ const auditOk = this.auditLogger.appendAuditLine({ sessionId, execution, decision, dryRun: this.dryRun });
189
+ if (!auditOk && this.auditFailClosed && !this.dryRun) {
190
+ const auditDenyHit = {
191
+ deny: true,
192
+ reason: "fail-closed-audit",
193
+ detail: "Audit log write failed. Execution blocked by fail-closed audit policy (#53).",
194
+ snippet: command || toolName,
195
+ };
196
+ decision = {
197
+ verdict: "deny",
198
+ stage: "P0",
199
+ reason: "fail-closed-audit",
200
+ detail: auditDenyHit.detail,
201
+ hit: auditDenyHit,
202
+ auditFailed: true,
203
+ };
204
+ }
205
+ }
206
+
207
+ // Record block counters (#48)
208
+ if (this.counters) {
209
+ this.counters.record(decision, execution);
210
+ }
211
+
212
+ // Shadow Mode (#128)
213
+ if (this.dryRun && (decision.verdict === "deny" || decision.verdict === "ask")) {
214
+ return {
215
+ verdict: "allow",
216
+ stage: decision.stage,
217
+ dryRun: true,
218
+ originalVerdict: decision.verdict,
219
+ reason: decision.reason,
220
+ detail: decision.detail,
221
+ hit: decision.hit,
222
+ lockedAsk: decision.lockedAsk,
223
+ };
224
+ }
225
+
226
+ return decision;
227
+ } catch (err) {
228
+ const errDecision = {
229
+ verdict: "ask",
230
+ stage: "P4",
231
+ reason: "inspect-error",
232
+ detail: String(err && err.message || err),
233
+ hit: { ask: true, reason: "inspect-error", detail: String(err && err.message || err) },
234
+ };
235
+ if (this.counters) this.counters.record(errDecision, execution);
236
+ return errDecision;
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Asynchronous evaluation supporting async classifiers (#129, #118).
242
+ *
243
+ * @param {{ name: string, arguments?: any }} execution
244
+ * @param {{ sessionId?: string, knobs?: any, cwd?: string }} [context]
245
+ * @returns {Promise<any>}
246
+ */
247
+ async decideAsync(execution, context = {}) {
248
+ if (this.classifier) {
249
+ let resolvedVerdict = null;
250
+ let caughtError = null;
251
+ try {
252
+ if (typeof this.classifier.classify === "function") {
253
+ resolvedVerdict = await this.classifier.classify(execution, context);
254
+ } else if (typeof this.classifier === "function") {
255
+ const res = this.classifier(execution, context);
256
+ resolvedVerdict = res && typeof res.then === "function" ? await res : res;
257
+ }
258
+ } catch (err) {
259
+ caughtError = err;
260
+ }
261
+
262
+ const originalClassifier = this.classifier;
263
+ if (caughtError) {
264
+ this.classifier = () => { throw caughtError; };
265
+ } else {
266
+ this.classifier = () => resolvedVerdict;
267
+ }
268
+ try {
269
+ return this.decide(execution, context);
270
+ } finally {
271
+ this.classifier = originalClassifier;
272
+ }
273
+ }
274
+ return this.decide(execution, context);
275
+ }
276
+
277
+ /**
278
+ * Internal evaluation of P0-P4 waterfall.
279
+ * @private
280
+ */
281
+ _evaluateWaterfall(execution, context, sessionId, toolName, command) {
282
+ // Fast-path Band 0 Hard Deny
283
+ const bandVerdict = this.bands.evaluate(toolName, command);
284
+ if (bandVerdict === "deny") {
285
+ this.breaker.countDeny(sessionId);
286
+ const hit = { deny: true, reason: "band-hard-deny", detail: command, snippet: command };
287
+ return { verdict: "deny", stage: "P0", reason: "band-hard-deny", detail: command, hit };
288
+ }
289
+
290
+ // Declarative YAML Rules evaluation (#38, #45, #44)
291
+ if (this.declarativeRules && command) {
292
+ const decl = this.declarativeRules.evaluate(toolName, command, context);
293
+ if (decl.match) {
294
+ if (decl.verdict === "deny") {
295
+ this.breaker.countDeny(sessionId);
296
+ const hit = { deny: true, reason: decl.reason || "declarative-deny", detail: command, snippet: command, ruleId: decl.ruleId };
297
+ return { verdict: "deny", stage: "P0", reason: decl.reason || "declarative-deny", detail: command, hit };
298
+ }
299
+ if (decl.verdict === "allow") {
300
+ this.breaker.resetConsecutive(sessionId);
301
+ return { verdict: "allow", stage: "P1", reason: decl.reason || "declarative-allow", hit: null, ruleId: decl.ruleId };
302
+ }
303
+ if (decl.verdict === "ask") {
304
+ const hit = { ask: true, reason: decl.reason || "declarative-ask", detail: command, snippet: command, ruleId: decl.ruleId };
305
+ const res = { verdict: "ask", stage: "P4", reason: decl.reason || "declarative-ask", detail: command, hit, ruleId: decl.ruleId };
306
+ this._applyLockedAskIfCritical(res, toolName, command, execution);
307
+ return res;
308
+ }
309
+ }
310
+ }
311
+
312
+ // Inspect execution for hard denials and syntax checks
313
+ const hit = inspectExecution(execution, this.inspectConfig);
314
+ if (hit.deny) {
315
+ this.breaker.countDeny(sessionId);
316
+ return { verdict: "deny", stage: "P0", reason: hit.reason || "hard-deny", detail: hit.detail || hit.snippet, hit };
317
+ }
318
+
319
+ // P1: Session Grants
320
+ if (this.grantRegistry && typeof this.grantRegistry.decide === "function") {
321
+ const grantMatch = this.grantRegistry.decide(toolName, execution.arguments, context);
322
+ if (grantMatch === "allow") {
323
+ this.breaker.resetConsecutive(sessionId);
324
+ return { verdict: "allow", stage: "P1", reason: "session-grant", hit };
325
+ }
326
+ }
327
+
328
+ // If Circuit Breaker is tripped, force P4 Human Ask
329
+ if (this.breaker.isTripped(sessionId)) {
330
+ if (this.ledger) this.ledger.record(sessionId, "breaker", { reason: "circuit-breaker-tripped", tool: toolName });
331
+ const breakerHit = {
332
+ ask: true,
333
+ reason: "circuit-breaker-tripped",
334
+ detail: "Circuit breaker tripped due to repeated security denials. Manual confirmation required.",
335
+ snippet: command || toolName,
336
+ };
337
+ const res = {
338
+ verdict: "ask",
339
+ stage: "P4",
340
+ reason: "circuit-breaker-tripped",
341
+ detail: breakerHit.detail,
342
+ hit: breakerHit,
343
+ };
344
+ this._applyLockedAskIfCritical(res, toolName, command, execution);
345
+ return res;
346
+ }
347
+
348
+ // P4: Human Ask if syntax is unparseable or uncertain
349
+ if (hit.ask) {
350
+ const res = { verdict: "ask", stage: "P4", reason: hit.reason || "uncertain-syntax", detail: hit.detail || hit.snippet, hit };
351
+ this._applyLockedAskIfCritical(res, toolName, command, execution);
352
+ return res;
353
+ }
354
+
355
+ // P2: Static Rules / Safe Allow
356
+ if (bandVerdict === "allow") {
357
+ this.breaker.resetConsecutive(sessionId);
358
+ return { verdict: "allow", stage: "P2", reason: "safe-allow", hit };
359
+ }
360
+
361
+ // P3: Optional Semantic Classifier
362
+ if (this.classifier) {
363
+ try {
364
+ const cls = typeof this.classifier === "function"
365
+ ? this.classifier(execution, context)
366
+ : (typeof this.classifier.classify === "function" ? this.classifier.classify(execution, context) : null);
367
+ if (cls && cls.verdict === "allow") {
368
+ this.breaker.resetConsecutive(sessionId);
369
+ return { verdict: "allow", stage: "P3", reason: cls.reason || "classifier-allow", hit };
370
+ }
371
+ if (cls && cls.verdict === "deny") {
372
+ this.breaker.countDeny(sessionId);
373
+ const clsHit = { deny: true, reason: cls.reason || "classifier-deny", snippet: command || toolName };
374
+ return { verdict: "deny", stage: "P3", reason: cls.reason || "classifier-deny", detail: cls.reason, hit: clsHit };
375
+ }
376
+ if (cls && cls.verdict === "ask") {
377
+ const res = { verdict: "ask", stage: "P4", reason: cls.reason || "classifier-ask", detail: cls.detail || cls.reason, hit: null };
378
+ this._applyLockedAskIfCritical(res, toolName, command, execution);
379
+ return res;
380
+ }
381
+ } catch (err) {
382
+ // Classifier error fails closed to P4 Ask or handles timeoutPolicy (#118, #111)
383
+ if (this.ledger) this.ledger.record(sessionId, "error", { reason: "classifier-error", detail: String(err && err.message || err), tool: toolName });
384
+ const isTimeout = CircuitBreaker.isFaultOrTimeout(err) || Boolean(err && (err.isTimeout || err.timeout));
385
+ if (isTimeout && this.timeoutPolicy === "permissive") {
386
+ const critical = isCriticalOperation(toolName, command, execution);
387
+ if (!critical && bandVerdict !== "deny") {
388
+ return { verdict: "allow", stage: "P3", reason: "classifier-timeout-fallback-permissive", hit: null };
389
+ }
390
+ const res = { verdict: "ask", stage: "P4", reason: "classifier-timeout-fallback-strict", detail: String(err && err.message || err), hit: null };
391
+ this._applyLockedAskIfCritical(res, toolName, command, execution);
392
+ return res;
393
+ }
394
+ const reason = isTimeout ? "classifier-timeout-strict" : "classifier-error";
395
+ const res = { verdict: "ask", stage: "P4", reason, detail: String(err && err.message || err), hit: null };
396
+ this._applyLockedAskIfCritical(res, toolName, command, execution);
397
+ return res;
398
+ }
399
+ }
400
+
401
+ // Default verdict for unclassified commands (#163)
402
+ if (this.defaultVerdict === "ask") {
403
+ const askHit = { ask: true, reason: "unmatched-ask", snippet: command || toolName };
404
+ const res = { verdict: "ask", stage: "P4", reason: "unmatched-ask", hit: askHit };
405
+ this._applyLockedAskIfCritical(res, toolName, command, execution);
406
+ return res;
407
+ }
408
+ return { verdict: "allow", stage: "P2", reason: "unmatched-allow", hit };
409
+ }
410
+
411
+ /**
412
+ * Helper to check and mark lockedAsk for critical operations (#52).
413
+ * @private
414
+ */
415
+ _applyLockedAskIfCritical(decision, toolName, command, execution) {
416
+ if (decision && decision.verdict === "ask" && isCriticalOperation(toolName, command, execution)) {
417
+ decision.lockedAsk = true;
418
+ decision.lockedReason = "critical-operation";
419
+ if (decision.hit) {
420
+ decision.hit.lockedAsk = true;
421
+ }
422
+ }
423
+ }
424
+
425
+ addGrant(options) {
426
+ if (this.grantRegistry && typeof this.grantRegistry.addGrant === "function") {
427
+ return this.grantRegistry.addGrant(options);
428
+ }
429
+ return null;
430
+ }
431
+
432
+ getGrants(sessionId) {
433
+ if (this.grantRegistry && typeof this.grantRegistry.getGrants === "function") {
434
+ return this.grantRegistry.getGrants(sessionId);
435
+ }
436
+ return [];
437
+ }
438
+
439
+ getLedgerSummary(sessionId) {
440
+ return this.ledger ? this.ledger.getSummary(sessionId) : null;
441
+ }
442
+
443
+ isRetryExhausted(sessionId) {
444
+ return this.retryGuard ? this.retryGuard.isExhausted(sessionId) : false;
445
+ }
446
+
447
+ clearCache() {
448
+ if (this.cache) this.cache.clear();
449
+ }
450
+
451
+ getCounters() {
452
+ return this.counters ? this.counters.getSnapshot() : null;
453
+ }
454
+
455
+ resetCounters() {
456
+ if (this.counters) this.counters.reset();
457
+ }
458
+
459
+ resume(sessionId, reason) {
460
+ if (this.retryGuard) this.retryGuard.reset(sessionId);
461
+ if (this.cache) this.cache.clearSession(sessionId);
462
+ return this.breaker.resume(sessionId, reason);
463
+ }
464
+ }