@goodandready/dsh-approval-gate 0.1.11 → 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.
@@ -0,0 +1,267 @@
1
+ // lib/parsers/shell-cmds.js
2
+ // Specialized shell AST parser for redirections, subshells, pipelines, and error masking.
3
+ // Resolves: #126, #90
4
+
5
+ import { REDIR_OPS, SPLIT_OPS } from "../tokenizer.js";
6
+ import { isProtectedConfigPath, isPathTraversal, normalizeSeparators } from "../paths.js";
7
+
8
+ const SHELLS = new Set(["bash", "sh", "dash", "zsh", "ksh", "ash"]);
9
+ const INTERPRETERS = new Set(["python", "python2", "python3", "node", "nodejs", "perl", "ruby", "php"]);
10
+ const DB_CLIENTS = new Set([
11
+ "sqlite3", "mysql", "psql", "pg_restore", "mongo", "mongosh", "redis-cli", "clickhouse-client",
12
+ ]);
13
+ const DESTRUCTIVE_SQL = /\b(?:ALTER|DROP|TRUNCATE|DELETE\s+FROM|CREATE\s+(?:TABLE|DATABASE|INDEX))\b/i;
14
+ const WRAPPERS = new Set(["sudo", "command", "builtin", "nohup", "time", "nice", "ionice", "stdbuf", "timeout", "exec"]);
15
+
16
+ const SILENT_STDERR_PATTERN = /(?:2\s*>\s*\/dev\/null|&>\s*\/dev\/null|2>&1\s*>\s*\/dev\/null|>\s*\/dev\/null\s*2>&1)/i;
17
+ const SILENT_EXIT_ZERO_PATTERN = /\|\|\s*(?:true|:|exit\s+0)\b/i;
18
+ const SET_PLUS_E_PATTERN = /\bset\s+(?:\+[eE]|\+o\s+errexit)\b/i;
19
+
20
+ /**
21
+ * Checks whether a path represents a raw physical or virtual disk device.
22
+ *
23
+ * @param {string} path
24
+ * @returns {boolean}
25
+ */
26
+ export function isDevicePath(path) {
27
+ if (typeof path !== "string" || !path.startsWith("/dev/")) return false;
28
+ const device = path.slice(5);
29
+ if (["null", "zero", "full", "random", "urandom", "stdin", "stdout", "stderr", "tty"].includes(device)) {
30
+ return false;
31
+ }
32
+ if (/^(?:sd[a-z]+\d*|hd[a-z]+\d*|vd[a-z]+\d*|xvd[a-z]+\d*|nvme\d+n\d+(?:p\d+)?|mmcblk\d+(?:p\d+)?|loop\d+)$/i.test(device)) {
33
+ return true;
34
+ }
35
+ return device.startsWith("mapper/") || device.startsWith("disk/by-id/") || device.startsWith("disk/by-uuid/");
36
+ }
37
+
38
+ /**
39
+ * Extracts redirection operators and their target paths from segment tokens.
40
+ *
41
+ * @param {Array<{ kind: string, value: string, dynamic?: boolean }>} seg
42
+ * @returns {Array<{ operator: string, path: string, dynamic: boolean }>}
43
+ */
44
+ export function extractRedirects(seg) {
45
+ if (!Array.isArray(seg)) return [];
46
+ const redirects = [];
47
+ for (let i = 0; i < seg.length; i += 1) {
48
+ const t = seg[i];
49
+ if (t.kind === "op" && REDIR_OPS.has(t.value)) {
50
+ const next = seg[i + 1];
51
+ if (next && next.kind === "word") {
52
+ redirects.push({
53
+ operator: t.value,
54
+ path: next.value,
55
+ dynamic: Boolean(next.dynamic),
56
+ });
57
+ }
58
+ }
59
+ }
60
+ return redirects;
61
+ }
62
+
63
+ /**
64
+ * Evaluates security of extracted redirections (device writes, secret writes, path traversals).
65
+ *
66
+ * @param {Array<{ operator: string, path: string, dynamic: boolean }>} redirects
67
+ * @param {{ workspaceDir?: string, env?: Record<string, string> }} [options]
68
+ * @returns {{ deny?: boolean, ask?: boolean, reason?: string, detail?: string, path?: string } | null}
69
+ */
70
+ export function inspectRedirectSecurity(redirects, options = {}) {
71
+ const workspaceDir = options.workspaceDir || process.cwd();
72
+ const env = options.env || process.env;
73
+
74
+ for (const redir of redirects) {
75
+ if (redir.dynamic) {
76
+ return {
77
+ ask: true,
78
+ reason: "dynamic-redirect",
79
+ detail: "The expanded redirect target cannot be verified; review before allowing",
80
+ path: redir.path,
81
+ };
82
+ }
83
+
84
+ const norm = normalizeSeparators(redir.path);
85
+
86
+ // Block device redirection
87
+ if (isDevicePath(norm)) {
88
+ return {
89
+ deny: true,
90
+ reason: "device-write",
91
+ detail: `Redirection targets block device ${norm}`,
92
+ path: redir.path,
93
+ };
94
+ }
95
+
96
+ // Block protected secrets redirection
97
+ if (isProtectedConfigPath(norm, env)) {
98
+ return {
99
+ deny: true,
100
+ reason: "secret-write",
101
+ detail: `Redirection targets protected configuration or credentials file ${norm}`,
102
+ path: redir.path,
103
+ };
104
+ }
105
+
106
+ // Block path traversal outside workspace
107
+ const isRel = !/^(?:\/|[A-Za-z]:\/|\/\/)/.test(norm);
108
+ const isExempt = !isRel && (
109
+ /^\/dev\/(?:null|zero|stdout|stderr|fd\/\d+)$/.test(norm) ||
110
+ /^(?:\/tmp|\/var\/tmp)(?:\/|$)/i.test(norm)
111
+ );
112
+
113
+ if (!isExempt && isPathTraversal(redir.path, workspaceDir)) {
114
+ return {
115
+ deny: true,
116
+ reason: "path-traversal",
117
+ detail: `Redirection escapes workspace boundary: ${redir.path}`,
118
+ path: redir.path,
119
+ };
120
+ }
121
+ }
122
+
123
+ return null;
124
+ }
125
+
126
+ /**
127
+ * Detects error masking and suppressed failures in shell commands.
128
+ * Resolves: #90 (SilentErrorGuard).
129
+ *
130
+ * @param {string} command - Raw command line
131
+ * @param {string[]} [argv] - Optional parsed argv
132
+ * @returns {{
133
+ * masked: boolean,
134
+ * kind?: "stderr-suppressed" | "exit-zero-forced" | "errexit-disabled",
135
+ * pattern?: string,
136
+ * reason?: string,
137
+ * detail?: string
138
+ * }}
139
+ */
140
+ export function inspectSilentErrors(command, argv = []) {
141
+ if (typeof command !== "string" || !command.trim()) {
142
+ return { masked: false };
143
+ }
144
+
145
+ // 1. Check stderr suppression to /dev/null
146
+ if (SILENT_STDERR_PATTERN.test(command)) {
147
+ return {
148
+ masked: true,
149
+ kind: "stderr-suppressed",
150
+ pattern: "2>/dev/null",
151
+ reason: "silent-error-masked",
152
+ detail: "Command suppresses error output via stderr redirection to /dev/null",
153
+ };
154
+ }
155
+
156
+ // 2. Check forced success masking (|| true, || :)
157
+ if (SILENT_EXIT_ZERO_PATTERN.test(command)) {
158
+ return {
159
+ masked: true,
160
+ kind: "exit-zero-forced",
161
+ pattern: "|| true",
162
+ reason: "silent-error-masked",
163
+ detail: "Command forces a success exit code via logical OR fallback (|| true or || :)",
164
+ };
165
+ }
166
+
167
+ // 3. Check shell errexit unsetting (set +e)
168
+ if (SET_PLUS_E_PATTERN.test(command)) {
169
+ return {
170
+ masked: true,
171
+ kind: "errexit-disabled",
172
+ pattern: "set +e",
173
+ reason: "silent-error-masked",
174
+ detail: "Command disables shell error-exit checking (set +e)",
175
+ };
176
+ }
177
+
178
+ return { masked: false };
179
+ }
180
+
181
+ function unwrapExecutableFromSegment(seg) {
182
+ const words = seg.filter((t) => t.kind === "word").map((t) => t.value);
183
+ const takesValue = new Set(["-u", "--user", "-g", "--group", "-p", "-s", "--signal", "-k", "--kill-after", "-o", "-e", "-i"]);
184
+ let i = 0;
185
+ while (i < words.length) {
186
+ const val = words[i];
187
+ if (val === "then" || val === "do" || val === "{" || val === "!" || val === "}") {
188
+ i += 1;
189
+ continue;
190
+ }
191
+ if (val === "env") {
192
+ i += 1;
193
+ while (i < words.length && words[i].includes("=") && !words[i].startsWith("-")) i += 1;
194
+ continue;
195
+ }
196
+ if (val.includes("=") && !val.startsWith("-")) {
197
+ i += 1;
198
+ continue;
199
+ }
200
+ if (!WRAPPERS.has(val)) {
201
+ const parts = val.replace(/\\/g, "/").split("/");
202
+ return parts[parts.length - 1] || "";
203
+ }
204
+ i += 1;
205
+ while (i < words.length && words[i].startsWith("-")) {
206
+ const flag = words[i];
207
+ if (flag === "--") { i += 1; break; }
208
+ if (takesValue.has(flag)) i += 2;
209
+ else i += 1;
210
+ }
211
+ if (val === "timeout" && i < words.length) i += 1;
212
+ if (val === "nice" && i < words.length && /^[0-9-]+$/.test(words[i])) i += 1;
213
+ }
214
+ return "";
215
+ }
216
+
217
+ /**
218
+ * Scans tokens for dangerous downloader-to-shell or destructive SQL pipelines.
219
+ *
220
+ * @param {Array<{ kind: string, value: string }>} tokens
221
+ * @returns {{ dangerousPipeline: boolean, kind?: "curl-pipe" | "destructive-sql", reason?: string }}
222
+ */
223
+ export function inspectPipelines(tokens) {
224
+ if (!Array.isArray(tokens)) return { dangerousPipeline: false };
225
+
226
+ let segment = [];
227
+ let pipelineHasDownloader = false;
228
+ let pipelineHasDestructiveSql = false;
229
+ let inPipeline = false;
230
+
231
+ for (const token of tokens) {
232
+ if (token.kind === "op" && SPLIT_OPS.has(token.value)) {
233
+ const cmd = unwrapExecutableFromSegment(segment);
234
+ if (inPipeline && (SHELLS.has(cmd) || INTERPRETERS.has(cmd)) && pipelineHasDownloader) {
235
+ return { dangerousPipeline: true, kind: "curl-pipe", reason: "curl-pipe" };
236
+ }
237
+ if (inPipeline && DB_CLIENTS.has(cmd) && pipelineHasDestructiveSql) {
238
+ return { dangerousPipeline: true, kind: "destructive-sql", reason: "destructive-sql" };
239
+ }
240
+
241
+ const continues = token.value === "|" || token.value === "|&";
242
+ if (continues) {
243
+ pipelineHasDownloader = pipelineHasDownloader || cmd === "curl" || cmd === "wget";
244
+ const segText = segment.filter((t) => t.kind === "word").map((t) => t.value).join(" ");
245
+ pipelineHasDestructiveSql = pipelineHasDestructiveSql || DESTRUCTIVE_SQL.test(segText);
246
+ inPipeline = true;
247
+ } else {
248
+ pipelineHasDownloader = false;
249
+ pipelineHasDestructiveSql = false;
250
+ inPipeline = false;
251
+ }
252
+ segment = [];
253
+ continue;
254
+ }
255
+ segment.push(token);
256
+ }
257
+
258
+ const finalCmd = unwrapExecutableFromSegment(segment);
259
+ if (inPipeline && (SHELLS.has(finalCmd) || INTERPRETERS.has(finalCmd)) && pipelineHasDownloader) {
260
+ return { dangerousPipeline: true, kind: "curl-pipe", reason: "curl-pipe" };
261
+ }
262
+ if (inPipeline && DB_CLIENTS.has(finalCmd) && pipelineHasDestructiveSql) {
263
+ return { dangerousPipeline: true, kind: "destructive-sql", reason: "destructive-sql" };
264
+ }
265
+
266
+ return { dangerousPipeline: false };
267
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-approval-gate",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Host-only command safety gate for DeepSeek Harness: blocks recognized dangerous operations and requests DSH approval when a command cannot be inspected.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -51,4 +51,4 @@
51
51
  "approval-gate",
52
52
  "safety"
53
53
  ]
54
- }
54
+ }