feat: use Landlock for sandboxing on Linux in TypeScript CLI (#763)
Building on top of https://github.com/openai/codex/pull/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: 
This commit is contained in:
@@ -4,6 +4,7 @@ import type { ParseEntry } from "shell-quote";
|
|||||||
|
|
||||||
import { process_patch } from "./apply-patch.js";
|
import { process_patch } from "./apply-patch.js";
|
||||||
import { SandboxType } from "./sandbox/interface.js";
|
import { SandboxType } from "./sandbox/interface.js";
|
||||||
|
import { execWithLandlock } from "./sandbox/landlock.js";
|
||||||
import { execWithSeatbelt } from "./sandbox/macos-seatbelt.js";
|
import { execWithSeatbelt } from "./sandbox/macos-seatbelt.js";
|
||||||
import { exec as rawExec } from "./sandbox/raw-exec.js";
|
import { exec as rawExec } from "./sandbox/raw-exec.js";
|
||||||
import { formatCommandForDisplay } from "../../format-command.js";
|
import { formatCommandForDisplay } from "../../format-command.js";
|
||||||
@@ -42,26 +43,30 @@ export function exec(
|
|||||||
sandbox: SandboxType,
|
sandbox: SandboxType,
|
||||||
abortSignal?: AbortSignal,
|
abortSignal?: AbortSignal,
|
||||||
): Promise<ExecResult> {
|
): Promise<ExecResult> {
|
||||||
// This is a temporary measure to understand what are the common base commands
|
|
||||||
// until we start persisting and uploading rollouts
|
|
||||||
|
|
||||||
const opts: SpawnOptions = {
|
const opts: SpawnOptions = {
|
||||||
timeout: timeoutInMillis || DEFAULT_TIMEOUT_MS,
|
timeout: timeoutInMillis || DEFAULT_TIMEOUT_MS,
|
||||||
...(requiresShell(cmd) ? { shell: true } : {}),
|
...(requiresShell(cmd) ? { shell: true } : {}),
|
||||||
...(workdir ? { cwd: workdir } : {}),
|
...(workdir ? { cwd: workdir } : {}),
|
||||||
};
|
};
|
||||||
// Merge default writable roots with any user-specified ones.
|
|
||||||
const writableRoots = [
|
|
||||||
process.cwd(),
|
|
||||||
os.tmpdir(),
|
|
||||||
...additionalWritableRoots,
|
|
||||||
];
|
|
||||||
if (sandbox === SandboxType.MACOS_SEATBELT) {
|
|
||||||
return execWithSeatbelt(cmd, opts, writableRoots, abortSignal);
|
|
||||||
}
|
|
||||||
|
|
||||||
// SandboxType.NONE (or any other) falls back to the raw exec implementation
|
switch (sandbox) {
|
||||||
return rawExec(cmd, opts, abortSignal);
|
case SandboxType.NONE: {
|
||||||
|
// SandboxType.NONE uses the raw exec implementation.
|
||||||
|
return rawExec(cmd, opts, abortSignal);
|
||||||
|
}
|
||||||
|
case SandboxType.MACOS_SEATBELT: {
|
||||||
|
// Merge default writable roots with any user-specified ones.
|
||||||
|
const writableRoots = [
|
||||||
|
process.cwd(),
|
||||||
|
os.tmpdir(),
|
||||||
|
...additionalWritableRoots,
|
||||||
|
];
|
||||||
|
return execWithSeatbelt(cmd, opts, writableRoots, abortSignal);
|
||||||
|
}
|
||||||
|
case SandboxType.LINUX_LANDLOCK: {
|
||||||
|
return execWithLandlock(cmd, opts, additionalWritableRoots, abortSignal);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function execApplyPatch(
|
export function execApplyPatch(
|
||||||
|
|||||||
@@ -303,6 +303,11 @@ async function getSandbox(runInSandbox: boolean): Promise<SandboxType> {
|
|||||||
"Sandbox was mandated, but 'sandbox-exec' was not found in PATH!",
|
"Sandbox was mandated, but 'sandbox-exec' was not found in PATH!",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else if (process.platform === "linux") {
|
||||||
|
// TODO: Need to verify that the Landlock sandbox is working. For example,
|
||||||
|
// using Landlock in a Linux Docker container from a macOS host may not
|
||||||
|
// work.
|
||||||
|
return SandboxType.LINUX_LANDLOCK;
|
||||||
} else if (CODEX_UNSAFE_ALLOW_NO_SANDBOX) {
|
} else if (CODEX_UNSAFE_ALLOW_NO_SANDBOX) {
|
||||||
// Allow running without a sandbox if the user has explicitly marked the
|
// Allow running without a sandbox if the user has explicitly marked the
|
||||||
// environment as already being sufficiently locked-down.
|
// environment as already being sufficiently locked-down.
|
||||||
|
|||||||
173
codex-cli/src/utils/agent/sandbox/landlock.ts
Normal file
173
codex-cli/src/utils/agent/sandbox/landlock.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import type { ExecResult } from "./interface.js";
|
||||||
|
import type { SpawnOptions } from "child_process";
|
||||||
|
|
||||||
|
import { exec } from "./raw-exec.js";
|
||||||
|
import { execFile } from "child_process";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { log } from "src/utils/logger/log.js";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs Landlock with the following permissions:
|
||||||
|
* - can read any file on disk
|
||||||
|
* - can write to process.cwd()
|
||||||
|
* - can write to the platform user temp folder
|
||||||
|
* - can write to any user-provided writable root
|
||||||
|
*/
|
||||||
|
export async function execWithLandlock(
|
||||||
|
cmd: Array<string>,
|
||||||
|
opts: SpawnOptions,
|
||||||
|
userProvidedWritableRoots: ReadonlyArray<string>,
|
||||||
|
abortSignal?: AbortSignal,
|
||||||
|
): Promise<ExecResult> {
|
||||||
|
const sandboxExecutable = await getSandboxExecutable();
|
||||||
|
|
||||||
|
const extraSandboxPermissions = userProvidedWritableRoots.flatMap(
|
||||||
|
(root: string) => ["--sandbox-permission", `disk-write-folder=${root}`],
|
||||||
|
);
|
||||||
|
|
||||||
|
const fullCommand = [
|
||||||
|
sandboxExecutable,
|
||||||
|
"--sandbox-permission",
|
||||||
|
"disk-full-read-access",
|
||||||
|
|
||||||
|
"--sandbox-permission",
|
||||||
|
"disk-write-cwd",
|
||||||
|
|
||||||
|
"--sandbox-permission",
|
||||||
|
"disk-write-platform-user-temp-folder",
|
||||||
|
|
||||||
|
...extraSandboxPermissions,
|
||||||
|
|
||||||
|
"--",
|
||||||
|
...cmd,
|
||||||
|
];
|
||||||
|
|
||||||
|
return exec(fullCommand, opts, abortSignal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily initialized promise that resolves to the absolute path of the
|
||||||
|
* architecture-specific Landlock helper binary.
|
||||||
|
*/
|
||||||
|
let sandboxExecutablePromise: Promise<string> | null = null;
|
||||||
|
|
||||||
|
async function detectSandboxExecutable(): Promise<string> {
|
||||||
|
// Find the executable relative to the package.json file.
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
let dir: string = path.dirname(__filename);
|
||||||
|
|
||||||
|
// Ascend until package.json is found or we reach the filesystem root.
|
||||||
|
// eslint-disable-next-line no-constant-condition
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await fs.promises.access(
|
||||||
|
path.join(dir, "package.json"),
|
||||||
|
fs.constants.F_OK,
|
||||||
|
);
|
||||||
|
break; // Found the package.json ⇒ dir is our project root.
|
||||||
|
} catch {
|
||||||
|
// keep searching
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = path.dirname(dir);
|
||||||
|
if (parent === dir) {
|
||||||
|
throw new Error("Unable to locate package.json");
|
||||||
|
}
|
||||||
|
dir = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sandboxExecutable = getLinuxSandboxExecutableForCurrentArchitecture();
|
||||||
|
const candidate = path.join(dir, "bin", sandboxExecutable);
|
||||||
|
try {
|
||||||
|
await fs.promises.access(candidate, fs.constants.X_OK);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`${candidate} not found or not executable`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Will throw if the executable is not working in this environment.
|
||||||
|
await verifySandboxExecutable(candidate);
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ERROR_WHEN_LANDLOCK_NOT_SUPPORTED = `\
|
||||||
|
The combination of seccomp/landlock that Codex uses for sandboxing is not
|
||||||
|
supported in this environment.
|
||||||
|
|
||||||
|
If you are running in a Docker container, you may want to try adding
|
||||||
|
restrictions to your Docker container such that it provides your desired
|
||||||
|
sandboxing guarantees and then run Codex with the
|
||||||
|
--dangerously-auto-approve-everything option inside the container.
|
||||||
|
|
||||||
|
If you are running on an older Linux kernel that does not support newer
|
||||||
|
features of seccomp/landlock, you will have to update your kernel to a newer
|
||||||
|
version.
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Now that we have the path to the executable, make sure that it works in
|
||||||
|
* this environment. For example, when running a Linux Docker container from
|
||||||
|
* macOS like so:
|
||||||
|
*
|
||||||
|
* docker run -it alpine:latest /bin/sh
|
||||||
|
*
|
||||||
|
* Running `codex-linux-sandbox-x64 -- true` in the container fails with:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* Error: sandbox error: seccomp setup error
|
||||||
|
*
|
||||||
|
* Caused by:
|
||||||
|
* 0: seccomp setup error
|
||||||
|
* 1: Error calling `seccomp`: Invalid argument (os error 22)
|
||||||
|
* 2: Invalid argument (os error 22)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
function verifySandboxExecutable(sandboxExecutable: string): Promise<void> {
|
||||||
|
// Note we are running `true` rather than `bash -lc true` because we want to
|
||||||
|
// ensure we run an executable, not a shell built-in. Note that `true` should
|
||||||
|
// always be available in a POSIX environment.
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const args = ["--", "true"];
|
||||||
|
execFile(sandboxExecutable, args, (error, stdout, stderr) => {
|
||||||
|
if (error) {
|
||||||
|
log(
|
||||||
|
`Sandbox check failed for ${sandboxExecutable} ${args.join(" ")}: ${error}`,
|
||||||
|
);
|
||||||
|
log(`stdout: ${stdout}`);
|
||||||
|
log(`stderr: ${stderr}`);
|
||||||
|
reject(new Error(ERROR_WHEN_LANDLOCK_NOT_SUPPORTED));
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the absolute path to the architecture-specific Landlock helper
|
||||||
|
* binary. (Could be a rejected promise if not found.)
|
||||||
|
*/
|
||||||
|
function getSandboxExecutable(): Promise<string> {
|
||||||
|
if (!sandboxExecutablePromise) {
|
||||||
|
sandboxExecutablePromise = detectSandboxExecutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
return sandboxExecutablePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return name of the native executable to use for Linux sandboxing. */
|
||||||
|
function getLinuxSandboxExecutableForCurrentArchitecture(): string {
|
||||||
|
switch (process.arch) {
|
||||||
|
case "arm64":
|
||||||
|
return "codex-linux-sandbox-arm64";
|
||||||
|
case "x64":
|
||||||
|
return "codex-linux-sandbox-x64";
|
||||||
|
// Fall back to the x86_64 build for anything else – it will obviously
|
||||||
|
// fail on incompatible systems but gives a sane error message rather
|
||||||
|
// than crashing earlier.
|
||||||
|
default:
|
||||||
|
return "codex-linux-sandbox-x64";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user