@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.
- package/CHANGELOG.md +20 -0
- package/lib/bands.js +16 -0
- package/lib/dispatcher.js +208 -0
- package/lib/engine.js +6 -1
- package/lib/index.js +139 -59
- package/lib/inspect.js +103 -160
- package/lib/loopback.js +85 -0
- package/lib/messages.js +43 -58
- 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
|
+
}
|