@goodandready/dsh-approval-gate 0.1.3

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 ADDED
@@ -0,0 +1,918 @@
1
+ import { MESSAGES } from "./messages.js";
2
+
3
+ // Pure command/path inspector for dsh-approval-gate.
4
+ // No Cordis imports: unit tests run this without the harness.
5
+
6
+ export const DEFAULT_BASH_TOOL = "bash";
7
+ export const DEFAULT_FILE_WRITE_TOOLS = Object.freeze([
8
+ "write",
9
+ "edit",
10
+ "Write",
11
+ "Edit",
12
+ "str_replace",
13
+ "apply_patch",
14
+ ]);
15
+
16
+ const SPLIT_OPS = new Set(["|", "|&", "||", "&&", ";", "&"]);
17
+ const REDIR_OPS = new Set([">", ">>", ">|", "<>", "2>", "2>>", "&>", "&>>"]);
18
+ const HEREDOC_OPS = new Set(["<<", "<<-"]);
19
+ const DYNAMIC_MARKER = "__shell_dynamic__";
20
+ const PREFIXES = new Set(["then", "do"]);
21
+ const SHELLS = new Set(["bash", "sh", "dash", "zsh", "ksh", "ash"]);
22
+ const INTERPRETERS = new Set(["python", "python2", "python3", "node", "nodejs", "perl", "ruby", "php"]);
23
+ const DB_CLIENTS = new Set([
24
+ "sqlite3", "mysql", "psql", "pg_restore", "mongo", "mongosh", "redis-cli", "clickhouse-client",
25
+ ]);
26
+ const DESTRUCTIVE_SQL = /\b(?:ALTER|DROP|TRUNCATE|DELETE\s+FROM|CREATE\s+(?:TABLE|DATABASE|INDEX))\b/i;
27
+ const KILL_CMDS = new Set(["kill", "pkill", "killall"]);
28
+ const SYSTEMCTL_DENY = new Set([
29
+ "stop", "restart", "disable", "enable", "mask", "unmask",
30
+ "reboot", "halt", "poweroff", "shutdown", "kill",
31
+ ]);
32
+ const SERVICE_DENY = new Set(["stop", "restart", "force-stop", "force-reload"]);
33
+
34
+ export function basename(path) {
35
+ const parts = String(path).replace(/\\/g, "/").split("/");
36
+ return parts[parts.length - 1] || "";
37
+ }
38
+
39
+ export function isProtectedPath(path) {
40
+ if (typeof path !== "string" || path.trim() === "") return false;
41
+ const base = basename(path);
42
+ if (base === ".env" || base.startsWith(".env.")) return true;
43
+ if (/^credentials\.ya?ml$/i.test(base)) return true;
44
+ if (/^settings\.ya?ml$/i.test(base)) return true;
45
+ if (base === "cordis.patch.yml") return true;
46
+ if (/\.(pem|key|crt|p12|pfx)$/i.test(base)) return true;
47
+ if (/^(id_rsa|id_ed25519|id_ecdsa)$/.test(base)) return true;
48
+ if (/(?:^|[._-])(?:secret|token|credential|passwd|api[_-]?key)(?:$|[._-])/i.test(base)) return true;
49
+ return false;
50
+ }
51
+
52
+ function fail(reason, snippet) {
53
+ return { ok: false, reason: reason, snippet: snippet };
54
+ }
55
+
56
+ function opAt(s, i) {
57
+ const rest = s.slice(i);
58
+ if (rest.startsWith("<(") || rest.startsWith(">(")) return null;
59
+ const candidates = ["2>&", "2>>", "&>>", "<<<", "<<-", "2>", ">>", ">|", "|&", "&&", "||", "&>", ">&", "<&", "<>", "<<", ">", "<", "|", ";", "&"];
60
+ for (const op of candidates) {
61
+ if (rest.startsWith(op)) return op;
62
+ }
63
+ return null;
64
+ }
65
+
66
+ function readCommandSubstitution(src, start) {
67
+ let depth = 1;
68
+ let quote = "";
69
+ let escaped = false;
70
+ for (let i = start + 2; i < src.length; i += 1) {
71
+ const c = src[i];
72
+ if (escaped) { escaped = false; continue; }
73
+ if (c === "\\") { escaped = true; continue; }
74
+ if (quote) {
75
+ if (c === quote) quote = "";
76
+ continue;
77
+ }
78
+ if (c === "'" || c === '"') { quote = c; continue; }
79
+ if (c === String.fromCharCode(96)) {
80
+ const end = readBacktick(src, i);
81
+ if (!end) return null;
82
+ i = end.end;
83
+ continue;
84
+ }
85
+ if (src.startsWith("$(", i)) { depth += 1; i += 1; continue; }
86
+ if (c === "(") { depth += 1; continue; }
87
+ if (c === ")") {
88
+ depth -= 1;
89
+ if (depth === 0) return { script: src.slice(start + 2, i), end: i + 1 };
90
+ }
91
+ }
92
+ return null;
93
+ }
94
+
95
+ function readBacktick(src, start) {
96
+ let escaped = false;
97
+ for (let i = start + 1; i < src.length; i += 1) {
98
+ if (escaped) { escaped = false; continue; }
99
+ if (src[i] === "\\") { escaped = true; continue; }
100
+ if (src[i] === String.fromCharCode(96)) return { script: src.slice(start + 1, i), end: i + 1 };
101
+ }
102
+ return null;
103
+ }
104
+
105
+ function readParameterExpansion(src, start) {
106
+ if (src[start + 1] !== "{") {
107
+ let i = start + 1;
108
+ if (/[A-Za-z_]/.test(src[i] || "")) {
109
+ i += 1;
110
+ while (i < src.length && /[A-Za-z0-9_]/.test(src[i])) i += 1;
111
+ } else if (i < src.length) i += 1;
112
+ return { end: i };
113
+ }
114
+ let depth = 1;
115
+ let quote = "";
116
+ for (let i = start + 2; i < src.length; i += 1) {
117
+ const c = src[i];
118
+ if (c === "\\") { i += 1; continue; }
119
+ if (quote) {
120
+ if (c === quote) quote = "";
121
+ continue;
122
+ }
123
+ if (c === "'" || c === '"') { quote = c; continue; }
124
+ if (c === String.fromCharCode(96)) {
125
+ const nested = readBacktick(src, i);
126
+ if (!nested) return null;
127
+ i = nested.end - 1;
128
+ continue;
129
+ }
130
+ if (src.startsWith("$" + "(", i)) {
131
+ const nested = readCommandSubstitution(src, i);
132
+ if (!nested) return null;
133
+ i = nested.end - 1;
134
+ continue;
135
+ }
136
+ if (src.startsWith("$" + "{", i)) {
137
+ const nested = readParameterExpansion(src, i);
138
+ if (!nested) return null;
139
+ i = nested.end - 1;
140
+ continue;
141
+ }
142
+ if (c === "{") depth += 1;
143
+ if (c === "}") {
144
+ depth -= 1;
145
+ if (depth === 0) return { end: i + 1, body: src.slice(start + 2, i) };
146
+ }
147
+ }
148
+ return null;
149
+ }
150
+
151
+ function lexFail(src, index, reason, tokens) {
152
+ return { ok: false, reason: reason, snippet: src.slice(Math.max(0, index - 32), index + 96), tokens: tokens };
153
+ }
154
+
155
+ function readHeredocDelimiter(src, index) {
156
+ let i = index;
157
+ while (i < src.length && (src[i] === " " || src[i] === "\t")) i += 1;
158
+ if (i >= src.length || src[i] === "\n" || src[i] === "\r") return null;
159
+ let delimiter = "";
160
+ let quote = "";
161
+ let started = false;
162
+ let quoted = false;
163
+ while (i < src.length && !/\s/.test(src[i]) && !opAt(src, i)) {
164
+ const c = src[i];
165
+ if (c === "'" || c === '"') {
166
+ if (!quote) { quote = c; quoted = true; started = true; i += 1; continue; }
167
+ if (quote === c) { quote = ""; i += 1; continue; }
168
+ }
169
+ if (c === "\\") {
170
+ if (i + 1 >= src.length) return null;
171
+ quoted = true;
172
+ delimiter += src[i + 1];
173
+ started = true;
174
+ i += 2;
175
+ continue;
176
+ }
177
+ delimiter += c;
178
+ started = true;
179
+ i += 1;
180
+ }
181
+ if (quote || !started || !delimiter) return null;
182
+ return { delimiter: delimiter, end: i, quoted: quoted };
183
+ }
184
+
185
+ export function tokenize(src) {
186
+ if (typeof src !== "string") return { ok: false, reason: "not-a-string", snippet: "", tokens: [] };
187
+ const tokens = [];
188
+ const pendingHeredocs = [];
189
+ let i = 0;
190
+ const n = src.length;
191
+
192
+ function flushWord(value, started, dynamic, substitutions) {
193
+ if (started) tokens.push({ kind: "word", value: value, dynamic: dynamic, substitutions: substitutions });
194
+ }
195
+
196
+ while (i < n) {
197
+ if (src.startsWith("\\\n", i)) { i += 2; continue; }
198
+ if (src[i] === "\r" || src[i] === "\n") {
199
+ if (src[i] === "\r" && src[i + 1] === "\n") i += 2;
200
+ else i += 1;
201
+ if (pendingHeredocs.length) {
202
+ for (const heredoc of pendingHeredocs) {
203
+ let body = "";
204
+ let found = false;
205
+ while (i <= n) {
206
+ const lineEnd = src.indexOf("\n", i);
207
+ const hasNewline = lineEnd !== -1;
208
+ let line = src.slice(i, hasNewline ? lineEnd : n);
209
+ if (line.endsWith("\r")) line = line.slice(0, -1);
210
+ const compare = heredoc.stripTabs ? line.replace(/^\t+/, "") : line;
211
+ if (compare === heredoc.delimiter) {
212
+ i = hasNewline ? lineEnd + 1 : n;
213
+ found = true;
214
+ break;
215
+ }
216
+ body += (heredoc.stripTabs ? line.replace(/^\t+/, "") : line) + (hasNewline ? "\n" : "");
217
+ if (!hasNewline) { i = n; break; }
218
+ i = lineEnd + 1;
219
+ }
220
+ if (!found) return lexFail(src, i, "unterminated-heredoc", tokens);
221
+ tokens.push({ kind: "heredoc", body: body, quoted: heredoc.quoted });
222
+ }
223
+ pendingHeredocs.length = 0;
224
+ tokens.push({ kind: "op", value: ";" });
225
+ } else {
226
+ tokens.push({ kind: "op", value: ";" });
227
+ }
228
+ continue;
229
+ }
230
+ if (/\s/.test(src[i])) { i += 1; continue; }
231
+ if (src[i] === "#" && (tokens.length === 0 || tokens[tokens.length - 1].kind === "op")) {
232
+ while (i < n && src[i] !== "\n") i += 1;
233
+ continue;
234
+ }
235
+
236
+ const op = opAt(src, i);
237
+ if (op) {
238
+ tokens.push({ kind: "op", value: op });
239
+ i += op.length;
240
+ if (HEREDOC_OPS.has(op)) {
241
+ const parsed = readHeredocDelimiter(src, i);
242
+ if (!parsed) return lexFail(src, i, "invalid-heredoc-delimiter", tokens);
243
+ tokens.push({ kind: "heredoc-delimiter", value: parsed.delimiter });
244
+ pendingHeredocs.push({ delimiter: parsed.delimiter, stripTabs: op === "<<-", quoted: parsed.quoted });
245
+ i = parsed.end;
246
+ }
247
+ continue;
248
+ }
249
+
250
+ let word = "";
251
+ let started = false;
252
+ let dynamic = false;
253
+ const substitutions = [];
254
+ while (i < n && !/\s/.test(src[i]) && !opAt(src, i)) {
255
+ const c = src[i];
256
+ if (src.startsWith("<(", i) || src.startsWith(">(", i)) {
257
+ const sub = readCommandSubstitution(src, i);
258
+ if (!sub) return lexFail(src, i, "unclosed-process-substitution", tokens);
259
+ substitutions.push({ kind: "process-substitution", script: sub.script });
260
+ word += DYNAMIC_MARKER;
261
+ dynamic = true;
262
+ started = true;
263
+ i = sub.end;
264
+ continue;
265
+ }
266
+ if (c === "(" || c === ")") return lexFail(src, i, "unsupported-shell-grouping", tokens);
267
+ if (c === "\\") {
268
+ if (i + 1 >= n) return lexFail(src, i, "dangling-backslash", tokens);
269
+ word += src[i + 1];
270
+ started = true;
271
+ i += 2;
272
+ continue;
273
+ }
274
+ if (c === "'") {
275
+ const j = src.indexOf("'", i + 1);
276
+ if (j < 0) return lexFail(src, i, "unclosed-single-quote", tokens);
277
+ word += src.slice(i + 1, j);
278
+ started = true;
279
+ i = j + 1;
280
+ continue;
281
+ }
282
+ if (c === '"') {
283
+ started = true;
284
+ i += 1;
285
+ let closed = false;
286
+ while (i < n) {
287
+ if (src[i] === '"') { i += 1; closed = true; break; }
288
+ if (src[i] === "\\") {
289
+ if (i + 1 >= n) return lexFail(src, i, "dangling-backslash", tokens);
290
+ word += src[i + 1];
291
+ i += 2;
292
+ continue;
293
+ }
294
+ if (src.startsWith("$(", i)) {
295
+ const sub = readCommandSubstitution(src, i);
296
+ if (!sub) return lexFail(src, i, "unclosed-command-substitution", tokens);
297
+ substitutions.push({ kind: "command-substitution", script: sub.script });
298
+ word += DYNAMIC_MARKER;
299
+ dynamic = true;
300
+ i = sub.end;
301
+ continue;
302
+ }
303
+ if (src[i] === String.fromCharCode(96)) {
304
+ const sub = readBacktick(src, i);
305
+ if (!sub) return lexFail(src, i, "unclosed-backtick-substitution", tokens);
306
+ substitutions.push({ kind: "backtick-substitution", script: sub.script });
307
+ word += DYNAMIC_MARKER;
308
+ dynamic = true;
309
+ i = sub.end;
310
+ continue;
311
+ }
312
+ if (src[i] === "$") {
313
+ const expansion = readParameterExpansion(src, i);
314
+ if (!expansion) return lexFail(src, i, "unclosed-parameter-expansion", tokens);
315
+ word += DYNAMIC_MARKER;
316
+ dynamic = true;
317
+ if (expansion.body !== undefined) substitutions.push({ kind: "parameter-expansion", script: expansion.body });
318
+ i = expansion.end;
319
+ continue;
320
+ }
321
+ word += src[i];
322
+ i += 1;
323
+ }
324
+ if (!closed) return lexFail(src, i, "unclosed-double-quote", tokens);
325
+ continue;
326
+ }
327
+ if (src.startsWith("$(", i)) {
328
+ const sub = readCommandSubstitution(src, i);
329
+ if (!sub) return lexFail(src, i, "unclosed-command-substitution", tokens);
330
+ substitutions.push({ kind: "command-substitution", script: sub.script });
331
+ word += DYNAMIC_MARKER;
332
+ dynamic = true;
333
+ started = true;
334
+ i = sub.end;
335
+ continue;
336
+ }
337
+ if (c === String.fromCharCode(96)) {
338
+ const sub = readBacktick(src, i);
339
+ if (!sub) return lexFail(src, i, "unclosed-backtick-substitution", tokens);
340
+ substitutions.push({ kind: "backtick-substitution", script: sub.script });
341
+ word += DYNAMIC_MARKER;
342
+ dynamic = true;
343
+ started = true;
344
+ i = sub.end;
345
+ continue;
346
+ }
347
+ if (c === "$") {
348
+ const expansion = readParameterExpansion(src, i);
349
+ if (!expansion) return lexFail(src, i, "unclosed-parameter-expansion", tokens);
350
+ word += DYNAMIC_MARKER;
351
+ dynamic = true;
352
+ started = true;
353
+ if (expansion.body !== undefined) substitutions.push({ kind: "parameter-expansion", script: expansion.body });
354
+ i = expansion.end;
355
+ continue;
356
+ }
357
+ if (c === "*" || c === "?") dynamic = true;
358
+ word += c;
359
+ started = true;
360
+ i += 1;
361
+ }
362
+ flushWord(word, started, dynamic, substitutions);
363
+ }
364
+ if (pendingHeredocs.length) return lexFail(src, n, "unterminated-heredoc", tokens);
365
+ return { ok: true, tokens: tokens };
366
+ }
367
+
368
+ function wordsOf(seg) {
369
+ return seg.filter(function (t) { return t.kind === "word"; }).map(function (t) { return t.value; });
370
+ }
371
+
372
+ function wordTokensOf(seg) {
373
+ return seg.filter(function (t) { return t.kind === "word"; });
374
+ }
375
+
376
+ function redirectsOf(seg) {
377
+ const paths = [];
378
+ for (let i = 0; i < seg.length; i += 1) {
379
+ const t = seg[i];
380
+ if (t.kind === "op" && REDIR_OPS.has(t.value)) {
381
+ const next = seg[i + 1];
382
+ if (next && next.kind === "word") paths.push({ path: next.value, dynamic: Boolean(next.dynamic), operator: t.value });
383
+ }
384
+ }
385
+ return paths;
386
+ }
387
+
388
+ function splitSegments(tokens) {
389
+ const segs = [];
390
+ let cur = [];
391
+ for (const t of tokens) {
392
+ if (t.kind === "op" && SPLIT_OPS.has(t.value)) {
393
+ segs.push(cur);
394
+ cur = [];
395
+ continue;
396
+ }
397
+ cur.push(t);
398
+ }
399
+ segs.push(cur);
400
+ return segs;
401
+ }
402
+
403
+ function stripPrefixes(argv) {
404
+ const out = argv.slice();
405
+ while (out.length) {
406
+ const head = out[0];
407
+ if (PREFIXES.has(head)) { out.shift(); continue; }
408
+ if (head === "env") {
409
+ out.shift();
410
+ while (out.length && out[0].includes("=") && out[0][0] !== "-") out.shift();
411
+ continue;
412
+ }
413
+ if (head.includes("=") && head[0] !== "-") { out.shift(); continue; }
414
+ break;
415
+ }
416
+ return out;
417
+ }
418
+
419
+ function hasLetterFlag(argv, letter) {
420
+ for (const a of argv) {
421
+ if (a === "--") break;
422
+ if (a.length > 1 && a[0] === "-" && a[1] !== "-" && a.indexOf(letter) !== -1) return true;
423
+ }
424
+ return false;
425
+ }
426
+
427
+ function deny(reason, snippet) {
428
+ return { deny: true, ask: false, reason: reason, detail: reason, snippet: String(snippet || reason).slice(0, 160) };
429
+ }
430
+
431
+ function allow() {
432
+ return { deny: false, ask: false };
433
+ }
434
+
435
+ function ask(reason, detail, snippet) {
436
+ return { deny: false, ask: true, reason: reason, detail: detail || reason, snippet: String(snippet || "").slice(0, 160) };
437
+ }
438
+
439
+ function scriptAfterDashC(rest) {
440
+ for (let i = 0; i < rest.length; i += 1) {
441
+ const a = rest[i];
442
+ if (a === "-c") return rest[i + 1];
443
+ if (a[0] === "-" && a[1] !== "-" && a.indexOf("c") !== -1) return rest[i + 1];
444
+ }
445
+ return undefined;
446
+ }
447
+
448
+ function skipWrapper(rest, arity) {
449
+ let i = 0;
450
+ while (i < rest.length && rest[i][0] === "-") {
451
+ const a = rest[i];
452
+ if (a === "--") { i += 1; break; }
453
+ if (arity.has(a)) i += 2;
454
+ else i += 1;
455
+ }
456
+ return rest.slice(i);
457
+ }
458
+
459
+ function gitOperation(rest) {
460
+ const takesValue = new Set(["-C", "-c", "--git-dir", "--work-tree", "--namespace"]);
461
+ let i = 0;
462
+ while (i < rest.length && rest[i].startsWith("-")) {
463
+ const flag = rest[i];
464
+ if (flag === "--") { i += 1; break; }
465
+ if (takesValue.has(flag)) i += 2;
466
+ else if (flag.startsWith("--") && flag.includes("=")) i += 1;
467
+ else if (flag.length > 2 && flag[1] !== "-") i += 1;
468
+ else i += 1;
469
+ }
470
+ return { verb: rest[i], args: rest.slice(i + 1) };
471
+ }
472
+
473
+ function isDevicePath(path) {
474
+ if (typeof path !== "string" || !path.startsWith("/dev/")) return false;
475
+ const device = path.slice(5);
476
+ if (["null", "zero", "full", "random", "urandom", "stdin", "stdout", "stderr", "tty"].includes(device)) return false;
477
+ 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;
478
+ return device.startsWith("mapper/") || device.startsWith("disk/by-id/") || device.startsWith("disk/by-uuid/");
479
+ }
480
+
481
+ function inspectInterpreterPayload(cmd, payload) {
482
+ const code = String(payload || "");
483
+ 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);
484
+ const writesFile = /\b(?:write_text|write_bytes|unlink|rmdir|remove|chmod|chown|truncate)\s*\(|\bopen\s*\([^)]*,\s*["'][wax+]/i.test(code);
485
+ if (executesProcess) {
486
+ if (/\brm\s+(?:-[a-z]*r[a-z]*|--recursive)\s+|\b(?:kill|pkill|killall)\b|\bsystemctl\s+(?:stop|restart|disable|mask)\b|\bservice\s+\S+\s+(?:stop|restart)\b|\bgit\s+reset\s+--hard\b|\b(?:curl|wget)\b[^|]*\|\s*(?:bash|sh)\b|\bmkfs(?:\.|\s)|\bdd\b[^\n]{0,160}\bof=\/dev\/(?:sd|hd|vd|xvd|nvme|mmcblk|loop|mapper)/i.test(code)) {
487
+ return deny("interpreter-payload", code);
488
+ }
489
+ return ask("interpreter-payload", "interpreter process execution is not fully analyzed", code);
490
+ }
491
+ if (writesFile) {
492
+ if (/(?:\.env(?:\.[A-Za-z0-9_.-]+)?|credentials\.ya?ml|settings\.ya?ml|cordis\.patch\.yml|id_(?:rsa|ed25519|ecdsa)|[A-Za-z0-9_.-]+\.key)\b/i.test(code)) return deny("secret-write", code);
493
+ return ask("interpreter-payload", "interpreter file writes are not fully analyzed", code);
494
+ }
495
+ return allow();
496
+ }
497
+
498
+ function inspectArgv(argv, depth, hasHeredoc) {
499
+ if (!argv.length) return allow();
500
+ const cmd = basename(argv[0]);
501
+ const rest = argv.slice(1);
502
+
503
+ if (cmd === "sudo" || cmd === "command" || cmd === "builtin" || cmd === "nohup" || cmd === "time" || cmd === "nice" || cmd === "ionice" || cmd === "stdbuf" || cmd === "timeout") {
504
+ const arity = new Set(["-u", "--user", "-g", "--group", "-p", "-s", "--signal", "-k", "--kill-after", "-o", "-e", "-i"]);
505
+ let next = skipWrapper(rest, arity);
506
+ if (cmd === "timeout" && next.length) next = next.slice(1);
507
+ if (cmd === "nice" && next.length && /^[0-9-]+$/.test(next[0])) next = next.slice(1);
508
+ return inspectArgv(next, depth, hasHeredoc);
509
+ }
510
+
511
+ if (cmd === "eval") return deny("eval", argv.join(" "));
512
+ if (KILL_CMDS.has(cmd)) return deny("process-signal", argv.join(" "));
513
+ if (cmd === "rm" && (hasLetterFlag(argv, "r") || rest.indexOf("--recursive") !== -1)) {
514
+ return deny("recursive-rm", argv.join(" "));
515
+ }
516
+ if (cmd === "systemctl") {
517
+ const verb = rest.find(function (a) { return a[0] !== "-"; });
518
+ if (verb && SYSTEMCTL_DENY.has(verb)) return deny("service-control", argv.join(" "));
519
+ }
520
+ if (cmd === "service") {
521
+ const verb = rest.length >= 2 ? rest[1] : "";
522
+ if (SERVICE_DENY.has(verb)) return deny("service-control", argv.join(" "));
523
+ }
524
+ if (cmd === "curl" && rest.some(function (a) {
525
+ return a.includes(DYNAMIC_MARKER) && /\b(?:authorization|proxy-authorization)\s*:\s*(?:bearer|token)\s+/i.test(a);
526
+ })) {
527
+ return ask("credential-in-argv", "expanded authorization values become curl process arguments", argv.join(" "));
528
+ }
529
+ if (cmd === "git") {
530
+ const operation = gitOperation(rest);
531
+ if (operation.verb === "clean" && operation.args.some(function (a) {
532
+ return a === "-f" || a === "--force" || (a[0] === "-" && a[1] !== "-" && a.indexOf("f") !== -1);
533
+ })) return deny("git-clean", argv.join(" "));
534
+ if (operation.verb === "reset" && operation.args.indexOf("--hard") !== -1) return deny("git-reset-hard", argv.join(" "));
535
+ }
536
+ if (cmd === "find" && rest.indexOf("-delete") !== -1) return deny("find-delete", argv.join(" "));
537
+ if (DB_CLIENTS.has(cmd) && DESTRUCTIVE_SQL.test(rest.join(" "))) {
538
+ return deny("destructive-sql", argv.join(" "));
539
+ }
540
+ if (/^mkfs(?:\.|$)/i.test(cmd)) return deny("mkfs-device", argv.join(" "));
541
+ if (cmd === "dd") {
542
+ for (const a of rest) {
543
+ if (a.startsWith("of=") && (isDevicePath(a.slice(3)) || isProtectedPath(a.slice(3)))) return deny("device-write", a);
544
+ }
545
+ }
546
+ if (cmd === "tee" || cmd === "install" || cmd === "cp" || cmd === "mv" || cmd === "truncate") {
547
+ for (const a of rest) {
548
+ if (a[0] !== "-" && isProtectedPath(a)) return deny("secret-write", argv.join(" "));
549
+ }
550
+ }
551
+ if (cmd === "sed" && (hasLetterFlag(argv, "i") || rest.indexOf("--in-place") !== -1)) {
552
+ for (const a of rest) {
553
+ if (a[0] !== "-" && isProtectedPath(a)) return deny("secret-write", argv.join(" "));
554
+ }
555
+ }
556
+ if (SHELLS.has(cmd)) {
557
+ const script = scriptAfterDashC(rest);
558
+ if (typeof script === "string") {
559
+ if (depth > 4) return deny("nested-shell", script);
560
+ return inspectBashCommand(script, depth + 1);
561
+ }
562
+ const scriptFile = rest.find(function (a) { return a && a[0] !== "-"; });
563
+ if (scriptFile) return ask("script-file", "shell script contents are not available to the guard", scriptFile);
564
+ if (!hasHeredoc) return ask("shell-stdin", "shell input is not fully available to the guard", argv.join(" "));
565
+ }
566
+ if (INTERPRETERS.has(cmd) && rest.some(function (a) { return a === "-c" || a === "-e"; })) {
567
+ const idx = rest.findIndex(function (a) { return a === "-c" || a === "-e"; });
568
+ return inspectInterpreterPayload(cmd, rest[idx + 1] || "");
569
+ }
570
+ if (/\.sh$/i.test(cmd) || (cmd.includes("/") && /\.(?:bash|zsh|ksh)$/i.test(cmd))) {
571
+ return ask("script-file", "script contents are not available to the guard", argv[0]);
572
+ }
573
+ return allow();
574
+ }
575
+
576
+ function hasDangerousPipeline(tokens) {
577
+ let segment = [];
578
+ let pipelineHasDownloader = false;
579
+ let inPipeline = false;
580
+ function inspectSegment() {
581
+ return commandNameFromSegment(segment);
582
+ }
583
+ for (const token of tokens) {
584
+ if (token.kind === "op" && SPLIT_OPS.has(token.value)) {
585
+ const cmd = inspectSegment();
586
+ if (inPipeline && SHELLS.has(cmd) && pipelineHasDownloader) return true;
587
+ const continues = token.value === "|" || token.value === "|&";
588
+ if (continues) {
589
+ pipelineHasDownloader = pipelineHasDownloader || cmd === "curl" || cmd === "wget";
590
+ inPipeline = true;
591
+ } else {
592
+ pipelineHasDownloader = false;
593
+ inPipeline = false;
594
+ }
595
+ segment = [];
596
+ continue;
597
+ }
598
+ segment.push(token);
599
+ }
600
+ const cmd = inspectSegment();
601
+ return inPipeline && pipelineHasDownloader && SHELLS.has(cmd);
602
+ }
603
+
604
+ function executableTokenOf(seg) {
605
+ const words = wordTokensOf(seg);
606
+ const wrappers = new Set(["sudo", "command", "builtin", "nohup", "time", "nice", "ionice", "stdbuf", "timeout", "exec"]);
607
+ const takesValue = new Set(["-u", "--user", "-g", "--group", "-p", "-s", "--signal", "-k", "--kill-after", "-o", "-e", "-i"]);
608
+ let i = 0;
609
+ while (i < words.length) {
610
+ const value = words[i].value;
611
+ if (PREFIXES.has(value)) { i += 1; continue; }
612
+ if (value === "env") {
613
+ i += 1;
614
+ while (i < words.length && words[i].value.includes("=") && !words[i].value.startsWith("-")) i += 1;
615
+ continue;
616
+ }
617
+ if (value.includes("=") && !value.startsWith("-")) { i += 1; continue; }
618
+ if (!wrappers.has(value)) return words[i];
619
+ i += 1;
620
+ while (i < words.length && words[i].value.startsWith("-")) {
621
+ const flag = words[i].value;
622
+ if (flag === "--") { i += 1; break; }
623
+ if (takesValue.has(flag)) i += 2;
624
+ else i += 1;
625
+ }
626
+ if (value === "timeout" && i < words.length) i += 1;
627
+ if (value === "nice" && i < words.length && /^[0-9-]+$/.test(words[i].value)) i += 1;
628
+ }
629
+ return undefined;
630
+ }
631
+
632
+ function commandNameFromSegment(seg) {
633
+ const token = executableTokenOf(seg);
634
+ return token ? basename(token.value) : "";
635
+ }
636
+
637
+ function inspectEmbeddedExpansions(source, depth) {
638
+ if (depth > 8) return ask("unparseable", "nesting-depth", source);
639
+ for (let i = 0; i < source.length; i += 1) {
640
+ if (source[i] === "\\") { i += 1; continue; }
641
+ if (source.startsWith("$" + "(", i) || source[i] === String.fromCharCode(96)) {
642
+ const sub = source.startsWith("$" + "(", i)
643
+ ? readCommandSubstitution(source, i)
644
+ : readBacktick(source, i);
645
+ if (!sub) return ask("unparseable", "unclosed-command-substitution", source.slice(i));
646
+ const nested = inspectBashCommand(sub.script, depth + 1);
647
+ if (nested.deny || nested.ask) return nested;
648
+ i = sub.end - 1;
649
+ continue;
650
+ }
651
+ if (source.startsWith("$" + "{", i)) {
652
+ const expansion = readParameterExpansion(source, i);
653
+ if (!expansion) return ask("unparseable", "unclosed-parameter-expansion", source.slice(i));
654
+ if (expansion.body !== undefined) {
655
+ const nested = inspectEmbeddedExpansions(expansion.body, depth + 1);
656
+ if (nested.deny || nested.ask) return nested;
657
+ }
658
+ i = expansion.end - 1;
659
+ }
660
+ }
661
+ return allow();
662
+ }
663
+
664
+ function inspectPipelineHeredocs(tokens, depth) {
665
+ let group = [];
666
+ let segment = [];
667
+ function finishGroup() {
668
+ if (segment.length) group.push(segment);
669
+ const hasShell = group.some(function (part) { return SHELLS.has(commandNameFromSegment(part)); });
670
+ if (hasShell) {
671
+ for (const part of group) {
672
+ for (const heredoc of part.filter(function (t) { return t.kind === "heredoc"; })) {
673
+ const nested = inspectBashCommand(heredoc.body, depth + 1);
674
+ if (nested.deny || nested.ask) return nested;
675
+ }
676
+ }
677
+ }
678
+ group = [];
679
+ segment = [];
680
+ return allow();
681
+ }
682
+ for (const token of tokens) {
683
+ if (token.kind === "op" && SPLIT_OPS.has(token.value)) {
684
+ group.push(segment);
685
+ segment = [];
686
+ if (token.value !== "|" && token.value !== "|&") {
687
+ const result = finishGroup();
688
+ if (result.deny || result.ask) return result;
689
+ }
690
+ } else {
691
+ segment.push(token);
692
+ }
693
+ }
694
+ group.push(segment);
695
+ return finishGroup();
696
+ }
697
+
698
+ function inspectUnparsedDanger(command) {
699
+ const checks = [
700
+ ["curl-pipe", /\b(?:curl|wget)\b[^\n]{0,240}\|\s*(?:sudo\s+)?(?:bash|sh)\b/i],
701
+ ["recursive-rm", /\brm\s+(?:-[^\s]*r[^\s]*|--recursive)(?:\s|$)/i],
702
+ ["process-signal", /\b(?:kill|pkill|killall)\b/i],
703
+ ["service-control", /\bsystemctl\s+(?:stop|restart|disable|enable|mask|unmask|reboot|halt|poweroff|shutdown|kill)\b|\bservice\s+\S+\s+(?:stop|restart|force-stop|force-reload)\b/i],
704
+ ["git-reset-hard", /\bgit\b[^\n;|&]{0,120}\breset\b[^\n;|&]{0,80}--hard\b/i],
705
+ ["git-clean", /\bgit\b[^\n;|&]{0,120}\bclean\b[^\n;|&]{0,80}(?:--force|(?:^|\s)-[^\s]*f[^\s]*)/i],
706
+ ["find-delete", /\bfind\b[^\n;|&]{0,200}\s-delete\b/i],
707
+ ["mkfs-device", /\bmkfs(?:\.[A-Za-z0-9_-]+)?\b/i],
708
+ ["device-write", /\bdd\b[^\n;|&]{0,200}\bof=\/dev\/(?:sd[a-z]+|hd[a-z]+|vd[a-z]+|xvd[a-z]+|nvme\d+n\d+(?:p\d+)?|mmcblk\d+(?:p\d+)?|loop\d+|mapper\/\S+)/i],
709
+ ["destructive-sql", /\b(?:sqlite3|mysql|psql|mongo|mongosh|clickhouse-client)\b[^\n;|&]{0,200}\b(?:ALTER|DROP|TRUNCATE|DELETE\s+FROM|CREATE\s+(?:TABLE|DATABASE|INDEX))\b/i],
710
+ ["secret-write", /(?:>|&>|\b(?:tee|install|mv|truncate|chmod|chown)\b|\bsed\b[^\n;|&]{0,160}(?:-i|--in-place))[^\n;|&]{0,240}(?:\.env(?:\.[\w.-]+)?|credentials\.ya?ml|settings\.ya?ml|cordis\.patch\.yml|[\w.-]*(?:secret|token|credential|api[_-]?key)[\w.-]*|id_(?:rsa|ed25519|ecdsa)|[\w.-]+\.key)\b/i],
711
+ ];
712
+ for (const [reason, pattern] of checks) {
713
+ if (pattern.test(command)) return deny(reason, command);
714
+ }
715
+ return null;
716
+ }
717
+
718
+ export function inspectBashCommand(command, depth) {
719
+ if (depth === undefined) depth = 0;
720
+ if (depth > 8) return ask("unparseable", "nesting-depth", String(command || ""));
721
+ if (typeof command !== "string") return ask("unknown-format", "command is not a string", "non-string command");
722
+ if (command.trim() === "") return allow();
723
+ const tok = tokenize(command);
724
+ const tokens = tok.tokens || [];
725
+ if (!tok.ok) {
726
+ const knownDanger = inspectUnparsedDanger(command);
727
+ if (knownDanger) return knownDanger;
728
+ }
729
+ if (hasDangerousPipeline(tokens)) return deny("curl-pipe", command);
730
+ const pipelineHeredocHit = inspectPipelineHeredocs(tokens, depth);
731
+ if (pipelineHeredocHit.deny || pipelineHeredocHit.ask) return pipelineHeredocHit;
732
+
733
+ const segs = splitSegments(tokens);
734
+ for (const seg of segs) {
735
+ const wordTokens = wordTokensOf(seg);
736
+ const heredocs = seg.filter(function (t) { return t.kind === "heredoc"; });
737
+ const argv = stripPrefixes(wordsOf(seg));
738
+ const commandToken = executableTokenOf(seg);
739
+ const commandName = commandNameFromSegment(seg);
740
+
741
+ for (const token of wordTokens) {
742
+ for (const substitution of token.substitutions || []) {
743
+ const nested = substitution.kind === "parameter-expansion"
744
+ ? inspectEmbeddedExpansions(substitution.script, depth + 1)
745
+ : inspectBashCommand(substitution.script, depth + 1);
746
+ if (nested.deny || nested.ask) return nested;
747
+ }
748
+ }
749
+
750
+ for (const redirect of redirectsOf(seg)) {
751
+ if (redirect.dynamic) return ask("dynamic-redirect", "expanded redirect target cannot be verified", redirect.path);
752
+ if (isProtectedPath(redirect.path)) return deny("secret-write", redirect.path);
753
+ }
754
+
755
+ if (commandToken && commandToken.dynamic && !commandToken.value.includes("=")) {
756
+ return ask("dynamic-command", "expanded command name cannot be verified", commandToken.value);
757
+ }
758
+
759
+ const dynamicArgs = wordTokens.some(function (t) {
760
+ return t.dynamic && (!commandToken || t !== commandToken) && !t.value.includes("=");
761
+ });
762
+ if (dynamicArgs && ["rm", "find", "git", "systemctl", "service", "tee", "install", "cp", "mv", "truncate", "sed", "dd"].includes(commandName)) {
763
+ return ask("dynamic-arguments", "expanded command arguments cannot be verified", commandName);
764
+ }
765
+
766
+ if (heredocs.length) {
767
+ for (const heredoc of heredocs) {
768
+ if (!heredoc.quoted) {
769
+ const expansionHit = inspectEmbeddedExpansions(heredoc.body, depth);
770
+ if (expansionHit.deny || expansionHit.ask) return expansionHit;
771
+ }
772
+ }
773
+ if (SHELLS.has(commandName)) {
774
+ for (const heredoc of heredocs) {
775
+ const nested = inspectBashCommand(heredoc.body, depth + 1);
776
+ if (nested.deny || nested.ask) return nested;
777
+ }
778
+ } else if (INTERPRETERS.has(commandName)) {
779
+ for (const heredoc of heredocs) {
780
+ const nested = inspectInterpreterPayload(commandName, heredoc.body);
781
+ if (nested.deny || nested.ask) return nested;
782
+ }
783
+ }
784
+ }
785
+
786
+ const hit = inspectArgv(argv, depth, heredocs.length > 0);
787
+ if (hit.deny || hit.ask) return hit;
788
+ }
789
+
790
+ if (!tok.ok) return ask("unparseable", tok.reason || "unsupported shell syntax", tok.snippet || command);
791
+ return allow();
792
+ }
793
+
794
+ function pathFromArgs(args) {
795
+ if (typeof args === "string") return args;
796
+ if (!args || typeof args !== "object") return null;
797
+ const keys = ["path", "file_path", "filePath", "file", "filename", "target", "to"];
798
+ for (const k of keys) {
799
+ if (typeof args[k] === "string") return args[k];
800
+ }
801
+ return null;
802
+ }
803
+
804
+ export function inspectExecution(execution, config) {
805
+ if (!config) config = {};
806
+ const toolName = config.toolName || DEFAULT_BASH_TOOL;
807
+ const fileTools = new Set(config.fileWriteTools || DEFAULT_FILE_WRITE_TOOLS);
808
+ const name = execution && execution.name;
809
+ const args = execution ? execution.arguments : undefined;
810
+
811
+ if (name === toolName) {
812
+ const command = args && typeof args === "object" ? args.command : undefined;
813
+ if (command === undefined || command === null || command === "") return allow();
814
+ if (typeof command !== "string") return ask("unknown-format", "command is not a string", "non-string command");
815
+ return inspectBashCommand(command);
816
+ }
817
+ if (typeof name === "string" && fileTools.has(name)) {
818
+ const path = pathFromArgs(args);
819
+ if (!path) return ask("unknown-write-target", "file write target could not be identified", name);
820
+ if (isProtectedPath(path)) return deny("secret-write", path);
821
+ }
822
+ return allow();
823
+ }
824
+
825
+ export function sessionTag(execution) {
826
+ const agent = execution && execution.agent;
827
+ if (typeof agent === "string") return " (session " + agent + ")";
828
+ if (agent && agent.session) return " (session " + String(agent.session) + ")";
829
+ return "";
830
+ }
831
+
832
+ function safeSnippet(value) {
833
+ return String(value || "")
834
+ .replace(/[\r\n\t\u0000-\u001f]+/g, " ")
835
+ .replace(/(authorization\s*:\s*(?:bearer|token)\s+)\S+/gi, "$1[redacted]")
836
+ .replace(/((?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*)[^\s,;]+/gi, "$1[redacted]")
837
+ .slice(0, 120);
838
+ }
839
+
840
+ function translated(translate, key, logger) {
841
+ if (typeof translate === "function") {
842
+ try {
843
+ const value = translate(key);
844
+ if (value && value !== key) return value;
845
+ } catch {
846
+ if (logger && typeof logger.warn === "function") {
847
+ logger.warn("[dsh-approval-gate] localization lookup failed; using English fallback");
848
+ }
849
+ }
850
+ }
851
+ return MESSAGES.en[key] || key;
852
+ }
853
+
854
+ const RULE_KEYS = Object.freeze({
855
+ "recursive-rm": "ruleRecursiveRm",
856
+ "process-signal": "ruleProcessSignal",
857
+ "service-control": "ruleServiceControl",
858
+ "git-clean": "ruleGitClean",
859
+ "git-reset-hard": "ruleGitResetHard",
860
+ "find-delete": "ruleFindDelete",
861
+ "destructive-sql": "ruleDestructiveSql",
862
+ "secret-write": "ruleSecretWrite",
863
+ "device-write": "ruleDeviceWrite",
864
+ "mkfs-device": "ruleMkfsDevice",
865
+ "curl-pipe": "ruleCurlPipe",
866
+ "eval": "ruleEval",
867
+ "nested-shell": "ruleNestedShell",
868
+ "interpreter-payload": "ruleInterpreterPayload",
869
+ });
870
+
871
+ const ASK_KEYS = Object.freeze({
872
+ unparseable: "askUnparseable",
873
+ "unknown-format": "askUnknownFormat",
874
+ "unknown-write-target": "askUnknownWriteTarget",
875
+ "dynamic-command": "askDynamicCommand",
876
+ "dynamic-arguments": "askDynamicArguments",
877
+ "dynamic-redirect": "askDynamicRedirect",
878
+ "script-file": "askScriptFile",
879
+ "shell-stdin": "askShellStdin",
880
+ "interpreter-payload": "askInterpreterPayload",
881
+ "credential-in-argv": "askCredentialInArgv",
882
+ });
883
+
884
+ const DETAIL_KEYS = Object.freeze({
885
+ "process-substitution": "detailProcessSubstitution",
886
+ "unclosed-process-substitution": "detailUnclosedProcessSubstitution",
887
+ "unclosed-command-substitution": "detailUnclosedCommandSubstitution",
888
+ "unclosed-backtick-substitution": "detailUnclosedBacktick",
889
+ "unclosed-parameter-expansion": "detailUnclosedParameterExpansion",
890
+ "unclosed-single-quote": "detailUnclosedSingleQuote",
891
+ "unclosed-double-quote": "detailUnclosedDoubleQuote",
892
+ "dangling-backslash": "detailDanglingEscape",
893
+ "invalid-heredoc-delimiter": "detailInvalidHeredoc",
894
+ "unterminated-heredoc": "detailUnterminatedHeredoc",
895
+ "unsupported-shell-grouping": "detailUnsupportedGrouping",
896
+ });
897
+
898
+ export function denyMessage(hit, who, translate, logger) {
899
+ const key = RULE_KEYS[hit && hit.reason] || "ruleUnknown";
900
+ const snippet = safeSnippet(hit && hit.snippet);
901
+ const parts = [translated(translate, "blockedPrefix", logger), translated(translate, key, logger)];
902
+ if (snippet) parts.push(translated(translate, "detectedLabel", logger) + ": " + snippet);
903
+ if (who) parts.push(who.trim());
904
+ return parts.join(" — ") + ".";
905
+ }
906
+
907
+ export function askMessage(hit, who, translate, logger) {
908
+ const key = ASK_KEYS[hit && hit.reason] || "askUnknown";
909
+ const snippet = safeSnippet(hit && hit.snippet);
910
+ const parts = [translated(translate, "approvalRequired", logger), translated(translate, key, logger)];
911
+ if (hit && hit.reason === "unparseable") {
912
+ const detailKey = DETAIL_KEYS[hit.detail] || "detailUnsupported";
913
+ parts.push(translated(translate, "parseReasonLabel", logger) + ": " + translated(translate, detailKey, logger));
914
+ }
915
+ if (snippet) parts.push(translated(translate, "commandLabel", logger) + ": " + snippet);
916
+ if (who) parts.push(who.trim());
917
+ return parts.join(" — ") + ".";
918
+ }