@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
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,
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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.
|
|
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(
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
224
|
-
if (
|
|
225
|
-
return
|
|
226
|
-
}
|
|
227
|
-
if (
|
|
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
|
-
|
|
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 (
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
456
|
-
|
|
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(
|
|
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
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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(
|
|
499
|
-
|
|
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 =
|
|
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
|
+
}
|
package/lib/loopback.js
ADDED
|
@@ -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
|
+
}
|