@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.
@@ -0,0 +1,321 @@
1
+ // lib/parsers/git.js
2
+ // Specialized Git AST and semantics parser with classification of read, local mutation,
3
+ // remote operations, and destructive commands.
4
+ // Resolves: #125, #103
5
+
6
+ export const GIT_CATEGORY_READ = "read";
7
+ export const GIT_CATEGORY_MUTATE_LOCAL = "mutate_local";
8
+ export const GIT_CATEGORY_REMOTE = "remote";
9
+ export const GIT_CATEGORY_DESTRUCTIVE = "destructive";
10
+
11
+ const GLOBAL_FLAGS_WITH_VALUE = new Set([
12
+ "-C",
13
+ "-c",
14
+ "--git-dir",
15
+ "--work-tree",
16
+ "--namespace",
17
+ "--exec-path",
18
+ "--super-prefix",
19
+ "--config-env",
20
+ ]);
21
+
22
+ const READ_ONLY_VERBS = new Set([
23
+ "status",
24
+ "log",
25
+ "diff",
26
+ "show",
27
+ "rev-parse",
28
+ "describe",
29
+ "cat-file",
30
+ "ls-files",
31
+ "ls-tree",
32
+ "shortlog",
33
+ "blame",
34
+ "check-ignore",
35
+ "check-ref-format",
36
+ "var",
37
+ "version",
38
+ "help",
39
+ "symbolic-ref",
40
+ "name-rev",
41
+ "merge-base",
42
+ "count-objects",
43
+ "diff-index",
44
+ "diff-tree",
45
+ "for-each-ref",
46
+ "rev-list",
47
+ ]);
48
+
49
+ const LOCAL_MUTATE_VERBS = new Set([
50
+ "add",
51
+ "commit",
52
+ "checkout",
53
+ "switch",
54
+ "branch",
55
+ "tag",
56
+ "stash",
57
+ "merge",
58
+ "rebase",
59
+ "cherry-pick",
60
+ "revert",
61
+ "mv",
62
+ "rm",
63
+ "init",
64
+ "apply",
65
+ "am",
66
+ ]);
67
+
68
+ const REMOTE_VERBS = new Set([
69
+ "push",
70
+ "pull",
71
+ "fetch",
72
+ "clone",
73
+ "remote",
74
+ "submodule",
75
+ ]);
76
+
77
+ /**
78
+ * Extracts global git options and isolates the git subcommand verb and its arguments.
79
+ *
80
+ * @param {string[]} argv - Array of tokens starting with 'git' or the first flag/verb.
81
+ * @returns {{ verb: string, args: string[], globalOptions: Record<string, string>, rawArgv: string[] }}
82
+ */
83
+ export function parseGitArgv(argv) {
84
+ if (!Array.isArray(argv) || argv.length === 0) {
85
+ return { verb: "", args: [], globalOptions: {}, rawArgv: [] };
86
+ }
87
+
88
+ const rawArgv = [...argv];
89
+ let idx = rawArgv[0] === "git" ? 1 : 0;
90
+ const globalOptions = {};
91
+
92
+ while (idx < rawArgv.length && rawArgv[idx].startsWith("-")) {
93
+ const flag = rawArgv[idx];
94
+ if (flag === "--") {
95
+ idx += 1;
96
+ break;
97
+ }
98
+ if (GLOBAL_FLAGS_WITH_VALUE.has(flag)) {
99
+ globalOptions[flag] = rawArgv[idx + 1] || "";
100
+ idx += 2;
101
+ } else if (flag.startsWith("--") && flag.includes("=")) {
102
+ const eqIdx = flag.indexOf("=");
103
+ globalOptions[flag.slice(0, eqIdx)] = flag.slice(eqIdx + 1);
104
+ idx += 1;
105
+ } else if (flag.length > 2 && flag[1] !== "-" && flag.startsWith("-C")) {
106
+ globalOptions["-C"] = flag.slice(2);
107
+ idx += 1;
108
+ } else {
109
+ globalOptions[flag] = "true";
110
+ idx += 1;
111
+ }
112
+ }
113
+
114
+ const verb = rawArgv[idx] || "";
115
+ const args = rawArgv.slice(idx + 1);
116
+
117
+ return { verb, args, globalOptions, rawArgv };
118
+ }
119
+
120
+ /**
121
+ * Checks whether an argument contains a specific short flag (e.g. 'f' in '-fdx').
122
+ *
123
+ * @param {string[]} args
124
+ * @param {string} flagChar
125
+ * @returns {boolean}
126
+ */
127
+ function hasShortFlag(args, flagChar) {
128
+ for (const a of args) {
129
+ if (a === "--") break;
130
+ if (a.length > 1 && a[0] === "-" && a[1] !== "-" && a.includes(flagChar)) {
131
+ return true;
132
+ }
133
+ }
134
+ return false;
135
+ }
136
+
137
+ /**
138
+ * Analyzes and classifies a git subcommand and its arguments.
139
+ *
140
+ * @param {string} verb
141
+ * @param {string[]} args
142
+ * @returns {{
143
+ * verb: string,
144
+ * category: "read" | "mutate_local" | "remote" | "destructive",
145
+ * isDestructive: boolean,
146
+ * isRemote: boolean,
147
+ * isForce: boolean,
148
+ * reason?: string,
149
+ * detail?: string
150
+ * }}
151
+ */
152
+ export function classifyGitOperation(verb, args = []) {
153
+ if (!verb) {
154
+ return {
155
+ verb: "",
156
+ category: GIT_CATEGORY_READ,
157
+ isDestructive: false,
158
+ isRemote: false,
159
+ isForce: false,
160
+ };
161
+ }
162
+
163
+ const subArgs = Array.isArray(args) ? args : [];
164
+
165
+ // 1. Destructive operations check
166
+ if (verb === "reset" && subArgs.includes("--hard")) {
167
+ return {
168
+ verb,
169
+ category: GIT_CATEGORY_DESTRUCTIVE,
170
+ isDestructive: true,
171
+ isRemote: false,
172
+ isForce: true,
173
+ reason: "git-reset-hard",
174
+ detail: "Destructive git reset --hard discards all uncommitted modifications",
175
+ };
176
+ }
177
+
178
+ if (verb === "clean" && (subArgs.includes("-f") || subArgs.includes("--force") || hasShortFlag(subArgs, "f"))) {
179
+ return {
180
+ verb,
181
+ category: GIT_CATEGORY_DESTRUCTIVE,
182
+ isDestructive: true,
183
+ isRemote: false,
184
+ isForce: true,
185
+ reason: "git-clean",
186
+ detail: "Destructive git clean removes untracked files irreversibly",
187
+ };
188
+ }
189
+
190
+ if (verb === "branch" && (subArgs.includes("-D") || (subArgs.includes("-d") && (subArgs.includes("-f") || subArgs.includes("--force"))))) {
191
+ return {
192
+ verb,
193
+ category: GIT_CATEGORY_DESTRUCTIVE,
194
+ isDestructive: true,
195
+ isRemote: false,
196
+ isForce: true,
197
+ reason: "git-branch-force-delete",
198
+ detail: "Forceful git branch deletion (-D) risks losing unmerged commits",
199
+ };
200
+ }
201
+
202
+ // 2. Remote operations (#103: git push vs local git operations)
203
+ if (verb === "push") {
204
+ const isForce = subArgs.some((a) => (
205
+ a === "--force" ||
206
+ a === "-f" ||
207
+ a.startsWith("--force-with-lease") ||
208
+ a === "--delete" ||
209
+ (a[0] === "+" && a.length > 1)
210
+ )) || hasShortFlag(subArgs, "f");
211
+
212
+ if (isForce) {
213
+ return {
214
+ verb,
215
+ category: GIT_CATEGORY_DESTRUCTIVE,
216
+ isDestructive: true,
217
+ isRemote: true,
218
+ isForce: true,
219
+ reason: "git-force-push",
220
+ detail: "Forceful git push overwrites remote repository commit history",
221
+ };
222
+ }
223
+
224
+ return {
225
+ verb,
226
+ category: GIT_CATEGORY_REMOTE,
227
+ isDestructive: false,
228
+ isRemote: true,
229
+ isForce: false,
230
+ reason: "git-remote-push",
231
+ detail: "Publishing commits to a remote repository via git push requires confirmation",
232
+ };
233
+ }
234
+
235
+ if (verb === "checkout" && (subArgs.includes("-f") || subArgs.includes("--force") || (subArgs.includes("--") && subArgs.includes(".")))) {
236
+ return {
237
+ verb,
238
+ category: GIT_CATEGORY_DESTRUCTIVE,
239
+ isDestructive: true,
240
+ isRemote: false,
241
+ isForce: true,
242
+ reason: "git-checkout-force",
243
+ detail: "Forceful checkout discards local working tree changes",
244
+ };
245
+ }
246
+
247
+ if (verb === "restore" && subArgs.includes("--worktree") && (subArgs.includes(".") || subArgs.includes("--staged"))) {
248
+ return {
249
+ verb,
250
+ category: GIT_CATEGORY_DESTRUCTIVE,
251
+ isDestructive: true,
252
+ isRemote: false,
253
+ isForce: true,
254
+ reason: "git-restore-worktree",
255
+ detail: "Restoring working tree files discards local changes",
256
+ };
257
+ }
258
+
259
+ if (REMOTE_VERBS.has(verb)) {
260
+ return {
261
+ verb,
262
+ category: GIT_CATEGORY_REMOTE,
263
+ isDestructive: false,
264
+ isRemote: true,
265
+ isForce: false,
266
+ };
267
+ }
268
+
269
+ if (LOCAL_MUTATE_VERBS.has(verb)) {
270
+ return {
271
+ verb,
272
+ category: GIT_CATEGORY_MUTATE_LOCAL,
273
+ isDestructive: false,
274
+ isRemote: false,
275
+ isForce: false,
276
+ };
277
+ }
278
+
279
+ if (READ_ONLY_VERBS.has(verb)) {
280
+ return {
281
+ verb,
282
+ category: GIT_CATEGORY_READ,
283
+ isDestructive: false,
284
+ isRemote: false,
285
+ isForce: false,
286
+ };
287
+ }
288
+
289
+ return {
290
+ verb,
291
+ category: GIT_CATEGORY_MUTATE_LOCAL,
292
+ isDestructive: false,
293
+ isRemote: false,
294
+ isForce: false,
295
+ };
296
+ }
297
+
298
+ /**
299
+ * Full inspection of a git command invocation.
300
+ *
301
+ * @param {string[]} argv - Tokenized arguments
302
+ * @returns {{
303
+ * verb: string,
304
+ * args: string[],
305
+ * category: "read" | "mutate_local" | "remote" | "destructive",
306
+ * isDestructive: boolean,
307
+ * isRemote: boolean,
308
+ * isForce: boolean,
309
+ * reason?: string,
310
+ * detail?: string
311
+ * }}
312
+ */
313
+ export function inspectGitCommand(argv) {
314
+ const parsed = parseGitArgv(argv);
315
+ const classification = classifyGitOperation(parsed.verb, parsed.args);
316
+ return {
317
+ ...classification,
318
+ args: parsed.args,
319
+ globalOptions: parsed.globalOptions,
320
+ };
321
+ }
@@ -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.12",
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
+ }