Skip to content

Commit a4b51f6

Browse files
authored
feat: use Landlock for sandboxing on Linux in TypeScript CLI (#763)
Building on top of #757, this PR updates Codex to use the Landlock executor binary for sandboxing in the Node.js CLI. Note that Codex has to be invoked with either `--full-auto` or `--auto-edit` to activate sandboxing. (Using `--suggest` or `--dangerously-auto-approve-everything` ensures the sandboxing codepath will not be exercised.) When I tested this on a Linux host (specifically, `Ubuntu 24.04.1 LTS`), things worked as expected: I ran Codex CLI with `--full-auto` and then asked it to do `echo 'hello mbolin' into hello_world.txt` and it succeeded without prompting me. However, in my testing, I discovered that the sandboxing did *not* work when using `--full-auto` in a Linux Docker container from a macOS host. I updated the code to throw a detailed error message when this happens: ![image](https://github.com/user-attachments/assets/e5b99def-f00e-4ade-a0c5-2394d30df52e)
1 parent 3f5975a commit a4b51f6

File tree

3 files changed

+197
-14
lines changed

3 files changed

+197
-14
lines changed

codex-cli/src/utils/agent/exec.ts

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { ParseEntry } from "shell-quote";
44

55
import { process_patch } from "./apply-patch.js";
66
import { SandboxType } from "./sandbox/interface.js";
7+
import { execWithLandlock } from "./sandbox/landlock.js";
78
import { execWithSeatbelt } from "./sandbox/macos-seatbelt.js";
89
import { exec as rawExec } from "./sandbox/raw-exec.js";
910
import { formatCommandForDisplay } from "../../format-command.js";
@@ -42,26 +43,30 @@ export function exec(
4243
sandbox: SandboxType,
4344
abortSignal?: AbortSignal,
4445
): Promise<ExecResult> {
45-
// This is a temporary measure to understand what are the common base commands
46-
// until we start persisting and uploading rollouts
47-
4846
const opts: SpawnOptions = {
4947
timeout: timeoutInMillis || DEFAULT_TIMEOUT_MS,
5048
...(requiresShell(cmd) ? { shell: true } : {}),
5149
...(workdir ? { cwd: workdir } : {}),
5250
};
53-
// Merge default writable roots with any user-specified ones.
54-
const writableRoots = [
55-
process.cwd(),
56-
os.tmpdir(),
57-
...additionalWritableRoots,
58-
];
59-
if (sandbox === SandboxType.MACOS_SEATBELT) {
60-
return execWithSeatbelt(cmd, opts, writableRoots, abortSignal);
61-
}
6251

63-
// SandboxType.NONE (or any other) falls back to the raw exec implementation
64-
return rawExec(cmd, opts, abortSignal);
52+
switch (sandbox) {
53+
case SandboxType.NONE: {
54+
// SandboxType.NONE uses the raw exec implementation.
55+
return rawExec(cmd, opts, abortSignal);
56+
}
57+
case SandboxType.MACOS_SEATBELT: {
58+
// Merge default writable roots with any user-specified ones.
59+
const writableRoots = [
60+
process.cwd(),
61+
os.tmpdir(),
62+
...additionalWritableRoots,
63+
];
64+
return execWithSeatbelt(cmd, opts, writableRoots, abortSignal);
65+
}
66+
case SandboxType.LINUX_LANDLOCK: {
67+
return execWithLandlock(cmd, opts, additionalWritableRoots, abortSignal);
68+
}
69+
}
6570
}
6671

6772
export function execApplyPatch(

codex-cli/src/utils/agent/handle-exec-command.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,11 @@ async function getSandbox(runInSandbox: boolean): Promise<SandboxType> {
303303
"Sandbox was mandated, but 'sandbox-exec' was not found in PATH!",
304304
);
305305
}
306+
} else if (process.platform === "linux") {
307+
// TODO: Need to verify that the Landlock sandbox is working. For example,
308+
// using Landlock in a Linux Docker container from a macOS host may not
309+
// work.
310+
return SandboxType.LINUX_LANDLOCK;
306311
} else if (CODEX_UNSAFE_ALLOW_NO_SANDBOX) {
307312
// Allow running without a sandbox if the user has explicitly marked the
308313
// environment as already being sufficiently locked-down.
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import type { ExecResult } from "./interface.js";
2+
import type { SpawnOptions } from "child_process";
3+
4+
import { exec } from "./raw-exec.js";
5+
import { execFile } from "child_process";
6+
import fs from "fs";
7+
import path from "path";
8+
import { log } from "src/utils/logger/log.js";
9+
import { fileURLToPath } from "url";
10+
11+
/**
12+
* Runs Landlock with the following permissions:
13+
* - can read any file on disk
14+
* - can write to process.cwd()
15+
* - can write to the platform user temp folder
16+
* - can write to any user-provided writable root
17+
*/
18+
export async function execWithLandlock(
19+
cmd: Array<string>,
20+
opts: SpawnOptions,
21+
userProvidedWritableRoots: ReadonlyArray<string>,
22+
abortSignal?: AbortSignal,
23+
): Promise<ExecResult> {
24+
const sandboxExecutable = await getSandboxExecutable();
25+
26+
const extraSandboxPermissions = userProvidedWritableRoots.flatMap(
27+
(root: string) => ["--sandbox-permission", `disk-write-folder=${root}`],
28+
);
29+
30+
const fullCommand = [
31+
sandboxExecutable,
32+
"--sandbox-permission",
33+
"disk-full-read-access",
34+
35+
"--sandbox-permission",
36+
"disk-write-cwd",
37+
38+
"--sandbox-permission",
39+
"disk-write-platform-user-temp-folder",
40+
41+
...extraSandboxPermissions,
42+
43+
"--",
44+
...cmd,
45+
];
46+
47+
return exec(fullCommand, opts, abortSignal);
48+
}
49+
50+
/**
51+
* Lazily initialized promise that resolves to the absolute path of the
52+
* architecture-specific Landlock helper binary.
53+
*/
54+
let sandboxExecutablePromise: Promise<string> | null = null;
55+
56+
async function detectSandboxExecutable(): Promise<string> {
57+
// Find the executable relative to the package.json file.
58+
const __filename = fileURLToPath(import.meta.url);
59+
let dir: string = path.dirname(__filename);
60+
61+
// Ascend until package.json is found or we reach the filesystem root.
62+
// eslint-disable-next-line no-constant-condition
63+
while (true) {
64+
try {
65+
// eslint-disable-next-line no-await-in-loop
66+
await fs.promises.access(
67+
path.join(dir, "package.json"),
68+
fs.constants.F_OK,
69+
);
70+
break; // Found the package.json ⇒ dir is our project root.
71+
} catch {
72+
// keep searching
73+
}
74+
75+
const parent = path.dirname(dir);
76+
if (parent === dir) {
77+
throw new Error("Unable to locate package.json");
78+
}
79+
dir = parent;
80+
}
81+
82+
const sandboxExecutable = getLinuxSandboxExecutableForCurrentArchitecture();
83+
const candidate = path.join(dir, "bin", sandboxExecutable);
84+
try {
85+
await fs.promises.access(candidate, fs.constants.X_OK);
86+
} catch {
87+
throw new Error(`${candidate} not found or not executable`);
88+
}
89+
90+
// Will throw if the executable is not working in this environment.
91+
await verifySandboxExecutable(candidate);
92+
return candidate;
93+
}
94+
95+
const ERROR_WHEN_LANDLOCK_NOT_SUPPORTED = `\
96+
The combination of seccomp/landlock that Codex uses for sandboxing is not
97+
supported in this environment.
98+
99+
If you are running in a Docker container, you may want to try adding
100+
restrictions to your Docker container such that it provides your desired
101+
sandboxing guarantees and then run Codex with the
102+
--dangerously-auto-approve-everything option inside the container.
103+
104+
If you are running on an older Linux kernel that does not support newer
105+
features of seccomp/landlock, you will have to update your kernel to a newer
106+
version.
107+
`;
108+
109+
/**
110+
* Now that we have the path to the executable, make sure that it works in
111+
* this environment. For example, when running a Linux Docker container from
112+
* macOS like so:
113+
*
114+
* docker run -it alpine:latest /bin/sh
115+
*
116+
* Running `codex-linux-sandbox-x64 -- true` in the container fails with:
117+
*
118+
* ```
119+
* Error: sandbox error: seccomp setup error
120+
*
121+
* Caused by:
122+
* 0: seccomp setup error
123+
* 1: Error calling `seccomp`: Invalid argument (os error 22)
124+
* 2: Invalid argument (os error 22)
125+
* ```
126+
*/
127+
function verifySandboxExecutable(sandboxExecutable: string): Promise<void> {
128+
// Note we are running `true` rather than `bash -lc true` because we want to
129+
// ensure we run an executable, not a shell built-in. Note that `true` should
130+
// always be available in a POSIX environment.
131+
return new Promise((resolve, reject) => {
132+
const args = ["--", "true"];
133+
execFile(sandboxExecutable, args, (error, stdout, stderr) => {
134+
if (error) {
135+
log(
136+
`Sandbox check failed for ${sandboxExecutable} ${args.join(" ")}: ${error}`,
137+
);
138+
log(`stdout: ${stdout}`);
139+
log(`stderr: ${stderr}`);
140+
reject(new Error(ERROR_WHEN_LANDLOCK_NOT_SUPPORTED));
141+
} else {
142+
resolve();
143+
}
144+
});
145+
});
146+
}
147+
148+
/**
149+
* Returns the absolute path to the architecture-specific Landlock helper
150+
* binary. (Could be a rejected promise if not found.)
151+
*/
152+
function getSandboxExecutable(): Promise<string> {
153+
if (!sandboxExecutablePromise) {
154+
sandboxExecutablePromise = detectSandboxExecutable();
155+
}
156+
157+
return sandboxExecutablePromise;
158+
}
159+
160+
/** @return name of the native executable to use for Linux sandboxing. */
161+
function getLinuxSandboxExecutableForCurrentArchitecture(): string {
162+
switch (process.arch) {
163+
case "arm64":
164+
return "codex-linux-sandbox-arm64";
165+
case "x64":
166+
return "codex-linux-sandbox-x64";
167+
// Fall back to the x86_64 build for anything else – it will obviously
168+
// fail on incompatible systems but gives a sane error message rather
169+
// than crashing earlier.
170+
default:
171+
return "codex-linux-sandbox-x64";
172+
}
173+
}

0 commit comments

Comments
 (0)