@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/inspect.js CHANGED
@@ -1,26 +1,22 @@
1
+ // lib/inspect.js
2
+ // Pure command and path inspector for dsh-approval-gate.
3
+ // Resolves: #133, #125, #126, #103, #90, #65, #40
4
+ // No Cordis imports: unit tests run this without the harness.
5
+
1
6
  import { checkEnvExfiltration } from "./inspect-env.js";
2
7
  export { checkEnvExfiltration } from "./inspect-env.js";
3
- import { DYNAMIC_MARKER, HEREDOC_OPS, REDIR_OPS, SPLIT_OPS, tokenize, readCommandSubstitution, readBacktick, readParameterExpansion } from "./tokenizer.js";
8
+ import { DYNAMIC_MARKER, SPLIT_OPS, tokenize, readCommandSubstitution, readBacktick, readParameterExpansion } from "./tokenizer.js";
4
9
  import { expandHome, isProtectedConfigPath, resolveSafePath, isPathTraversal, canonicalizeFsPath, normalizeSeparators } from "./paths.js";
5
10
  import { askMessage, denyMessage, sessionTag } from "./messages.js";
11
+ import { inspectGitCommand } from "./parsers/git.js";
12
+ import { extractRedirects, inspectRedirectSecurity, inspectSilentErrors, inspectPipelines, isDevicePath } from "./parsers/shell-cmds.js";
13
+ import { isLoopbackCommand } from "./loopback.js";
14
+ import { isReviewerAgent, extractFilePath, DEFAULT_BASH_TOOL, DEFAULT_FILE_WRITE_TOOLS } from "./dispatcher.js";
6
15
 
7
16
  export { DYNAMIC_MARKER, tokenize, splitSubcommands } from "./tokenizer.js";
8
17
  export { askMessage, denyMessage, sessionTag } from "./messages.js";
9
18
  export { expandHome, isProtectedConfigPath, resolveSafePath, isPathTraversal, canonicalizeFsPath, normalizeSeparators } from "./paths.js";
10
-
11
- // Pure command/path inspector for dsh-approval-gate.
12
- // No Cordis imports: unit tests run this without the harness.
13
-
14
- export const DEFAULT_BASH_TOOL = "bash";
15
- export const DEFAULT_FILE_WRITE_TOOLS = Object.freeze([
16
- "write",
17
- "edit",
18
- "Write",
19
- "Edit",
20
- "str_replace_editor",
21
- "str_replace",
22
- "apply_patch",
23
- ]);
19
+ export { DEFAULT_BASH_TOOL, DEFAULT_FILE_WRITE_TOOLS } from "./dispatcher.js";
24
20
 
25
21
  const PREFIXES = new Set(["then", "do", "{", "}", "!"]);
26
22
  const SHELLS = new Set(["bash", "sh", "dash", "zsh", "ksh", "ash"]);
@@ -36,6 +32,11 @@ const SYSTEMCTL_DENY = new Set([
36
32
  ]);
37
33
  const SERVICE_DENY = new Set(["stop", "restart", "force-stop", "force-reload"]);
38
34
 
