-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathcommand-sandbox.ts
More file actions
208 lines (194 loc) · 9.14 KB
/
Copy pathcommand-sandbox.ts
File metadata and controls
208 lines (194 loc) · 9.14 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
import * as path from "node:path";
import { execFileSync } from "node:child_process";
export interface SandboxCapabilities {
filesystemConfinement: boolean;
networkDenial: boolean;
// "unshare" is deliberately not offered as a mechanism even though the
// `unshare` binary itself is present on nearly every Linux system:
// creating a network namespace via plain `unshare --net` commonly
// requires privileges plain users don't have (fails with "Operation not
// permitted" on many stock kernel configs), which would make a sandboxed
// command fail outright instead of just running unsandboxed — worse than
// not attempting it. bubblewrap handles the unprivileged-namespace setup
// properly and is the only mechanism offered on Linux.
mechanism: "bubblewrap" | "sandbox-exec" | "none";
}
function commandExists(cmd: string): boolean {
try {
execFileSync(process.platform === "win32" ? "where" : "which", [cmd], { stdio: "ignore" });
return true;
} catch {
return false;
}
}
// bwrap being on PATH isn't sufficient on its own — modern Ubuntu (23.10+)
// restricts unprivileged user-namespace creation by default (AppArmor
// policy), which makes bwrap fail at runtime with a permission error even
// when it's installed. A plain `which bwrap` check wouldn't catch that and
// would report false confidence, so this actually runs a trivial sandboxed
// no-op and checks it really works.
function canUseBubblewrap(): boolean {
if (!commandExists("bwrap")) return false;
try {
execFileSync("bwrap", ["--unshare-all", "--dev", "/dev", "--proc", "/proc", "true"], {
stdio: "ignore",
timeout: 5000,
});
return true;
} catch {
return false;
}
}
function defaultAvailabilityCheck(cmd: string): boolean {
return cmd === "bwrap" ? canUseBubblewrap() : commandExists(cmd);
}
// `hasCommand` is injectable so tests can simulate "bwrap is/isn't usable"
// without actually shelling out.
export function detectSandboxCapabilities(
platform: NodeJS.Platform = process.platform,
hasCommand: (cmd: string) => boolean = defaultAvailabilityCheck
): SandboxCapabilities {
if (platform === "linux") {
if (hasCommand("bwrap")) return { filesystemConfinement: true, networkDenial: true, mechanism: "bubblewrap" };
return { filesystemConfinement: false, networkDenial: false, mechanism: "none" };
}
if (platform === "darwin") {
// Built into macOS — no install needed, so this should essentially
// always be available, but check anyway rather than assume.
if (hasCommand("sandbox-exec")) return { filesystemConfinement: true, networkDenial: true, mechanism: "sandbox-exec" };
return { filesystemConfinement: false, networkDenial: false, mechanism: "none" };
}
// Windows has no equivalent lightweight primitive: Windows Sandbox is a
// VM-like container requiring Pro/Enterprise, and Job Objects/restricted
// tokens don't confine the filesystem or network. Stays on the existing
// command-text blocklist plus resource-monitor.ts limits.
return { filesystemConfinement: false, networkDenial: false, mechanism: "none" };
}
export interface WrapCommandOptions {
workspaceRoot: string;
allowNetwork: boolean;
// Directory the command should start in. Defaults to the workspace root.
// bubblewrap is passed --chdir, which takes precedence over the working
// directory inherited from the spawning process, so a caller that resolved
// a subdirectory has to pass it through here — setting it only on the
// spawn options would be silently overridden. Callers are responsible for
// confining this to the workspace (they go through resolveSafePath); it is
// not re-validated here.
cwd?: string;
}
export interface WrappedCommand {
command: string;
args: string[];
}
// Wraps `command` (a shell command string, run via `sh -c` either way) so it
// executes inside an OS-level sandbox instead of directly. Returns null when
// no sandboxing mechanism is available — callers should fall back to
// running `command` unwrapped rather than failing outright.
export function wrapCommand(
command: string,
opts: WrapCommandOptions,
platform: NodeJS.Platform = process.platform,
hasCommand: (cmd: string) => boolean = defaultAvailabilityCheck
): WrappedCommand | null {
const caps = detectSandboxCapabilities(platform, hasCommand);
const root = path.resolve(opts.workspaceRoot);
const startDir = opts.cwd ? path.resolve(opts.cwd) : root;
if (caps.mechanism === "bubblewrap") {
const args = [
"--ro-bind",
"/",
"/",
"--dev",
"/dev",
"--proc",
"/proc",
// Bound through (not a fresh --tmpfs) so files written to the
// real /tmp before the sandboxed process starts — e.g. run_code's
// temp script file, written via the host's os.tmpdir() — are
// still visible inside the sandbox.
"--bind",
"/tmp",
"/tmp",
"--bind",
root,
root,
"--chdir",
startDir,
"--die-with-parent",
"--unshare-all",
];
if (opts.allowNetwork) args.push("--share-net");
args.push("sh", "-c", command);
return { command: "bwrap", args };
}
if (caps.mechanism === "sandbox-exec") {
return { command: "sandbox-exec", args: ["-p", buildMacSandboxProfile(root, opts.allowNetwork), "sh", "-c", command] };
}
return null;
}
function buildMacSandboxProfile(workspaceRoot: string, allowNetwork: boolean): string {
const escaped = workspaceRoot.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
return [
"(version 1)",
"(allow default)",
'(deny file-write* (subpath "/"))',
`(allow file-write* (subpath "${escaped}"))`,
// macOS's real temp dirs — os.tmpdir() resolves under
// /var/folders/.../T (symlinked from /private/var/folders), and
// /tmp itself is a symlink to /private/tmp. Both need to stay
// writable for the same reason /tmp does in the bubblewrap path
// above: run_code's temp script file lives there.
'(allow file-write* (subpath "/tmp"))',
'(allow file-write* (subpath "/private/tmp"))',
'(allow file-write* (subpath "/private/var/folders"))',
// The blanket file-write deny above also covers /dev — bubblewrap's
// Linux path gets a working /dev for free via --dev /dev, but
// sandbox-exec needs these listed explicitly. Without /dev/null
// specifically, ordinary tools that redirect to it internally
// (git being the one that surfaced this) fail with "could not open
// '/dev/null' for reading and writing: Operation not permitted".
'(allow file-write* (literal "/dev/null"))',
'(allow file-write* (literal "/dev/zero"))',
'(allow file-write* (literal "/dev/tty"))',
allowNetwork ? "(allow network*)" : "(deny network*)",
"",
].join("\n");
}
// Quotes a single argument so the shell that will run it treats it as one
// literal value. Used both to fold a wrapped {command, args} back into the
// single shell-command string that `child_process.exec`/`spawn(...,
// {shell:true})` expect, and by agent-tools.ts when it builds a fixed command
// around a *value* the model supplied (a path for `git diff`, a message for
// `git commit`).
//
// The two shells need different treatment, and getting this wrong in either
// direction is a bug:
//
// - POSIX `sh`: single quotes, with an embedded quote written as '\''.
// Double quotes would not be enough, because `$(...)` and backticks are
// still expanded inside them.
// - Windows `cmd.exe`: single quotes are not quote characters at all, so
// POSIX quoting there would corrupt ordinary arguments rather than protect
// them. Double quotes are the right tool: `&`, `|`, `<` and `>` are
// literal inside them, and `$(...)`/backticks mean nothing to cmd.exe.
// An embedded double quote is written as "" — the convention both cmd.exe
// and the argv parser of the program being launched understand.
export function shellQuote(arg: string, platform: NodeJS.Platform = process.platform): string {
if (platform === "win32") return `"${arg.replace(/"/g, '""')}"`;
return `'${arg.replace(/'/g, `'\\''`)}'`;
}
// Applies sandboxing to `command` if a mechanism is available on this
// platform, otherwise returns it unchanged — the one function agent-tools.ts
// actually calls before handing a command to exec/spawn. `platform`/
// `hasCommand` are forwarded to wrapCommand purely so tests can exercise
// this without depending on the host OS.
export function applySandbox(
command: string,
opts: WrapCommandOptions,
platform: NodeJS.Platform = process.platform,
hasCommand: (cmd: string) => boolean = defaultAvailabilityCheck
): string {
const wrapped = wrapCommand(command, opts, platform, hasCommand);
if (!wrapped) return command;
return [wrapped.command, ...wrapped.args].map((arg) => shellQuote(arg, platform)).join(" ");
}