@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/CHANGELOG.md +11 -0
- package/lib/dispatcher.js +208 -0
- package/lib/index.js +31 -0
- package/lib/inspect.js +103 -160
- package/lib/loopback.js +85 -0
- package/lib/messages.js +43 -1
- package/lib/parsers/git.js +321 -0
- package/lib/parsers/shell-cmds.js +267 -0
- package/package.json +2 -2
|
@@ -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.
|
|
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
|
+
}
|