@goodandready/dsh-approval-gate 0.1.12 → 0.1.13

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
+ }
@@ -0,0 +1,85 @@
1
+ // lib/loopback.js
2
+ // Safe loopback bypass helper for 127.0.0.1 and localhost dev servers.
3
+ // Resolves: #40
4
+
5
+ /**
6
+ * Checks whether a hostname or IP string represents a local loopback interface.
7
+ *
8
+ * @param {string} host
9
+ * @returns {boolean}
10
+ */
11
+ export function isLoopbackHost(host) {
12
+ if (typeof host !== "string" || !host.trim()) return false;
13
+ const h = host.trim().toLowerCase().replace(/^\[|\]$/g, "");
14
+
15
+ if (h === "localhost" || h.endsWith(".localhost")) return true;
16
+ if (h === "127.0.0.1" || h === "0.0.0.0" || h === "::1" || h === "0:0:0:0:0:0:0:1") return true;
17
+
18
+ // 127.0.0.0/8 IPv4 loopback range
19
+ if (/^127(?:\.(?:25[0-5]|2[0-4]\d|[01]?\d\d?)){3}$/.test(h)) return true;
20
+
21
+ // IPv6 loopback variants (::ffff:127.x.x.x)
22
+ if (/^::ffff:127(?:\.(?:25[0-5]|2[0-4]\d|[01]?\d\d?)){3}$/i.test(h)) return true;
23
+
24
+ return false;
25
+ }
26
+
27
+ /**
28
+ * Checks whether a URL points to a loopback address.
29
+ *
30
+ * @param {string} targetUrl
31
+ * @returns {boolean}
32
+ */
33
+ export function isLoopbackUrl(targetUrl) {
34
+ if (typeof targetUrl !== "string" || !targetUrl.trim()) return false;
35
+ try {
36
+ const parsed = new URL(targetUrl.includes("://") ? targetUrl : `http://${targetUrl}`);
37
+ return isLoopbackHost(parsed.hostname);
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Checks whether an HTTP/network tool execution or command targets a loopback endpoint.
45
+ *
46
+ * @param {string[]} argv - Tokens of curl/wget or similar command
47
+ * @returns {{ isLoopback: boolean, host?: string, port?: string }}
48
+ */
49
+ export function isLoopbackCommand(argv) {
50
+ if (!Array.isArray(argv) || argv.length === 0) return { isLoopback: false };
51
+ const cmd = argv[0].replace(/\\/g, "/").split("/").pop();
52
+
53
+ if (cmd !== "curl" && cmd !== "wget" && cmd !== "nc" && cmd !== "socat") {
54
+ return { isLoopback: false };
55
+ }
56
+
57
+ const urls = [];
58
+ for (let i = 1; i < argv.length; i += 1) {
59
+ const a = argv[i];
60
+ if (a.startsWith("-")) {
61
+ // Skip flags and their values if common
62
+ if ((a === "-u" || a === "-H" || a === "-d" || a === "--data" || a === "-o" || a === "-O") && i + 1 < argv.length) {
63
+ i += 1;
64
+ }
65
+ continue;
66
+ }
67
+ if (a.includes("localhost") || a.includes("127.0.0.1") || a.includes("::1") || a.includes("0.0.0.0") || a.startsWith("http://") || a.startsWith("https://")) {
68
+ urls.push(a);
69
+ }
70
+ }
71
+
72
+ if (urls.length === 0) return { isLoopback: false };
73
+
74
+ const allLoopback = urls.every((u) => isLoopbackUrl(u));
75
+ if (allLoopback) {
76
+ try {
77
+ const parsed = new URL(urls[0].includes("://") ? urls[0] : `http://${urls[0]}`);
78
+ return { isLoopback: true, host: parsed.hostname, port: parsed.port };
79
+ } catch {
80
+ return { isLoopback: true };
81
+ }
82
+ }
83
+
84
+ return { isLoopback: false };
85
+ }
package/lib/messages.js CHANGED
@@ -32,12 +32,18 @@ export const MESSAGES = Object.freeze({
32
32
  askShellStdin: "Shell input is not fully available to the guard; review it before allowing",
33
33
  askInterpreterPayload: "Interpreter side effects are not fully analyzed; review it before allowing",
34
34
  askCredentialInArgv: "Expanded authorization values become process arguments; use a credential-safe API helper or review before allowing",
35
+ askGitRemotePush: "Publishing commits to a remote repository via git push requires confirmation",
36
+ askSilentErrorMasked: "The command suppresses errors or masks non-zero exit codes; review before allowing",
35
37
  ruleUnknown: "A blocking safety rule matched",
36
38
  ruleRecursiveRm: "Recursive file deletion",
37
39
  ruleProcessSignal: "Process termination",
38
40
  ruleServiceControl: "Service state change",
39
41
  ruleGitClean: "Destructive git clean",
40
42
  ruleGitResetHard: "Destructive git reset --hard",
43
+ ruleGitForcePush: "Forceful git push",
44
+ ruleGitBranchForceDelete: "Forceful git branch deletion",
45
+ ruleGitCheckoutForce: "Forceful checkout discarding local modifications",
46
+ ruleGitRestoreWorktree: "Restoring working tree files discarding modifications",
41
47
  ruleFindDelete: "find -delete",
42
48
  ruleDestructiveSql: "Destructive SQL",
43
49
  ruleSecretWrite: "Write to a protected secret file",
@@ -51,6 +57,9 @@ export const MESSAGES = Object.freeze({
51
57
  rulePathTraversal: "Directory traversal attack detected",
52
58
  ruleBandHardDeny: "Command matched Band 0 critical security blocklist",
53
59
  ruleCircuitBreakerTripped: "Circuit breaker tripped due to repeated security denials",
60
+ ruleSilentErrorMasked: "Suppression of command failure or error stream",
61
+ ruleChmodChown: "Permission or ownership mutation",
62
+ ruleReviewerReadOnly: "Reviewer agent is strictly isolated in read-only mode",
54
63
  hintRecursiveRm: "Specify exact target files instead of recursive deletion",
55
64
  hintPathTraversal: "Keep operations strictly within the project directory",
56
65
  hintSecretWrite: "Modify configuration via settings or environment variables, not raw file overwrite",
@@ -58,6 +67,11 @@ export const MESSAGES = Object.freeze({
58
67
  hintCircuitBreakerTripped: "Request manual operator approval before attempting new system commands",
59
68
  hintDestructiveSql: "Use migrations or execute non-destructive database operations",
60
69
  hintCurlPipe: "Download the script, inspect its content, and execute locally",
70
+ hintGitForcePush: "Push branch without force or check with remote administrator",
71
+ hintGitRemotePush: "Confirm remote target branch and repository before pushing",
72
+ hintSilentErrorMasked: "Allow standard error output to be captured and handle non-zero exit codes explicitly",
73
+ hintChmodChown: "Avoid modifying file permissions or system directory ownership directly",
74
+ hintReviewerReadOnly: "Reviewer agents may only read repository files, not run commands or modify files",
61
75
  },
62
76
  zh: {
63
77
  blockedPrefix: "dsh-approval-gate 规则已阻止",
@@ -89,12 +103,18 @@ export const MESSAGES = Object.freeze({
89
103
  askShellStdin: "卫兵无法完整获取 Shell 输入;请在允许前进行审查",
90
104
  askInterpreterPayload: "解释器副作用未完全分析;请在允许前进行审查",
91
105
  askCredentialInArgv: "展开后的授权凭据作为进程参数传递;请使用凭据安全的 API 辅助工具,或在允许前进行审查",
106
+ askGitRemotePush: "通过 git push 向远程仓库发布代码需要确认",
107
+ askSilentErrorMasked: "命令屏蔽了错误输出或强制将退出码设为 0;请在允许前审查",
92
108
  ruleUnknown: "命中阻止性安全规则",
93
109
  ruleRecursiveRm: "递归删除文件",
94
110
  ruleProcessSignal: "终止进程",
95
111
  ruleServiceControl: "更改服务状态",
96
112
  ruleGitClean: "破坏性 git clean",
97
113
  ruleGitResetHard: "破坏性 git reset --hard",
114
+ ruleGitForcePush: "强制 git push",
115
+ ruleGitBranchForceDelete: "强制删除 git 分支",
116
+ ruleGitCheckoutForce: "强制 checkout 丢弃本地修改",
117
+ ruleGitRestoreWorktree: "还原工作区文件并丢弃修改",
98
118
  ruleFindDelete: "find -delete",
99
119
  ruleDestructiveSql: "破坏性 SQL",
100
120
  ruleSecretWrite: "写入受保护的敏感文件",
@@ -108,6 +128,9 @@ export const MESSAGES = Object.freeze({
108
128
  rulePathTraversal: "检测到路径穿越攻击",
109
129
  ruleBandHardDeny: "命令匹配 Band 0 关键安全黑名单",
110
130
  ruleCircuitBreakerTripped: "因连续多次安全拒绝,断路熔断器已触发",
131
+ ruleSilentErrorMasked: "屏蔽错误输出或强制忽略退出码",
132
+ ruleChmodChown: "修改权限或所有权",
133
+ ruleReviewerReadOnly: "审查者代理处于只读隔离模式",
111
134
  hintRecursiveRm: "请指定具体目标文件,避免递归删除",
112
135
  hintPathTraversal: "请将操作限制在项目根目录内",
113
136
  hintSecretWrite: "请通过设置或环境变量修改配置,不要直接覆盖敏感配置文件",
@@ -115,6 +138,11 @@ export const MESSAGES = Object.freeze({
115
138
  hintCircuitBreakerTripped: "请在尝试新的系统命令前请求操作员人工授权",
116
139
  hintDestructiveSql: "请使用版本化数据库迁移,或执行非破坏性查询",
117
140
  hintCurlPipe: "先将脚本下载到本地检查内容,再安全执行",
141
+ hintGitForcePush: "请勿使用 force 参数推送分支,或与远程仓库管理员确认",
142
+ hintGitRemotePush: "推送前请核对目标分支与远程仓库地址",
143
+ hintSilentErrorMasked: "请保留标准错误输出,并明确处理非零退出码",
144
+ hintChmodChown: "避免直接修改系统目录所有权或文件权限",
145
+ hintReviewerReadOnly: "审查者代理仅可读取项目文件,禁止执行命令或修改文件",
118
146
  },
119
147
  });
120
148
 
@@ -154,6 +182,10 @@ const RULE_KEYS = Object.freeze({
154
182
  "service-control": "ruleServiceControl",
155
183
  "git-clean": "ruleGitClean",
156
184
  "git-reset-hard": "ruleGitResetHard",
185
+ "git-force-push": "ruleGitForcePush",
186
+ "git-branch-force-delete": "ruleGitBranchForceDelete",
187
+ "git-checkout-force": "ruleGitCheckoutForce",
188
+ "git-restore-worktree": "ruleGitRestoreWorktree",
157
189
  "find-delete": "ruleFindDelete",
158
190
  "destructive-sql": "ruleDestructiveSql",
159
191
  "secret-write": "ruleSecretWrite",
@@ -167,6 +199,9 @@ const RULE_KEYS = Object.freeze({
167
199
  "path-traversal": "rulePathTraversal",
168
200
  "band-hard-deny": "ruleBandHardDeny",
169
201
  "circuit-breaker-tripped": "ruleCircuitBreakerTripped",
202
+ "silent-error-masked": "ruleSilentErrorMasked",
203
+ "chmod-chown": "ruleChmodChown",
204
+ "reviewer-read-only": "ruleReviewerReadOnly",
170
205
  });
171
206
 
172
207
  const HINT_KEYS = Object.freeze({
@@ -177,6 +212,11 @@ const HINT_KEYS = Object.freeze({
177
212
  "circuit-breaker-tripped": "hintCircuitBreakerTripped",
178
213
  "destructive-sql": "hintDestructiveSql",
179
214
  "curl-pipe": "hintCurlPipe",
215
+ "git-force-push": "hintGitForcePush",
216
+ "git-remote-push": "hintGitRemotePush",
217
+ "silent-error-masked": "hintSilentErrorMasked",
218
+ "chmod-chown": "hintChmodChown",
219
+ "reviewer-read-only": "hintReviewerReadOnly",
180
220
  });
181
221
 
182
222
  const ASK_KEYS = Object.freeze({
@@ -190,6 +230,8 @@ const ASK_KEYS = Object.freeze({
190
230
  "shell-stdin": "askShellStdin",
191
231
  "interpreter-payload": "askInterpreterPayload",
192
232
  "credential-in-argv": "askCredentialInArgv",
233
+ "git-remote-push": "askGitRemotePush",
234
+ "silent-error-masked": "askSilentErrorMasked",
193
235
  });
194
236
 
195
237
  const DETAIL_KEYS = Object.freeze({
@@ -231,4 +273,4 @@ export function askMessage(hit, who, translate, logger) {
231
273
  if (snippet) parts.push(translated(translate, "commandLabel", logger) + ": " + snippet);
232
274
  if (who) parts.push(who.trim());
233
275
  return parts.join(" — ") + ".";
234
- }
276
+ }