@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/CHANGELOG.md +162 -146
- package/lib/classifier.js +392 -0
- package/lib/contract-guard.js +147 -0
- package/lib/dispatcher.js +209 -207
- package/lib/engine.js +512 -407
- package/lib/index.js +57 -1
- package/lib/inspect.js +573 -520
- package/lib/messages.js +57 -0
- package/lib/overengineering.js +97 -0
- package/lib/paths.js +378 -320
- package/package.json +55 -55
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* @
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
if (
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (/\
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (
|
|
45
|
-
|
|
46
|
-
//
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* }
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
this.
|
|
96
|
-
this.
|
|
97
|
-
|
|
98
|
-
this.
|
|
99
|
-
this.
|
|
100
|
-
this.
|
|
101
|
-
this.
|
|
102
|
-
|
|
103
|
-
this.
|
|
104
|
-
this.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
//
|
|
185
|
-
if (
|
|
186
|
-
|
|
187
|
-
if (
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
this.
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
const res =
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
};
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
if (this.
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
if (
|
|
404
|
-
|
|
405
|
-
|
|
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
|
+
}
|