35
+ const DESTRUCTIVE_CMDS = new Set([
36
+ "rm", "chmod", "chown", "kill", "pkill", "systemctl", "service",
37
+ "git", "dd", "mkfs", "tee", "install", "mv", "cp", "sed",
38
+ ]);
39
+
39
40
  export function basename(path) {
40
41
  const parts = String(path).replace(/\\/g, "/").split("/");
41
42
  return parts[parts.length - 1] || "";
@@ -47,23 +48,11 @@ export function isProtectedPath(path, env = process.env) {
47
48
  }
48
49
 
49
50
  function wordsOf(seg) {
50
- return seg.filter(function (t) { return t.kind === "word"; }).map(function (t) { return t.value; });
51
+ return seg.filter((t) => t.kind === "word").map((t) => t.value);
51
52
  }
52
53
 
53
54
  function wordTokensOf(seg) {
54
- return seg.filter(function (t) { return t.kind === "word"; });
55
- }
56
-
57
- function redirectsOf(seg) {
58
- const paths = [];
59
- for (let i = 0; i < seg.length; i += 1) {
60
- const t = seg[i];
61
- if (t.kind === "op" && REDIR_OPS.has(t.value)) {
62
- const next = seg[i + 1];
63
- if (next && next.kind === "word") paths.push({ path: next.value, dynamic: Boolean(next.dynamic), operator: t.value });
64
- }
65
- }
66
- return paths;
55
+ return seg.filter((t) => t.kind === "word");
67
56
  }
68
57
 
69
58
  function splitSegments(tokens) {
@@ -82,7 +71,7 @@ function splitSegments(tokens) {
82
71
  }
83
72
 
84
73
  function stripPrefixes(argv) {
85
- const out = argv.filter(function (a) { return a !== "}" && a !== ";"; });
74
+ const out = argv.filter((a) => a !== "}" && a !== ";");
86
75
  while (out.length) {
87
76
  const head = out[0];
88
77
  if (PREFIXES.has(head)) { out.shift(); continue; }
@@ -106,7 +95,7 @@ function hasLetterFlag(argv, letter) {
106
95
  }
107
96
 
108
97
  function deny(reason, snippet) {
109
- return { deny: true, ask: false, reason: reason, detail: reason, snippet: String(snippet || reason).slice(0, 160) };
98
+ return { deny: true, ask: false, reason, detail: reason, snippet: String(snippet || reason).slice(0, 160) };
110
99
  }
111
100
 
112
101
  function allow() {
@@ -114,7 +103,7 @@ function allow() {
114
103
  }
115
104
 
116
105
  function ask(reason, detail, snippet) {
117
- return { deny: false, ask: true, reason: reason, detail: detail || reason, snippet: String(snippet || "").slice(0, 160) };
106
+ return { deny: false, ask: true, reason, detail: detail || reason, snippet: String(snippet || "").slice(0, 160) };
118
107
  }
119
108
 
120
109
  function scriptAfterDashC(rest) {
@@ -137,28 +126,6 @@ function skipWrapper(rest, arity) {
137
126
  return rest.slice(i);
138
127
  }
139
128
 
140
- function gitOperation(rest) {
141
- const takesValue = new Set(["-C", "-c", "--git-dir", "--work-tree", "--namespace"]);
142
- let i = 0;
143
- while (i < rest.length && rest[i].startsWith("-")) {
144
- const flag = rest[i];
145
- if (flag === "--") { i += 1; break; }
146
- if (takesValue.has(flag)) i += 2;
147
- else if (flag.startsWith("--") && flag.includes("=")) i += 1;
148
- else if (flag.length > 2 && flag[1] !== "-") i += 1;
149
- else i += 1;
150
- }
151
- return { verb: rest[i], args: rest.slice(i + 1) };
152
- }
153
-
154
- function isDevicePath(path) {
155
- if (typeof path !== "string" || !path.startsWith("/dev/")) return false;
156
- const device = path.slice(5);
157
- if (["null", "zero", "full", "random", "urandom", "stdin", "stdout", "stderr", "tty"].includes(device)) return false;
158
- if (/^(?:sd[a-z]+|hd[a-z]+|vd[a-z]+|xvd[a-z]+|nvme\d+n\d+(?:p\d+)?|mmcblk\d+(?:p\d+)?|loop\d+)$/i.test(device)) return true;
159
- return device.startsWith("mapper/") || device.startsWith("disk/by-id/") || device.startsWith("disk/by-uuid/");
160
- }
161
-
162
129
  function inspectInterpreterPayload(cmd, payload) {
163
130
  const code = String(payload || "");
164
131
  const executesProcess = /(?:\bos\.system\s*\(|\bsubprocess\.(?:run|Popen|call|check_call|check_output)\s*\(|\bchild_process\.(?:exec|execSync|spawn|spawnSync)\s*\(|\b(?:exec|spawn)\s*\()/i.test(code);
@@ -187,7 +154,7 @@ function inspectArgv(argv, depth, hasHeredoc, config = {}) {
187
154
  }
188
155
 
189
156
  if (cmd === "env") {
190
- const sub = rest.filter(a => !a.startsWith("-") && !a.includes("="));
157
+ const sub = rest.filter((a) => !a.startsWith("-") && !a.includes("="));
191
158
  if (sub.length > 0) {
192
159
  return inspectArgv(sub, depth, hasHeredoc, config);
193
160
  }
@@ -203,30 +170,38 @@ function inspectArgv(argv, depth, hasHeredoc, config = {}) {
203
170
 
204
171
  if (cmd === "eval") return deny("eval", argv.join(" "));
205
172
  if (KILL_CMDS.has(cmd)) return deny("process-signal", argv.join(" "));
206
- if (cmd === "rm" && (hasLetterFlag(argv, "r") || hasLetterFlag(argv, "R") || rest.indexOf("--recursive") !== -1)) {
173
+ if (cmd === "rm" && (hasLetterFlag(argv, "r") || hasLetterFlag(argv, "R") || rest.includes("--recursive"))) {
207
174
  return deny("recursive-rm", argv.join(" "));
208
175
  }
209
176
  if (cmd === "systemctl") {
210
- const verb = rest.find(function (a) { return a[0] !== "-"; });
177
+ const verb = rest.find((a) => a[0] !== "-");
211
178
  if (verb && SYSTEMCTL_DENY.has(verb)) return deny("service-control", argv.join(" "));
212
179
  }
213
180
  if (cmd === "service") {
214
181
  const verb = rest.length >= 2 ? rest[1] : "";
215
182
  if (SERVICE_DENY.has(verb)) return deny("service-control", argv.join(" "));
216
183
  }
217
- if (cmd === "curl" && rest.some(function (a) {
218
- return a.includes(DYNAMIC_MARKER) && /\b(?:authorization|proxy-authorization)\s*:\s*(?:bearer|token)\s+/i.test(a);
219
- })) {
220
- return ask("credential-in-argv", "expanded authorization values become curl process arguments", argv.join(" "));
184
+
185
+ // Network commands & loopback bypass (#40)
186
+ if (cmd === "curl" || cmd === "wget") {
187
+ const loopback = isLoopbackCommand(argv);
188
+ if (!loopback.isLoopback && rest.some((a) => a.includes(DYNAMIC_MARKER) && /\b(?:authorization|proxy-authorization)\s*:\s*(?:bearer|token)\s+/i.test(a))) {
189
+ return ask("credential-in-argv", "expanded authorization values become curl process arguments", argv.join(" "));
190
+ }
221
191
  }
192
+
193
+ // Git inspection via specialized parser (#125, #103)
222
194
  if (cmd === "git") {
223
- const operation = gitOperation(rest);
224
- if (operation.verb === "clean" && operation.args.some(function (a) {
225
- return a === "-f" || a === "--force" || (a[0] === "-" && a[1] !== "-" && a.indexOf("f") !== -1);
226
- })) return deny("git-clean", argv.join(" "));
227
- if (operation.verb === "reset" && operation.args.indexOf("--hard") !== -1) return deny("git-reset-hard", argv.join(" "));
195
+ const git = inspectGitCommand(argv);
196
+ if (git.isDestructive) {
197
+ return deny(git.reason, argv.join(" "));
198
+ }
199
+ if (git.verb === "push") {
200
+ return ask("git-remote-push", git.detail || "Publishing commits to a remote repository via git push requires confirmation", argv.join(" "));
201
+ }
228
202
  }
229
- if (cmd === "find" && rest.indexOf("-delete") !== -1) return deny("find-delete", argv.join(" "));
203
+
204
+ if (cmd === "find" && rest.includes("-delete")) return deny("find-delete", argv.join(" "));
230
205
  if (DB_CLIENTS.has(cmd) && DESTRUCTIVE_SQL.test(rest.join(" "))) {
231
206
  return deny("destructive-sql", argv.join(" "));
232
207
  }
@@ -236,95 +211,52 @@ function inspectArgv(argv, depth, hasHeredoc, config = {}) {
236
211
  if (a.startsWith("of=") && (isDevicePath(a.slice(3)) || isProtectedPath(a.slice(3)))) return deny("device-write", a);
237
212
  }
238
213
  }
214
+
215
+ // File mutations & chmod/chown protection (#65)
239
216
  if (cmd === "tee" || cmd === "install" || cmd === "cp" || cmd === "mv" || cmd === "truncate" || cmd === "chmod" || cmd === "chown") {
240
217
  for (const a of rest) {
241
218
  if (a[0] !== "-" && isProtectedPath(a)) return deny("secret-write", argv.join(" "));
242
219
  }
243
- if ((cmd === "chmod" || cmd === "chown") && (hasLetterFlag(argv, "R") || hasLetterFlag(argv, "r") || rest.indexOf("--recursive") !== -1)) {
244
- for (const a of rest) {
245
- if (a === "/" || a === "/etc" || a === "/var" || a === "/usr" || a === "/root" || a === "/boot") {
246
- return deny("device-write", argv.join(" "));
220
+ if (cmd === "chmod" || cmd === "chown") {
221
+ const isRecursive = hasLetterFlag(argv, "R") || hasLetterFlag(argv, "r") || rest.includes("--recursive");
222
+ if (isRecursive) {
223
+ for (const a of rest) {
224
+ if (a === "/" || a === "/etc" || a === "/var" || a === "/usr" || a === "/root" || a === "/boot") {
225
+ return deny("device-write", argv.join(" "));
226
+ }
247
227
  }
228
+ return deny("chmod-chown", argv.join(" "));
248
229
  }
249
230
  }
250
231
  }
251
- if (cmd === "sed" && (hasLetterFlag(argv, "i") || rest.some(function (a) { return a === "--in-place" || a.startsWith("--in-place="); }))) {
232
+
233
+ if (cmd === "sed" && (hasLetterFlag(argv, "i") || rest.some((a) => a === "--in-place" || a.startsWith("--in-place=")))) {
252
234
  for (const a of rest) {
253
235
  if (a[0] !== "-" && isProtectedPath(a)) return deny("secret-write", argv.join(" "));
254
236
  }
255
237
  }
238
+
256
239
  if (SHELLS.has(cmd) || cmd === "source" || cmd === ".") {
257
240
  const script = scriptAfterDashC(rest);
258
241
  if (typeof script === "string") {
259
242
  if (depth > 4) return deny("nested-shell", script);
260
243
  return inspectBashCommand(script, depth + 1, config);
261
244
  }
262
- const scriptFile = rest.find(function (a) { return a && a[0] !== "-"; });
245
+ const scriptFile = rest.find((a) => a && a[0] !== "-");
263
246
  if (scriptFile) return ask("script-file", "shell script contents are not available to the guard", scriptFile);
264
247
  if (!hasHeredoc) return ask("shell-stdin", "shell input is not fully available to the guard", argv.join(" "));
265
248
  }
266
- if (INTERPRETERS.has(cmd) && rest.some(function (a) { return a === "-c" || a === "-e"; })) {
267
- const idx = rest.findIndex(function (a) { return a === "-c" || a === "-e"; });
249
+
250
+ if (INTERPRETERS.has(cmd) && rest.some((a) => a === "-c" || a === "-e")) {
251
+ const idx = rest.findIndex((a) => a === "-c" || a === "-e");
268
252
  return inspectInterpreterPayload(cmd, rest[idx + 1] || "");
269
253
  }
254
+
270
255
  if (/\.sh$/i.test(cmd) || (cmd.includes("/") && /\.(?:bash|zsh|ksh)$/i.test(cmd))) {
271
256
  return ask("script-file", "script contents are not available to the guard", argv[0]);
272
257
  }
273
- return allow();
274
- }
275
258
 
276
- function hasDangerousPipeline(tokens) {
277
- let segment = [];
278
- let pipelineHasDownloader = false;
279
- let inPipeline = false;
280
- function inspectSegment() {
281
- return commandNameFromSegment(segment);
282
- }
283
- for (const token of tokens) {
284
- if (token.kind === "op" && SPLIT_OPS.has(token.value)) {
285
- const cmd = inspectSegment();
286
- if (inPipeline && (SHELLS.has(cmd) || INTERPRETERS.has(cmd)) && pipelineHasDownloader) return true;
287
- const continues = token.value === "|" || token.value === "|&";
288
- if (continues) {
289
- pipelineHasDownloader = pipelineHasDownloader || cmd === "curl" || cmd === "wget";
290
- inPipeline = true;
291
- } else {
292
- pipelineHasDownloader = false;
293
- inPipeline = false;
294
- }
295
- segment = [];
296
- continue;
297
- }
298
- segment.push(token);
299
- }
300
- const cmd = inspectSegment();
301
- return inPipeline && pipelineHasDownloader && (SHELLS.has(cmd) || INTERPRETERS.has(cmd));
302
- }
303
-
304
- function hasDestructiveSqlPipeline(tokens) {
305
- let pipelineHasDestructiveSql = false;
306
- let inPipeline = false;
307
- let segment = [];
308
- for (const token of tokens) {
309
- if (token.kind === "op" && SPLIT_OPS.has(token.value)) {
310
- const cmd = commandNameFromSegment(segment);
311
- if (inPipeline && DB_CLIENTS.has(cmd) && pipelineHasDestructiveSql) return true;
312
- const continues = token.value === "|" || token.value === "|&";
313
- if (continues) {
314
- const segText = wordsOf(segment).join(" ");
315
- pipelineHasDestructiveSql = pipelineHasDestructiveSql || DESTRUCTIVE_SQL.test(segText);
316
- inPipeline = true;
317
- } else {
318
- pipelineHasDestructiveSql = false;
319
- inPipeline = false;
320
- }
321
- segment = [];
322
- continue;
323
- }
324
- segment.push(token);
325
- }
326
- const cmd = commandNameFromSegment(segment);
327
- return inPipeline && pipelineHasDestructiveSql && DB_CLIENTS.has(cmd);
259
+ return allow();
328
260
  }
329
261
 
330
262
  function executableTokenOf(seg) {
@@ -392,10 +324,10 @@ function inspectPipelineHeredocs(tokens, depth, config = {}) {
392
324
  let segment = [];
393
325
  function finishGroup() {
394
326
  if (segment.length) group.push(segment);
395
- const hasShell = group.some(function (part) { return SHELLS.has(commandNameFromSegment(part)); });
327
+ const hasShell = group.some((part) => SHELLS.has(commandNameFromSegment(part)));
396
328
  if (hasShell) {
397
329
  for (const part of group) {
398
- for (const heredoc of part.filter(function (t) { return t.kind === "heredoc"; })) {
330
+ for (const heredoc of part.filter((t) => t.kind === "heredoc")) {
399
331
  const nested = inspectBashCommand(heredoc.body, depth + 1, config);
400
332
  if (nested.deny || nested.ask) return nested;
401
333
  }
@@ -446,17 +378,27 @@ export function inspectBashCommand(command, depth = 0, config = {}) {
446
378
  if (depth > 8) return ask("unparseable", "nesting-depth", String(command || ""));
447
379
  if (typeof command !== "string") return ask("unknown-format", "command is not a string", "non-string command");
448
380
  if (command.trim() === "") return allow();
381
+
449
382
  const tok = tokenize(command);
450
383
  const tokens = tok.tokens || [];
384
+
451
385
  if (!tok.ok) {
452
386
  const knownDanger = inspectUnparsedDanger(command);
453
387
  if (knownDanger) return knownDanger;
454
388
  }
455
- if (hasDangerousPipeline(tokens)) return deny("curl-pipe", command);
456
- if (hasDestructiveSqlPipeline(tokens)) return deny("destructive-sql", command);
389
+
390
+ // Pipeline inspection (#126)
391
+ const pipeCheck = inspectPipelines(tokens);
392
+ if (pipeCheck.dangerousPipeline) {
393
+ return deny(pipeCheck.reason, command);
394
+ }
395
+
457
396
  const pipelineHeredocHit = inspectPipelineHeredocs(tokens, depth, config);
458
397
  if (pipelineHeredocHit.deny || pipelineHeredocHit.ask) return pipelineHeredocHit;
459
398
 
399
+ // Silent error guard check (#90)
400
+ const silentHit = inspectSilentErrors(command);
401
+
460
402
  const segs = splitSegments(tokens);
461
403
  for (const seg of segs) {
462
404
  const rawWords = wordsOf(seg);
@@ -465,11 +407,16 @@ export function inspectBashCommand(command, depth = 0, config = {}) {
465
407
  return deny("env-dump-leak", envHit.detail || command);
466
408
  }
467
409
  const wordTokens = wordTokensOf(seg);
468
- const heredocs = seg.filter(function (t) { return t.kind === "heredoc"; });
410
+ const heredocs = seg.filter((t) => t.kind === "heredoc");
469
411
  const argv = stripPrefixes(wordsOf(seg));
470
412
  const commandToken = executableTokenOf(seg);
471
413
  const commandName = commandNameFromSegment(seg);
472
414
 
415
+ // Silent error guard triggered on mutating system commands (#90)
416
+ if (silentHit.masked && (config.silentErrorGuard || DESTRUCTIVE_CMDS.has(commandName))) {
417
+ return ask("silent-error-masked", silentHit.detail, command);
418
+ }
419
+
473
420
  for (const token of wordTokens) {
474
421
  for (const substitution of token.substitutions || []) {
475
422
  const nested = substitution.kind === "parameter-expansion"
@@ -479,25 +426,23 @@ export function inspectBashCommand(command, depth = 0, config = {}) {
479
426
  }
480
427
  }
481
428
 
482
- for (const redirect of redirectsOf(seg)) {
483
- if (redirect.dynamic) return ask("dynamic-redirect", "expanded redirect target cannot be verified", redirect.path);
484
- const baseDir = (config && config.workspaceDir) || process.cwd();
485
- const norm = normalizeSeparators(redirect.path);
486
- const isRel = !/^(?:\/|[A-Za-z]:\/|\/\/)/.test(norm);
487
- const isExempt = !isRel && (/^\/dev\/(?:null|zero|stdout|stderr|fd\/\d+)$/.test(norm) || /^(?:\/tmp|\/var\/tmp)(?:\/|$)/i.test(norm));
488
- if (!isExempt && isPathTraversal(redirect.path, baseDir)) {
489
- return deny("path-traversal", redirect.path);
490
- }
491
- if (isProtectedPath(redirect.path)) return deny("secret-write", redirect.path);
429
+ // Redirects security inspection (#126)
430
+ const redirects = extractRedirects(seg);
431
+ const redirectHit = inspectRedirectSecurity(redirects, {
432
+ workspaceDir: config.workspaceDir,
433
+ env: config.env,
434
+ });
435
+ if (redirectHit) {
436
+ return redirectHit.deny ? deny(redirectHit.reason, redirectHit.path) : ask(redirectHit.reason, redirectHit.detail, redirectHit.path);
492
437
  }
493
438
 
494
439
  if (commandToken && commandToken.dynamic && !commandToken.value.includes("=")) {
495
440
  return ask("dynamic-command", "expanded command name cannot be verified", commandToken.value);
496
441
  }
497
442
 
498
- const dynamicArgs = wordTokens.some(function (t) {
499
- return t.dynamic && (!commandToken || t !== commandToken) && !t.value.includes("=");
500
- });
443
+ const dynamicArgs = wordTokens.some((t) => (
444
+ t.dynamic && (!commandToken || t !== commandToken) && !t.value.includes("=")
445
+ ));
501
446
  if (dynamicArgs && ["rm", "find", "git", "systemctl", "service", "tee", "install", "cp", "mv", "truncate", "sed", "dd"].includes(commandName)) {
502
447
  return ask("dynamic-arguments", "expanded command arguments cannot be verified", commandName);
503
448
  }
@@ -536,16 +481,6 @@ export function inspectBashCommand(command, depth = 0, config = {}) {
536
481
  return allow();
537
482
  }
538
483
 
539
- function pathFromArgs(args) {
540
- if (typeof args === "string") return args;
541
- if (!args || typeof args !== "object") return null;
542
- const keys = ["path", "file_path", "filePath", "file", "filename", "target", "to"];
543
- for (const k of keys) {
544
- if (typeof args[k] === "string") return args[k];
545
- }
546
- return null;
547
- }
548
-
549
484
  export function inspectExecution(execution, config) {
550
485
  if (!config) config = {};
551
486
  const toolName = config.toolName || DEFAULT_BASH_TOOL;
@@ -553,14 +488,22 @@ export function inspectExecution(execution, config) {
553
488
  const name = execution && execution.name;
554
489
  const args = execution ? execution.arguments : undefined;
555
490
 
491
+ // Reviewer agent read-only mode isolation (#65)
492
+ if (isReviewerAgent(execution, config)) {
493
+ if (name === toolName || fileTools.has(name)) {
494
+ return deny("reviewer-read-only", `Reviewer agent is strictly isolated in read-only mode; tool ${name} is prohibited`);
495
+ }
496
+ }
497
+
556
498
  if (name === toolName) {
557
499
  const command = args && typeof args === "object" ? args.command : undefined;
558
500
  if (command === undefined || command === null || command === "") return allow();
559
501
  if (typeof command !== "string") return ask("unknown-format", "command is not a string", "non-string command");
560
502
  return inspectBashCommand(command, 0, config);
561
503
  }
504
+
562
505
  if (typeof name === "string" && fileTools.has(name)) {
563
- const path = pathFromArgs(args);
506
+ const path = extractFilePath(args);
564
507
  if (!path) return ask("unknown-write-target", "file write target could not be identified", name);
565
508
  if (config.workspaceDir) {
566
509
  const safeCheck = resolveSafePath(path, config.workspaceDir);
@@ -573,6 +516,6 @@ export function inspectExecution(execution, config) {
573
516
  if (isProtectedPath(path)) return deny("secret-write", path);
574
517
  }
575
518
  }
576
- return allow();
577
- }
578
519
 
520
+ return allow();
521
+ }
package/lib/ledger.js ADDED
@@ -0,0 +1,164 @@
1
+ // lib/ledger.js
2
+ // Quality and Anomaly Ledger for dsh-approval-gate (#88).
3
+ // Tracks session health, consecutive denials, and execution anomalies.
4
+
5
+ export const MAX_LEDGER_EVENTS_PER_SESSION = 50;
6
+
7
+ /**
8
+ * @typedef {Object} LedgerEvent
9
+ * @property {number} timestamp
10
+ * @property {string} type - "allow" | "deny" | "ask" | "exhaustion" | "breaker" | "error"
11
+ * @property {string} reason
12
+ * @property {string} [detail]
13
+ * @property {string} [tool]
14
+ */
15
+
16
+ /**
17
+ * @typedef {Object} SessionLedger
18
+ * @property {string} sessionId
19
+ * @property {number} totalDecisions
20
+ * @property {number} allowedCount
21
+ * @property {number} deniedCount
22
+ * @property {number} askCount
23
+ * @property {number} anomalyCount
24
+ * @property {number} consecutiveDenies
25
+ * @property {boolean} breakerTripped
26
+ * @property {boolean} retryExhausted
27
+ * @property {LedgerEvent[]} recentEvents
28
+ */
29
+
30
+ export class QualityLedger {
31
+ constructor() {
32
+ /** @type {Map<string, SessionLedger>} */
33
+ this.sessions = new Map();
34
+ }
35
+
36
+ /**
37
+ * @private
38
+ */
39
+ _getOrCreate(sessionId) {
40
+ const key = sessionId || "default";
41
+ let ledger = this.sessions.get(key);
42
+ if (!ledger) {
43
+ ledger = {
44
+ sessionId: key,
45
+ totalDecisions: 0,
46
+ allowedCount: 0,
47
+ deniedCount: 0,
48
+ askCount: 0,
49
+ anomalyCount: 0,
50
+ consecutiveDenies: 0,
51
+ breakerTripped: false,
52
+ retryExhausted: false,
53
+ recentEvents: [],
54
+ };
55
+ this.sessions.set(key, ledger);
56
+ }
57
+ return ledger;
58
+ }
59
+
60
+ /**
61
+ * Record a decision or system event in the ledger.
62
+ *
63
+ * @param {string} sessionId
64
+ * @param {string} type - "allow" | "deny" | "ask" | "exhaustion" | "breaker" | "error"
65
+ * @param {{ reason?: string, detail?: string, tool?: string }} [details]
66
+ */
67
+ record(sessionId, type, details = {}) {
68
+ const ledger = this._getOrCreate(sessionId);
69
+ const now = Date.now();
70
+
71
+ const event = {
72
+ timestamp: now,
73
+ type,
74
+ reason: details.reason || "",
75
+ detail: details.detail ? String(details.detail).slice(0, 200) : undefined,
76
+ tool: details.tool || "",
77
+ };
78
+
79
+ ledger.recentEvents.push(event);
80
+ if (ledger.recentEvents.length > MAX_LEDGER_EVENTS_PER_SESSION) {
81
+ ledger.recentEvents.shift();
82
+ }
83
+
84
+ if (type === "allow") {
85
+ ledger.totalDecisions += 1;
86
+ ledger.allowedCount += 1;
87
+ ledger.consecutiveDenies = 0;
88
+ } else if (type === "deny") {
89
+ ledger.totalDecisions += 1;
90
+ ledger.deniedCount += 1;
91
+ ledger.consecutiveDenies += 1;
92
+ if (ledger.consecutiveDenies > 1) {
93
+ ledger.anomalyCount += 1;
94
+ }
95
+ } else if (type === "ask") {
96
+ ledger.totalDecisions += 1;
97
+ ledger.askCount += 1;
98
+ } else if (type === "exhaustion") {
99
+ ledger.retryExhausted = true;
100
+ ledger.anomalyCount += 1;
101
+ } else if (type === "breaker") {
102
+ ledger.breakerTripped = true;
103
+ ledger.anomalyCount += 1;
104
+ } else if (type === "error") {
105
+ ledger.anomalyCount += 1;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Returns a quality summary for the session.
111
+ *
112
+ * @param {string} sessionId
113
+ * @returns {{
114
+ * sessionId: string,
115
+ * totalDecisions: number,
116
+ * allowedCount: number,
117
+ * deniedCount: number,
118
+ * askCount: number,
119
+ * anomalyCount: number,
120
+ * consecutiveDenies: number,
121
+ * qualityScore: number,
122
+ * degradationWarning: boolean,
123
+ * recentEvents: LedgerEvent[]
124
+ * }}
125
+ */
126
+ getSummary(sessionId) {
127
+ const ledger = this._getOrCreate(sessionId);
128
+ const total = ledger.totalDecisions;
129
+
130
+ // Quality score: 1.0 down towards 0.0 based on denial & anomaly ratio
131
+ let score = 1.0;
132
+ if (total > 0) {
133
+ const penalty = (ledger.deniedCount * 0.2 + ledger.anomalyCount * 0.3) / total;
134
+ score = Math.max(0, Math.min(1.0, 1.0 - penalty));
135
+ }
136
+
137
+ const degradationWarning = ledger.consecutiveDenies >= 2 ||
138
+ ledger.retryExhausted ||
139
+ ledger.breakerTripped ||
140
+ (total >= 3 && ledger.deniedCount / total >= 0.5);
141
+
142
+ return {
143
+ sessionId: ledger.sessionId,
144
+ totalDecisions: ledger.totalDecisions,
145
+ allowedCount: ledger.allowedCount,
146
+ deniedCount: ledger.deniedCount,
147
+ askCount: ledger.askCount,
148
+ anomalyCount: ledger.anomalyCount,
149
+ consecutiveDenies: ledger.consecutiveDenies,
150
+ qualityScore: Number(score.toFixed(2)),
151
+ degradationWarning,
152
+ recentEvents: ledger.recentEvents.map((e) => ({ ...e })),
153
+ };
154
+ }
155
+
156
+ reset(sessionId) {
157
+ const key = sessionId || "default";
158
+ this.sessions.delete(key);
159
+ }
160
+
161
+ clear() {
162
+ this.sessions.clear();
163
+ }
164
+ }