@goodandready/dsh-approval-gate 0.1.12 → 0.1.14

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,7 +1,7 @@
1
1
  // lib/engine.js
2
2
  // 5-tier Security Decision Engine for dsh-approval-gate.
3
3
  // Waterfall:
4
- // P0: Hard Deny (Bands Band 0 + inspectExecution hard denials + protected files)
4
+ // P0: Hard Deny (Bands Band 0 + inspectExecution hard denials + protected files + retry exhaustion)
5
5
  // P1: Session Grants (scoped temporary allowances)
6
6
  // P2: Static Rules / Safe Allow (Bands Band 1 + safe read-only commands)
7
7
  // P3: Optional Semantic Classifier
@@ -11,15 +11,26 @@ import { BandsEngine } from "./bands.js";
11
11
  import { CircuitBreaker } from "./breaker.js";
12
12
  import { SessionGrantStore } from "./grants.js";
13
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";
14
18
 
15
19
  export class SecurityEngine {
16
20
  /**
17
21
  * @param {{
18
22
  * bands?: BandsEngine,
19
23
  * breaker?: CircuitBreaker,
20
- * inspectConfig?: { toolName?: string, fileWriteTools?: string[] },
24
+ * cache?: VerdictCache,
25
+ * retryGuard?: RetryExhaustionGuard,
26
+ * ledger?: QualityLedger,
27
+ * auditLogger?: AuditLogger,
28
+ * inspectConfig?: { toolName?: string, fileWriteTools?: string[], workspaceDir?: string },
21
29
  * classifier?: ((execution: any, context?: any) => { verdict: "allow"|"deny", reason?: string }),
22
- * grantRegistry?: { decide: (tool: string, args: any, context?: any) => "allow"|"no-match" }
30
+ * grantRegistry?: { decide: (tool: string, args: any, context?: any) => "allow"|"no-match" },
31
+ * dryRun?: boolean,
32
+ * shadowMode?: boolean,
33
+ * defaultVerdict?: "allow" | "ask"
23
34
  * }} [options]
24
35
  */
25
36
  constructor(options = {}) {
@@ -29,21 +40,115 @@ export class SecurityEngine {
29
40
  this.classifier = options.classifier || null;
30
41
  this.grantRegistry = options.grantRegistry || new SessionGrantStore(options);
31
42
  this.defaultVerdict = options.defaultVerdict || "allow";
43
+
44
+ this.dryRun = Boolean(options.dryRun || options.shadowMode);
45
+ this.cache = options.cache !== undefined ? options.cache : new VerdictCache();
46
+ this.retryGuard = options.retryGuard !== undefined ? options.retryGuard : new RetryExhaustionGuard();
47
+ this.ledger = options.ledger !== undefined ? options.ledger : new QualityLedger();
48
+ this.auditLogger = options.auditLogger !== undefined ? options.auditLogger : new AuditLogger();
32
49
  }
33
50
 
34
51
  /**
35
52
  * Evaluates an execution request through the P0-P4 waterfall.
36
53
  *
37
54
  * @param {{ name: string, arguments?: any }} execution - Tool call
38
- * @param {{ sessionId?: string, knobs?: any }} [context] - Context options
39
- * @returns {{ verdict: "allow" | "deny" | "ask", stage: "P0" | "P1" | "P2" | "P3" | "P4", reason: string, detail?: string, hit?: any }}
55
+ * @param {{ sessionId?: string, knobs?: any, cwd?: string }} [context] - Context options
56
+ * @returns {{ verdict: "allow" | "deny" | "ask", stage: "P0" | "P1" | "P2" | "P3" | "P4", reason: string, detail?: string, hit?: any, dryRun?: boolean, cached?: boolean }}
40
57
  */
41
58
  decide(execution, context = {}) {
42
59
  try {
43
60
  const sessionId = context.sessionId || "default";
44
- const toolName = execution && execution.name;
45
- const command = execution && execution.arguments && execution.arguments.command;
61
+ const toolName = execution && execution.name;
62
+ const command = execution && execution.arguments && execution.arguments.command;
63
+
64
+ // Check Retry Exhaustion guard (#96)
65
+ if (this.retryGuard && this.retryGuard.isExhausted(sessionId)) {
66
+ const exhHit = {
67
+ deny: true,
68
+ reason: "retry-exhaustion",
69
+ detail: "Retry exhaustion: consecutive blocked attempts exceeded threshold",
70
+ snippet: command || toolName,
71
+ };
72
+ const exhDecision = { verdict: "deny", stage: "P0", reason: "retry-exhaustion", detail: exhHit.detail, hit: exhHit };
73
+ if (this.ledger) this.ledger.record(sessionId, "exhaustion", { reason: "retry-exhaustion", tool: toolName });
74
+ if (this.auditLogger) this.auditLogger.log({ sessionId, execution, decision: exhDecision, dryRun: this.dryRun });
75
+
76
+ if (this.dryRun) {
77
+ return { verdict: "allow", stage: "P0", dryRun: true, originalVerdict: "deny", reason: "retry-exhaustion", detail: exhHit.detail, hit: exhHit };
78
+ }
79
+ return exhDecision;
80
+ }
81
+
82
+ // Check Verdict Cache (#63, #119)
83
+ const cacheKey = this.cache ? this.cache.makeKey(execution, context) : null;
84
+ if (this.cache && cacheKey) {
85
+ const cached = this.cache.get(cacheKey);
86
+ if (cached) {
87
+ if (this.dryRun && (cached.verdict === "deny" || cached.verdict === "ask")) {
88
+ return {
89
+ verdict: "allow",
90
+ stage: cached.stage,
91
+ dryRun: true,
92
+ originalVerdict: cached.verdict,
93
+ reason: cached.reason,
94
+ detail: cached.detail,
95
+ hit: cached.hit,
96
+ cached: true,
97
+ };
98
+ }
99
+ return cached;
100
+ }
101
+ }
102
+
103
+ const decision = this._evaluateWaterfall(execution, context, sessionId, toolName, command);
104
+
105
+ // Post-decision hooks: ledger, retryGuard, cache, audit
106
+ if (decision.verdict === "deny") {
107
+ if (this.retryGuard) this.retryGuard.recordVerdict(sessionId, execution, "deny");
108
+ if (this.ledger) this.ledger.record(sessionId, "deny", { reason: decision.reason, detail: decision.detail, tool: toolName });
109
+ if (this.cache && cacheKey) this.cache.set(cacheKey, decision, { sessionId });
110
+ } else if (decision.verdict === "allow") {
111
+ if (this.retryGuard) this.retryGuard.recordVerdict(sessionId, execution, "allow");
112
+ if (this.ledger) this.ledger.record(sessionId, "allow", { reason: decision.reason, detail: decision.detail, tool: toolName });
113
+ if (this.cache && cacheKey) this.cache.set(cacheKey, decision, { sessionId });
114
+ } else if (decision.verdict === "ask") {
115
+ if (this.ledger) this.ledger.record(sessionId, "ask", { reason: decision.reason, detail: decision.detail, tool: toolName });
116
+ }
46
117
 
118
+ if (this.auditLogger) {
119
+ this.auditLogger.log({ sessionId, execution, decision, dryRun: this.dryRun });
120
+ }
121
+
122
+ // Shadow Mode (#128)
123
+ if (this.dryRun && (decision.verdict === "deny" || decision.verdict === "ask")) {
124
+ return {
125
+ verdict: "allow",
126
+ stage: decision.stage,
127
+ dryRun: true,
128
+ originalVerdict: decision.verdict,
129
+ reason: decision.reason,
130
+ detail: decision.detail,
131
+ hit: decision.hit,
132
+ };
133
+ }
134
+
135
+ return decision;
136
+ } catch (err) {
137
+ return {
138
+ verdict: "ask",
139
+ stage: "P4",
140
+ reason: "inspect-error",
141
+ detail: String(err && err.message || err),
142
+ hit: { ask: true, reason: "inspect-error", detail: String(err && err.message || err) },
143
+ };
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Internal evaluation of P0-P4 waterfall.
149
+ * @private
150
+ */
151
+ _evaluateWaterfall(execution, context, sessionId, toolName, command) {
47
152
  // Fast-path Band 0 Hard Deny
48
153
  const bandVerdict = this.bands.evaluate(toolName, command);
49
154
  if (bandVerdict === "deny") {
@@ -70,6 +175,7 @@ export class SecurityEngine {
70
175
 
71
176
  // If Circuit Breaker is tripped, suspend auto-mode and force P4 Human Ask
72
177
  if (this.breaker.isTripped(sessionId)) {
178
+ if (this.ledger) this.ledger.record(sessionId, "breaker", { reason: "circuit-breaker-tripped", tool: toolName });
73
179
  const breakerHit = {
74
180
  ask: true,
75
181
  reason: "circuit-breaker-tripped",
@@ -110,7 +216,8 @@ export class SecurityEngine {
110
216
  return { verdict: "deny", stage: "P3", reason: cls.reason || "classifier-deny", detail: cls.reason, hit: clsHit };
111
217
  }
112
218
  } catch (err) {
113
- // Classifier error fails closed to P4 Ask
219
+ // Classifier error fails closed to P4 Ask (#111: does not trip breaker)
220
+ if (this.ledger) this.ledger.record(sessionId, "error", { reason: "classifier-error", detail: String(err && err.message || err), tool: toolName });
114
221
  return { verdict: "ask", stage: "P4", reason: "classifier-error", detail: String(err && err.message || err), hit: null };
115
222
  }
116
223
  }
@@ -121,15 +228,6 @@ export class SecurityEngine {
121
228
  return { verdict: "ask", stage: "P4", reason: "unmatched-ask", hit: askHit };
122
229
  }
123
230
  return { verdict: "allow", stage: "P2", reason: "unmatched-allow", hit };
124
- } catch (err) {
125
- return {
126
- verdict: "ask",
127
- stage: "P4",
128
- reason: "inspect-error",
129
- detail: String(err && err.message || err),
130
- hit: { ask: true, reason: "inspect-error", detail: String(err && err.message || err) },
131
- };
132
- }
133
231
  }
134
232
 
135
233
  addGrant(options) {
@@ -146,7 +244,21 @@ export class SecurityEngine {
146
244
  return [];
147
245
  }
148
246
 
247
+ getLedgerSummary(sessionId) {
248
+ return this.ledger ? this.ledger.getSummary(sessionId) : null;
249
+ }
250
+
251
+ isRetryExhausted(sessionId) {
252
+ return this.retryGuard ? this.retryGuard.isExhausted(sessionId) : false;
253
+ }
254
+
255
+ clearCache() {
256
+ if (this.cache) this.cache.clear();
257
+ }
258
+
149
259
  resume(sessionId, reason) {
260
+ if (this.retryGuard) this.retryGuard.reset(sessionId);
261
+ if (this.cache) this.cache.clearSession(sessionId);
150
262
  return this.breaker.resume(sessionId, reason);
151
263
  }
152
264
  }
@@ -0,0 +1,100 @@
1
+ // lib/fingerprint.js
2
+ // Canonical operation fingerprint generator for dsh-approval-gate (#80).
3
+ // Format: tool:normalize(cmd):target_path
4
+ // Strict: flag or argument variations produce distinct fingerprints.
5
+
6
+ import { createHash } from "node:crypto";
7
+ import { extractFilePath } from "./dispatcher.js";
8
+ import { canonicalizeFsPath, normalizeSeparators } from "./paths.js";
9
+
10
+ /**
11
+ * Normalizes command whitespace while preserving quotes.
12
+ *
13
+ * @param {string} cmd
14
+ * @returns {string}
15
+ */
16
+ export function normalizeCommandString(cmd) {
17
+ if (typeof cmd !== "string") return "";
18
+ const trimmed = cmd.trim();
19
+ if (!trimmed) return "";
20
+
21
+ let result = "";
22
+ let inSingle = false;
23
+ let inDouble = false;
24
+ let lastWasSpace = false;
25
+
26
+ for (let i = 0; i < trimmed.length; i += 1) {
27
+ const ch = trimmed[i];
28
+ if (ch === "'" && !inDouble) {
29
+ inSingle = !inSingle;
30
+ result += ch;
31
+ lastWasSpace = false;
32
+ } else if (ch === '"' && !inSingle) {
33
+ inDouble = !inDouble;
34
+ result += ch;
35
+ lastWasSpace = false;
36
+ } else if (/\s/.test(ch) && !inSingle && !inDouble) {
37
+ if (!lastWasSpace) {
38
+ result += " ";
39
+ lastWasSpace = true;
40
+ }
41
+ } else {
42
+ result += ch;
43
+ lastWasSpace = false;
44
+ }
45
+ }
46
+
47
+ return result.trim();
48
+ }
49
+
50
+ /**
51
+ * Generates a canonical fingerprint for an operation.
52
+ * Format: tool:normalized_cmd:target_path
53
+ *
54
+ * @param {{ name?: string, tool?: string, arguments?: any } | string} execution
55
+ * @param {{ cwd?: string }} [options]
56
+ * @returns {string}
57
+ */
58
+ export function computeFingerprint(execution, options = {}) {
59
+ let tool = "";
60
+ let args = {};
61
+
62
+ if (typeof execution === "string") {
63
+ tool = execution.trim().toLowerCase();
64
+ } else if (execution && typeof execution === "object") {
65
+ tool = String(execution.name || execution.tool || "").trim().toLowerCase();
66
+ args = execution.arguments || {};
67
+ if (typeof args === "string") {
68
+ try {
69
+ args = JSON.parse(args);
70
+ } catch {
71
+ args = { raw: args };
72
+ }
73
+ }
74
+ }
75
+
76
+ const rawCmd = args && typeof args.command === "string" ? args.command : "";
77
+ const normalizedCmd = normalizeCommandString(rawCmd);
78
+
79
+ let targetPath = (args && (args.cwd || extractFilePath(args))) || options.cwd || "";
80
+ if (typeof targetPath === "string" && targetPath.trim()) {
81
+ targetPath = canonicalizeFsPath(normalizeSeparators(targetPath.trim()));
82
+ } else {
83
+ targetPath = "";
84
+ }
85
+
86
+ return `${tool}:${normalizedCmd}:${targetPath}`;
87
+ }
88
+
89
+ /**
90
+ * Computes sha256 hash of the canonical operation fingerprint.
91
+ *
92
+ * @param {{ name?: string, tool?: string, arguments?: any } | string} execution
93
+ * @param {{ cwd?: string, algorithm?: string }} [options]
94
+ * @returns {string}
95
+ */
96
+ export function computeFingerprintHash(execution, options = {}) {
97
+ const fp = computeFingerprint(execution, options);
98
+ const algo = options.algorithm || "sha256";
99
+ return createHash(algo).update(fp, "utf8").digest("hex");
100
+ }
package/lib/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { SecurityEngine } from "./engine.js";
2
2
  import { BandsEngine } from "./bands.js";
3
+ import { AuditLogger } from "./audit-log.js";
3
4
  import Schema from "@deepseek-ai/schemastery";
4
5
 
5
6
  if (typeof Schema.prototype?.volatile !== "function") {
@@ -205,6 +206,12 @@ export const Config = Schema.object({
205
206
  workspaceDir: Schema.string().default("").volatile(),
206
207
  /** Default verdict for unclassified commands: 'allow' (default) or 'ask' (fail-closed) (#163). */
207
208
  defaultVerdict: Schema.union(["allow", "ask"]).default("allow").volatile(),
209
+ /** Dry-run / shadow mode for safe testing of rules on production (#128). */
210
+ dryRun: Schema.boolean().default(false).volatile(),
211
+ /** Shadow mode alias for dryRun (#128). */
212
+ shadowMode: Schema.boolean().default(false).volatile(),
213
+ /** Path to append-only audit log file (#56). */
214
+ auditLogPath: Schema.string().default("").volatile(),
208
215
  /** Security engine instance (internal/testing). */
209
216
  engine: Schema.any().hidden(),
210
217
  });
@@ -248,12 +255,18 @@ export function apply(ctx, config) {
248
255
  inspectConfig: { toolName: toolName, fileWriteTools: fileWriteTools, workspaceDir: cfg.workspaceDir || "" },
249
256
  bands: new BandsEngine({ toolRegistry: ctx && ctx.tools }),
250
257
  defaultVerdict: cfg.defaultVerdict || "allow",
258
+ dryRun: Boolean(cfg.dryRun || cfg.shadowMode),
259
+ auditLogger: new AuditLogger({ logPath: cfg.auditLogPath || undefined }),
251
260
  });
252
261
 
253
262
  const gateService = {
254
263
  engine,
255
264
  addGrant: (options) => engine.addGrant(options),
256
265
  resume: (sessionId, reason) => engine.resume(sessionId, reason),
266
+ cache: engine.cache,
267
+ retryGuard: engine.retryGuard,
268
+ ledger: engine.ledger,
269
+ getLedgerSummary: (sessionId) => engine.getLedgerSummary(sessionId),
257
270
  };
258
271
  if (ctx && typeof ctx === "object") {
259
272
  if (typeof ctx.provide === "function") {
@@ -282,6 +295,13 @@ export function apply(ctx, config) {
282
295
  return { kind: "ask", reason: askMessage(hit, who, translate, ctx && ctx.logger) };
283
296
  }
284
297
 
298
+ if (decision.dryRun) {
299
+ if (ctx && ctx.logger && typeof ctx.logger.info === "function") {
300
+ ctx.logger.info(`[dsh-approval-gate:DRY-RUN] would block rule=${decision.reason} (${decision.originalVerdict})${who}`);
301
+ }
302
+ return next();
303
+ }
304
+
285
305
  if (decision.verdict === "ask") {
286
306
  if (decision.reason === "circuit-breaker-tripped") {
287
307
  const hit = decision.hit || {
@@ -325,6 +345,13 @@ export function apply(ctx, config) {
325
345
  return denyMessage(hit, who, translate, ctx && ctx.logger);
326
346
  }
327
347
 
348
+ if (decision.dryRun) {
349
+ if (ctx && ctx.logger && typeof ctx.logger.info === "function") {
350
+ ctx.logger.info(`[dsh-approval-gate:DRY-RUN] would block rule=${decision.reason} (${decision.originalVerdict})${who}`);
351
+ }
352
+ return undefined;
353
+ }
354
+
328
355
  if (decision.verdict === "deny" || (decision.verdict === "ask" && !hasPreExecute)) {
329
356
  const hit = decision.hit || {
330
357
  deny: true,
@@ -370,3 +397,56 @@ export {
370
397
  export { expandHome, resolveSafePath, isPathTraversal, canonicalizeFsPath } from "./paths.js";
371
398
  export { splitSubcommands } from "./tokenizer.js";
372
399
  export { redactCredentials, redactUrl } from "./redact.js";
400
+ export {
401
+ CommandDispatcher,
402
+ isReviewerAgent,
403
+ extractFilePath,
404
+ TOOL_KIND_SHELL,
405
+ TOOL_KIND_FILE_WRITE,
406
+ TOOL_KIND_FILE_READ,
407
+ TOOL_KIND_NETWORK,
408
+ TOOL_KIND_GENERIC,
409
+ } from "./dispatcher.js";
410
+ export {
411
+ parseGitArgv,
412
+ classifyGitOperation,
413
+ inspectGitCommand,
414
+ GIT_CATEGORY_READ,
415
+ GIT_CATEGORY_MUTATE_LOCAL,
416
+ GIT_CATEGORY_REMOTE,
417
+ GIT_CATEGORY_DESTRUCTIVE,
418
+ } from "./parsers/git.js";
419
+ export {
420
+ isDevicePath,
421
+ extractRedirects,
422
+ inspectRedirectSecurity,
423
+ inspectSilentErrors,
424
+ inspectPipelines,
425
+ } from "./parsers/shell-cmds.js";
426
+ export {
427
+ isLoopbackHost,
428
+ isLoopbackUrl,
429
+ isLoopbackCommand,
430
+ } from "./loopback.js";
431
+ export {
432
+ computeFingerprint,
433
+ computeFingerprintHash,
434
+ normalizeCommandString,
435
+ } from "./fingerprint.js";
436
+ export {
437
+ VerdictCache,
438
+ DEFAULT_CACHE_MAX_SIZE,
439
+ DEFAULT_CACHE_TTL_MS,
440
+ } from "./cache.js";
441
+ export {
442
+ RetryExhaustionGuard,
443
+ DEFAULT_MAX_RETRY_ATTEMPTS,
444
+ } from "./retry-exhaustion.js";
445
+ export {
446
+ QualityLedger,
447
+ MAX_LEDGER_EVENTS_PER_SESSION,
448
+ } from "./ledger.js";
449
+ export {
450
+ AuditLogger,
451
+ DEFAULT_AUDIT_LOG_RELATIVE,
452
+ } from "./audit-log.js";