Simplify and generalize drop-sudo approach

Signed-off-by: William Woodruff <[email protected]>
This commit is contained in:
William Woodruff
2026-08-13 15:33:59 -04:00
parent f7d5898e7e
commit 80894aef53
13 changed files with 495 additions and 1346 deletions
-3
View File
@@ -33,9 +33,6 @@ jobs:
- name: Test
run: pnpm test
- name: Test privileged Linux drop-sudo
run: pnpm run test:privileged
- name: Build bundles
run: pnpm run build
+3 -3
View File
@@ -160,7 +160,7 @@ The `safety-strategy` input determines how much access Codex receives on the run
See [Protecting your `OPENAI_API_KEY`](./docs/security.md#protecting-your-openai_api_key) on the Security page for important details on this topic.
- **`drop-sudo` (default)** — On Linux and macOS runners, the action revokes the default user’s `sudo` membership before invoking Codex. On Linux, Codex processes launched by the action also run with only the user’s primary group and without process capabilities. This change lasts for the rest of the job, so subsequent steps cannot rely on `sudo`. This is usually the safest choice on GitHub-hosted runners.
- **`drop-sudo` (default)** — On Linux and macOS runners, the action revokes the default user’s `sudo` membership before invoking Codex. On Linux, it also removes the user from the `docker` group and restricts access to the Docker daemon socket. These changes last for the rest of the job, so subsequent steps cannot rely on `sudo` or Docker. This is usually the safest choice on GitHub-hosted runners.
- **`unprivileged-user`** — Runs Codex as the user provided via `codex-user`. Use this if you manage your own runner with a pre-created unprivileged account. Ensure the user can read the repository checkout and any files Codex needs. See [`unprivileged-user.yml`](./examples/unprivileged-user.yml) for an example of how to configure such an account on `ubuntu-latest`.
- **`read-only`** — Executes Codex in a read-only sandbox. Codex can view files but cannot mutate the filesystem or access the network directly. The OpenAI API key still flows through the proxy, so Codex could read it if it can reach process memory.
- **`unsafe`** — No privilege reduction. Codex runs as the default `runner` user (which typically has `sudo`). Only use this when you fully trust the prompt. On Windows runners this is the only supported choice and the action will fail if another option is provided.
@@ -168,7 +168,7 @@ See [Protecting your `OPENAI_API_KEY`](./docs/security.md#protecting-your-openai
### Operating system support
- **Windows**: GitHub-hosted Windows runners lack a supported sandbox. Set `safety-strategy: unsafe`. The action validates this and exits early otherwise.
- **Linux/macOS**: All options for `safety-strategy` are supported. Linux `drop-sudo` expects a non-root runner with passwordless sudo and `setpriv` in `/usr/bin`; GitHub-hosted Ubuntu runners provide this setup. Again, if you pick `drop-sudo`, remember that later steps in your `job` that rely on `sudo` will fail. If you do need to run code that requires `sudo` after `openai/codex-action` has run, one option is to pipe the output of `openai/codex-action` to a fresh `job` on a new host and to continue your workflow from there.
- **Linux/macOS**: All options for `safety-strategy` are supported. Again, if you pick `drop-sudo`, remember that later steps in your `job` that rely on `sudo` or Linux Docker access will fail. If you do need to run code that requires those privileges after `openai/codex-action` has run, one option is to pipe the output of `openai/codex-action` to a fresh `job` on a new host and to continue your workflow from there.
- **GitHub-hosted Linux runners**: The action enables unprivileged user namespaces during setup and clears Ubuntu's AppArmor gate when present. This avoids the `bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted` failure seen on newer hosted images, including workflows that use the action once to bootstrap Codex and then call `codex` in later steps. Self-hosted Linux runners still need equivalent kernel support configured ahead of time.
## Outputs
@@ -193,7 +193,7 @@ jobs:
- To use a non-default Responses endpoint (for example Azure OpenAI), set `responses-api-endpoint` to the provider's URL while keeping `openai-api-key` populated; the proxy will still send `Authorization: Bearer <key>` upstream.
- If you want Codex to have access to a narrow set of privileged functionality, consider running a local MCP server that can perform these actions and configure Codex to use it.
- If you need more control over the CLI invocation, pass flags through `codex-args` or create a `config.toml` in `codex-home`. Prefer a [permission profile](https://developers.openai.com/codex/permissions), starting with `:workspace` for workspace editing, over legacy sandbox flags for new integrations.
- You can call `codex` from subsequent scripts after using the action once with `openai-api-key`, but direct commands do not pass through the action’s reduced Linux process identity.
- Once `openai/codex-action` is run once with `openai-api-key`, you can also call `codex` from subsequent scripts in your job. (You can omit `prompt` and `prompt-file` from the action in this case.)
## Azure
+4 -6
View File
@@ -76,9 +76,8 @@ inputs:
the default user before running Codex, and run Codex as that user. This
is only supported on Linux and macOS runners. This option is
irreversible: if the default user has sudo privileges, they will be
removed permanently for the duration of the job. On Linux, Codex
processes launched by this action also run with only the user's primary
group and without process capabilities.
removed permanently for the duration of the job. On Linux, access to
the Docker daemon is also removed for the duration of the job.
* `unprivileged-user` Run Codex as the specified user specified by the
`codex-user` option (the user must already exist). Note the caller is
responsible for ensuring the specified user has the privileges it needs
@@ -303,7 +302,7 @@ runs:
fi
- name: Drop sudo privilege, if appropriate
if: ${{ inputs['safety-strategy'] == 'drop-sudo' && inputs['openai-api-key'] != '' && (runner.os != 'Linux' || (inputs.prompt == '' && inputs['prompt-file'] == '')) }}
if: ${{ inputs['safety-strategy'] == 'drop-sudo' && inputs['openai-api-key'] != '' }}
shell: bash
env:
ACTION_PATH: ${{ github.action_path }}
@@ -311,7 +310,6 @@ runs:
case "${RUNNER_OS}" in
Linux)
node "$ACTION_PATH/dist/main.js" drop-sudo --user runner --group sudo
echo "::warning::Direct Codex commands in later steps do not pass through the action's reduced Linux process identity."
;;
macOS)
node "$ACTION_PATH/dist/main.js" drop-sudo --user runner --group admin
@@ -323,7 +321,7 @@ runs:
esac
- name: Verify sudo privilege removed
if: ${{ inputs['safety-strategy'] == 'drop-sudo' && inputs['openai-api-key'] != '' && (runner.os != 'Linux' || (inputs.prompt == '' && inputs['prompt-file'] == '')) }}
if: ${{ inputs['safety-strategy'] == 'drop-sudo' && inputs['openai-api-key'] != '' }}
shell: bash
run: |
if sudo -n true 2>/dev/null; then
+306 -521
View File
File diff suppressed because one or more lines are too long
+1 -2
View File
@@ -6,8 +6,7 @@
"scripts": {
"build": "esbuild src/main.ts --sourcemap=inline --format=cjs --bundle --platform=node --target=node20 --outdir=dist",
"check": "tsc --noEmit",
"test": "pnpm run build && node --test test/*.test.mjs",
"test:privileged": "pnpm run build && node --test --test-concurrency=1 test/privileged/*.test.mjs"
"test": "pnpm run build && node --test test/*.test.mjs"
},
"dependencies": {
"@actions/core": "^1.11.1",
+37 -35
View File
@@ -21,7 +21,7 @@ export interface DropSudoOptions {
const LINUX_PLATFORM = "linux";
const MACOS_PLATFORM = "darwin";
const SUDO_PATH = "/usr/bin/sudo";
const DOCKER_SOCKET_PATH = "/var/run/docker.sock";
export async function dropSudo(options: DropSudoOptions): Promise<void> {
const platform = process.platform;
@@ -40,15 +40,15 @@ export async function dropSudo(options: DropSudoOptions): Promise<void> {
await ensurePasswordlessSudo();
// `sudo -K` invalidates cached credentials but exits non-zero when no ticket
// exists yet. Ignore that failure so fresh runners don't blow up.
await execCommand(SUDO_PATH, ["-K"], { ignoreFailure: true });
await execCommand("sudo", ["-K"], { ignoreFailure: true });
const execArgs = [...process.execArgv];
const scriptPath = process.argv[1];
// Re-enter this command under sudo so the privilege-dropping work happens in a
// single place regardless of the host platform.
await execCommand(SUDO_PATH, [
await execCommand("sudo", [
"-n",
process.execPath,
"node",
...execArgs,
scriptPath,
"drop-sudo",
@@ -61,18 +61,7 @@ export async function dropSudo(options: DropSudoOptions): Promise<void> {
// Invalidate the sudo ticket again; ignore failures for the same reason as
// above (some environments return an error when no timestamp exists).
await execCommand(SUDO_PATH, ["-K"], { ignoreFailure: true });
}
export async function verifySudoUnavailable(): Promise<void> {
const result = await execCommand(SUDO_PATH, ["-n", "true"], {
capture: true,
ignoreFailure: true,
});
if (result.code === 0) {
throw new Error("Expected sudo to be disabled, but sudo succeeded.");
}
console.log("Confirmed sudo privilege is disabled.");
await execCommand("sudo", ["-K"], { ignoreFailure: true });
}
async function dropSudoWithPrivileges(options: DropSudoOptions): Promise<void> {
@@ -84,26 +73,39 @@ async function dropSudoWithPrivileges(options: DropSudoOptions): Promise<void> {
switch (process.platform) {
case LINUX_PLATFORM: {
if (await isUserInGroup(options.user, options.group)) {
if (await commandExists("deluser")) {
await execCommand("deluser", [options.user, options.group]);
console.log(
`Used 'deluser ${options.user} ${options.group}' to drop sudo privilege.`
);
} else if (await commandExists("gpasswd")) {
await execCommand("gpasswd", ["-d", options.user, options.group]);
console.log(
`Used 'gpasswd -d ${options.user} ${options.group}' to drop sudo privilege.`
);
for (const group of new Set([options.group, "docker"])) {
if (await isUserInGroup(options.user, group)) {
if (await commandExists("deluser")) {
await execCommand("deluser", [options.user, group]);
console.log(
`Used 'deluser ${options.user} ${group}' to drop ${group} privilege.`
);
} else if (await commandExists("gpasswd")) {
await execCommand("gpasswd", ["-d", options.user, group]);
console.log(
`Used 'gpasswd -d ${options.user} ${group}' to drop ${group} privilege.`
);
} else {
throw new Error("Neither deluser nor gpasswd available.");
}
changed = true;
} else {
throw new Error("Neither deluser nor gpasswd available.");
console.log(`${options.user} is not a member of the ${group} group.`);
}
changed = true;
} else {
console.log(
`${options.user} is not a member of the ${options.group} group.`
);
}
try {
// Existing runner processes retain their inherited docker group even
// after account membership changes, so revoke access on the socket too.
await fs.chmod(DOCKER_SOCKET_PATH, 0o600);
console.log(`Restricted ${DOCKER_SOCKET_PATH} to its owner.`);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
console.log(`${DOCKER_SOCKET_PATH} does not exist.`);
}
break;
}
case MACOS_PLATFORM: {
@@ -172,9 +174,9 @@ async function dropSudoWithPrivileges(options: DropSudoOptions): Promise<void> {
);
}
export async function ensurePasswordlessSudo(): Promise<void> {
async function ensurePasswordlessSudo(): Promise<void> {
try {
await execCommand(SUDO_PATH, ["-n", "true"], { capture: true });
await execCommand("sudo", ["-n", "true"], { capture: true });
} catch (error) {
throw new Error("Unexpected: passwordless sudo not available.");
}
-177
View File
@@ -1,177 +0,0 @@
import { randomBytes } from "node:crypto";
import { type ChildProcess } from "node:child_process";
const ENV_PATH = "/usr/bin/env";
const SETPRIV_PATH = "/usr/bin/setpriv";
const SHELL_PATH = "/bin/sh";
const SUDO_PATH = "/usr/bin/sudo";
const GATE_SCRIPT = [
`printf '%s\\n' "$1"`,
`IFS= read -r gate || exit 125`,
`[ "$gate" = "$2" ] || exit 125`,
`shift 2`,
`exec "$@"`,
].join("; ");
export interface GatedCodexCommand {
program: string;
args: Array<string>;
readyToken: string;
goToken: string;
}
/** Builds a Codex command that waits after entering the reduced Linux identity. */
export function buildGatedCodexCommand({
uid,
gid,
user,
home,
codexPath,
codexArgs,
}: {
uid: number;
gid: number;
user: string;
home: string;
codexPath: string;
codexArgs: Array<string>;
}): GatedCodexCommand {
const readyToken = `codex-action-ready-${randomBytes(16).toString("hex")}`;
const goToken = `codex-action-go-${randomBytes(16).toString("hex")}`;
return {
program: SUDO_PATH,
args: [
"-n",
"-E",
"--",
SETPRIV_PATH,
`--reuid=${uid}`,
`--regid=${gid}`,
"--clear-groups",
"--no-new-privs",
"--inh-caps=-all",
"--ambient-caps=-all",
"--bounding-set=-all",
"--",
ENV_PATH,
"-u",
"SUDO_COMMAND",
"-u",
"SUDO_USER",
"-u",
"SUDO_UID",
"-u",
"SUDO_GID",
"-u",
"ENV",
"-u",
"BASH_ENV",
"-u",
"SHELLOPTS",
`HOME=${home}`,
`USER=${user}`,
`LOGNAME=${user}`,
`PATH=${process.env.PATH ?? ""}`,
SHELL_PATH,
"-c",
GATE_SCRIPT,
"codex-action-gate",
readyToken,
goToken,
codexPath,
...codexArgs,
],
readyToken,
goToken,
};
}
export async function waitForLinuxGate(
child: ChildProcess,
readyToken: string
): Promise<void> {
const stdout = child.stdout;
if (stdout == null) {
throw new Error("Linux drop-sudo gate requires a stdout pipe.");
}
stdout.setEncoding("utf8");
await new Promise<void>((resolve, reject) => {
let buffered = "";
const cleanup = () => {
stdout.off("data", onData);
stdout.off("error", onError);
stdout.off("end", onEnd);
child.off("close", onClose);
};
const fail = (error: Error) => {
cleanup();
reject(error);
};
const onData = (chunk: string) => {
buffered += chunk;
if (buffered.length > 1024) {
fail(
new Error("Linux drop-sudo gate emitted an invalid readiness marker.")
);
return;
}
const newline = buffered.indexOf("\n");
if (newline < 0) {
return;
}
const line = buffered.slice(0, newline).replace(/\r$/, "");
if (line !== readyToken) {
fail(
new Error("Linux drop-sudo gate did not confirm the reduced identity.")
);
return;
}
const remainder = buffered.slice(newline + 1);
cleanup();
if (remainder.length > 0) {
process.stdout.write(remainder);
}
stdout.pipe(process.stdout, { end: false });
resolve();
};
const onError = (error: Error) => fail(error);
const onEnd = () =>
fail(new Error("Linux drop-sudo gate exited before becoming ready."));
const onClose = () =>
fail(new Error("Linux drop-sudo gate closed before becoming ready."));
stdout.on("data", onData);
stdout.once("error", onError);
stdout.once("end", onEnd);
child.once("close", onClose);
});
}
export async function endChildInput(
child: ChildProcess,
input: string
): Promise<void> {
const stdin = child.stdin;
if (stdin == null) {
throw new Error("Codex child process does not have a stdin pipe.");
}
await new Promise<void>((resolve, reject) => {
const onError = (error: Error) => {
stdin.off("finish", onFinish);
reject(error);
};
const onFinish = () => {
stdin.off("error", onError);
resolve();
};
stdin.once("error", onError);
stdin.once("finish", onFinish);
stdin.end(input);
});
}
+41 -133
View File
@@ -4,17 +4,6 @@ import path from "path";
import os from "os";
import { setOutput } from "@actions/core";
import { checkOutput } from "./checkOutput";
import {
dropSudo,
ensurePasswordlessSudo,
verifySudoUnavailable,
} from "./dropSudo";
import {
buildGatedCodexCommand,
endChildInput,
waitForLinuxGate,
type GatedCodexCommand,
} from "./linuxDropSudo";
export type PromptSource =
| {
@@ -97,31 +86,6 @@ export async function runCodexExec({
break;
}
const isLinuxDropSudo =
safetyStrategy === "drop-sudo" && process.platform === "linux";
if (isLinuxDropSudo) {
if (
typeof process.getuid !== "function" ||
typeof process.getgid !== "function"
) {
throw new Error("Linux drop-sudo requires POSIX user and group APIs.");
}
if (process.getuid() === 0) {
throw new Error(
"Linux drop-sudo cannot run Codex from a runner whose default user is root."
);
}
try {
await ensurePasswordlessSudo();
} catch {
throw new Error(
"Linux drop-sudo requires passwordless sudo before Codex starts. If an earlier drop-sudo invocation already removed sudo, run Codex in that invocation or use a fresh job."
);
}
}
const runAsUser: string | null =
safetyStrategy === "unprivileged-user" ? codexUser : null;
@@ -143,18 +107,10 @@ export async function runCodexExec({
extraArgs,
});
const commandPrefix: Array<string> = [];
let linuxDropSudoUser: string | null = null;
let linuxGate: GatedCodexCommand | null = null;
const command: Array<string> = [];
let pathToCodex = "codex";
if (isLinuxDropSudo) {
pathToCodex = (await checkOutput(["which", "codex"])).trim();
if (!pathToCodex) {
throw new Error("could not find 'codex' in PATH");
}
linuxDropSudoUser = os.userInfo().username;
} else if (safetyStrategy === "unprivileged-user") {
if (safetyStrategy === "unprivileged-user") {
if (codexUser == null) {
throw new Error(
"codexUser must be specified when using the 'unprivileged-user' safety strategy."
@@ -175,40 +131,41 @@ export async function runCodexExec({
throw new Error("could not find 'codex' in PATH");
}
commandPrefix.push("sudo", "-u", codexUser, "--");
command.push("sudo", "-u", codexUser, "--");
}
const codexArgs = [
command.push(
pathToCodex,
"exec",
"--skip-git-repo-check",
"--cd",
cd,
"--output-last-message",
outputFile.file,
];
outputFile.file
);
if (resolvedOutputSchema != null) {
codexArgs.push("--output-schema", resolvedOutputSchema.file);
command.push("--output-schema", resolvedOutputSchema.file);
}
if (model != null) {
codexArgs.push("--model", model);
command.push("--model", model);
}
if (effort != null) {
// https://github.com/openai/codex/blob/00debb6399eb51c4b9273f0bc012912c42fe6c91/docs/config.md#config
// https://github.com/openai/codex/blob/00debb6399eb51c4b9273f0bc012912c42fe6c91/docs/config.md#model_reasoning_effort
codexArgs.push("--config", `model_reasoning_effort="${effort}"`);
command.push("--config", `model_reasoning_effort="${effort}"`);
}
codexArgs.push(...extraArgs);
command.push(...extraArgs);
switch (permissionSelection.type) {
case "sandbox":
codexArgs.push("--sandbox", permissionSelection.mode);
command.push("--sandbox", permissionSelection.mode);
break;
case "profile":
codexArgs.push(
command.push(
"--config",
`default_permissions=${JSON.stringify(permissionSelection.name)}`
);
@@ -225,87 +182,38 @@ export async function runCodexExec({
extraEnv = `CODEX_HOME=${codexHome} `;
}
let program: string;
let command: Array<string>;
if (linuxDropSudoUser != null) {
const userInfo = os.userInfo();
linuxGate = buildGatedCodexCommand({
uid: process.getuid!(),
gid: process.getgid!(),
user: linuxDropSudoUser,
home: process.env.HOME ?? userInfo.homedir,
codexPath: pathToCodex,
codexArgs,
});
program = linuxGate.program;
command = linuxGate.args;
console.log(
`Running with Linux drop-sudo protection: ${extraEnv}${pathToCodex} ${codexArgs
.map((arg) => JSON.stringify(arg))
.join(" ")}`
);
} else {
const fullCommand = [...commandPrefix, pathToCodex, ...codexArgs];
program = fullCommand.shift()!;
command = fullCommand;
console.log(
`Running: ${extraEnv}${program} ${command
.map((arg) => JSON.stringify(arg))
.join(" ")}`
);
}
// Split the `program` from the `args` for `spawn()`.
const program = command.shift()!;
console.log(
`Running: ${extraEnv}${program} ${command
.map((a) => JSON.stringify(a))
.join(" ")}`
);
try {
const child = spawn(program, command, {
env,
stdio: ["pipe", linuxGate == null ? "inherit" : "pipe", "inherit"],
});
const spawned = new Promise<void>((resolve, reject) => {
child.once("spawn", resolve);
child.once("error", reject);
});
const completed = new Promise<number>((resolve, reject) => {
child.once("error", reject);
child.once("close", (code) => resolve(code ?? 1));
});
const gateReady =
linuxGate == null
? Promise.resolve()
: waitForLinuxGate(child, linuxGate.readyToken);
await new Promise((resolve, reject) => {
const child = spawn(program, command, {
env,
stdio: ["pipe", "inherit", "inherit"],
});
child.stdin.write(input);
child.stdin.end();
try {
await Promise.all([spawned, gateReady]);
if (linuxDropSudoUser != null) {
await dropSudo({
user: linuxDropSudoUser,
group: "sudo",
rootPhase: false,
});
await verifySudoUnavailable();
}
child.on("error", reject);
if (linuxGate != null && child.exitCode != null) {
throw new Error(
`${program} exited before privilege cleanup completed (code ${child.exitCode})`
);
}
const childInput =
linuxGate == null ? input : `${linuxGate.goToken}\n${input}`;
await endChildInput(child, childInput);
} catch (error) {
if (child.stdin != null && !child.stdin.destroyed) {
child.stdin.on("error", () => undefined);
child.stdin.end();
}
await completed.catch(() => undefined);
throw error;
}
child.on("close", async (code) => {
if (code !== 0) {
reject(new Error(`${program} exited with code ${code}`));
return;
}
const code = await completed;
if (code !== 0) {
throw new Error(`${program} exited with code ${code}`);
}
await finalizeExecution(outputFile, runAsUser);
try {
await finalizeExecution(outputFile, runAsUser);
resolve(undefined);
} catch (err) {
reject(err);
}
});
});
} finally {
await cleanupOutputSchema(resolvedOutputSchema);
}
-27
View File
@@ -1,27 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
const actionPath = fileURLToPath(new URL("../action.yml", import.meta.url));
const action = readFileSync(actionPath, "utf8");
function stepCondition(name) {
const marker = ` - name: ${name}\n`;
const start = action.indexOf(marker);
assert.notEqual(start, -1, `missing action step: ${name}`);
const next = action.indexOf("\n - name: ", start + marker.length);
const block = action.slice(start, next < 0 ? undefined : next);
const condition = block
.split("\n")
.find((line) => line.startsWith(" if: "));
assert.ok(condition, `missing condition for action step: ${name}`);
return condition.slice(" if: ".length);
}
test("keeps Linux prompt execution out of the pre-drop steps", () => {
const expected = `\${{ inputs['safety-strategy'] == 'drop-sudo' && inputs['openai-api-key'] != '' && (runner.os != 'Linux' || (inputs.prompt == '' && inputs['prompt-file'] == '')) }}`;
assert.equal(stepCondition("Drop sudo privilege, if appropriate"), expected);
assert.equal(stepCondition("Verify sudo privilege removed"), expected);
});
+102
View File
@@ -0,0 +1,102 @@
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import { existsSync, statSync } from "node:fs";
import { createInterface } from "node:readline";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
const mainPath = fileURLToPath(new URL("../dist/main.js", import.meta.url));
const dockerSocket = "/var/run/docker.sock";
const canTestDockerCleanup =
process.platform === "linux" &&
existsSync(dockerSocket) &&
spawnSync("sudo", ["-n", "true"]).status === 0;
function sudo(args) {
const result = spawnSync("sudo", ["-n", "--", ...args], {
encoding: "utf8",
timeout: 30_000,
});
assert.equal(result.status, 0, result.stderr);
return result.stdout.trim();
}
test(
"drop-sudo blocks Docker access even for inherited group memberships",
{ skip: !canTestDockerCleanup, timeout: 30_000 },
async (t) => {
const user = `codexdrop${process.pid}${Date.now().toString(36)}`;
const originalSocket = statSync(dockerSocket);
let userCreated = false;
let probe;
t.after(async () => {
if (probe != null && probe.exitCode == null) {
await new Promise((resolve) => {
probe.once("close", resolve);
probe.kill();
});
}
sudo(["chmod", (originalSocket.mode & 0o777).toString(8), dockerSocket]);
if (userCreated) {
sudo(["userdel", user]);
}
});
sudo(["useradd", "--no-create-home", "--groups", "sudo,docker", user]);
userCreated = true;
probe = spawn(
"sudo",
[
"-n",
"-u",
user,
"--",
process.execPath,
"-e",
`const { accessSync, constants } = require("node:fs");
function report() {
let accessible = true;
try {
accessSync(process.argv[1], constants.R_OK | constants.W_OK);
} catch {
accessible = false;
}
console.log(JSON.stringify({ groups: process.getgroups(), accessible }));
}
report();
process.stdin.once("data", report);`,
dockerSocket,
],
{ stdio: ["pipe", "pipe", "pipe"] }
);
const lines = createInterface({ input: probe.stdout })[
Symbol.asyncIterator
]();
const before = JSON.parse((await lines.next()).value);
assert.equal(before.accessible, true);
assert.equal(before.groups.includes(originalSocket.gid), true);
sudo([
process.execPath,
mainPath,
"drop-sudo",
"--root-phase",
"--user",
user,
"--group",
"sudo",
]);
const accountGroups = sudo(["id", "-nG", user]).split(/\s+/);
assert.equal(accountGroups.includes("sudo"), false);
assert.equal(accountGroups.includes("docker"), false);
assert.equal(statSync(dockerSocket).mode & 0o777, 0o600);
probe.stdin.end("check\n");
const after = JSON.parse((await lines.next()).value);
assert.deepEqual(after.groups, before.groups);
assert.equal(after.accessible, false);
}
);
-63
View File
@@ -1,63 +0,0 @@
import { spawnSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
const args = process.argv.slice(2);
const cdIndex = args.indexOf("--cd");
if (cdIndex < 0 || cdIndex + 1 >= args.length) {
throw new Error("missing --cd");
}
process.chdir(args[cdIndex + 1]);
const status = readFileSync("/proc/self/status", "utf8");
const statusField = (name) => {
const line = status
.split("\n")
.find((candidate) => candidate.startsWith(`${name}:`));
if (line == null) {
throw new Error(`missing ${name} in /proc/self/status`);
}
return line.slice(name.length + 1).trim();
};
const identity = {
uid: process.getuid(),
gid: process.getgid(),
groups: statusField("Groups"),
idGroups: spawnSync("/usr/bin/id", ["-G"], { encoding: "utf8" })
.stdout.trim(),
accountGroups: spawnSync("/usr/bin/id", ["-nG", process.env.USER], {
encoding: "utf8",
}).stdout.trim(),
capInh: statusField("CapInh"),
capPrm: statusField("CapPrm"),
capEff: statusField("CapEff"),
capBnd: statusField("CapBnd"),
capAmb: statusField("CapAmb"),
noNewPrivs: statusField("NoNewPrivs"),
sudoStatus: spawnSync("/usr/bin/sudo", ["-n", "true"]).status,
home: process.env.HOME,
cwd: process.cwd(),
supplementaryFileReadable: true,
};
try {
readFileSync(process.env.SUPPLEMENTARY_GROUP_FILE);
} catch {
identity.supplementaryFileReadable = false;
}
let prompt = "";
for await (const chunk of process.stdin) {
prompt += chunk;
}
const outputIndex = args.indexOf("--output-last-message");
if (outputIndex < 0 || outputIndex + 1 >= args.length) {
throw new Error("missing --output-last-message");
}
writeFileSync(args[outputIndex + 1], "fake final message\n");
writeFileSync(
process.env.CODEX_CAPTURE_PATH,
JSON.stringify({ identity, prompt })
);
-375
View File
@@ -1,375 +0,0 @@
import assert from "node:assert/strict";
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
const mainPath = fileURLToPath(
new URL("../../dist/main.js", import.meta.url)
);
const fakeCodexPath = fileURLToPath(
new URL("../fixtures/fakeCodexLinuxIdentity.mjs", import.meta.url)
);
const isLinux = process.platform === "linux";
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
encoding: "utf8",
timeout: 120_000,
...options,
});
assert.equal(
result.status,
0,
`${command} ${args.join(" ")} failed:\n${result.stderr ?? result.error ?? ""}`
);
return result;
}
function runSudo(args, options = {}) {
return run("/usr/bin/sudo", ["-n", "--", ...args], options);
}
function runAsUser(user, args, options = {}) {
return run("/usr/bin/sudo", ["-n", "-u", user, "--", ...args], options);
}
function spawnCodexAs({
uid,
gid,
user,
home,
binDir,
actionPath,
workspace,
outputPath,
prompt,
extraEnv = {},
}) {
return spawnSync(
"/usr/bin/sudo",
[
"-n",
"--",
"/usr/bin/setpriv",
`--reuid=${uid}`,
`--regid=${gid}`,
"--init-groups",
"--",
"/usr/bin/env",
`HOME=${home}`,
`USER=${user}`,
`LOGNAME=${user}`,
`PATH=${binDir}:${process.env.PATH ?? ""}`,
...Object.entries(extraEnv).map(([key, value]) => `${key}=${value}`),
process.execPath,
actionPath,
"run-codex-exec",
"--prompt", prompt,
"--prompt-file", "",
"--codex-home", "",
"--cd", workspace,
"--extra-args", "",
"--output-file", outputPath,
"--output-schema-file", "",
"--output-schema", "",
"--sandbox", "",
"--permission-profile", "",
"--model", "",
"--effort", "",
"--safety-strategy", "drop-sudo",
"--codex-user", "",
],
{ encoding: "utf8", timeout: 120_000 }
);
}
function registerCleanup(t, { user, groups, sudoersPath, tempDir }) {
t.after(() => {
const failures = [];
const attempt = (args, allowedStatuses = [0]) => {
const result = spawnSync("/usr/bin/sudo", ["-n", "--", ...args], {
encoding: "utf8",
});
if (!allowedStatuses.includes(result.status)) {
failures.push(
`${args.join(" ")}: ${result.stderr ?? result.error ?? result.status}`
);
}
};
attempt(["/usr/bin/rm", "-f", sudoersPath]);
attempt(["/usr/sbin/userdel", "-r", user], [0, 6]);
for (const group of groups) {
attempt(["/usr/sbin/groupdel", group], [0, 6]);
}
attempt(["/usr/bin/rm", "-rf", tempDir]);
assert.deepEqual(failures, []);
});
}
test(
"Linux drop-sudo launches Codex with a reduced process identity",
{ skip: !isLinux },
(t) => {
assert.equal(
spawnSync("/usr/bin/sudo", ["-n", "true"]).status,
0,
"the privileged test requires passwordless sudo"
);
for (const executable of [
"/usr/bin/env", "/usr/bin/gpasswd", "/usr/bin/id", "/usr/bin/setpriv",
"/usr/bin/sudo", "/usr/sbin/groupadd", "/usr/sbin/groupdel",
"/usr/sbin/useradd", "/usr/sbin/userdel",
]) {
assert.equal(existsSync(executable), true, `missing ${executable}`);
}
const suffix = `${process.pid}${Date.now().toString(36).slice(-4)}`;
const user = `cdxu${suffix}`;
const primaryGroup = `cdxp${suffix}`;
const supplementaryGroup = `cdxs${suffix}`;
const tempDir = mkdtempSync(path.join(tmpdir(), "codex-drop-sudo-"));
const home = path.join(tempDir, "home");
const binDir = path.join(tempDir, "bin");
const actionPath = path.join(tempDir, "main.js");
const workspace = path.join(tempDir, "workspace");
const capturePath = path.join(tempDir, "capture.json");
const outputPath = path.join(tempDir, "output.md");
const groupFile = path.join(tempDir, "supplementary-group-only");
const sudoersSource = path.join(tempDir, "sudoers");
const sudoersPath = `/etc/sudoers.d/${user}`;
mkdirSync(binDir);
mkdirSync(workspace);
copyFileSync(mainPath, actionPath);
registerCleanup(t, {
user,
groups: [supplementaryGroup, primaryGroup],
sudoersPath,
tempDir,
});
runSudo(["/usr/sbin/groupadd", primaryGroup]);
runSudo(["/usr/sbin/groupadd", supplementaryGroup]);
runSudo([
"/usr/sbin/useradd",
"--create-home",
"--home-dir",
home,
"--gid",
primaryGroup,
"--groups",
`sudo,${supplementaryGroup}`,
"--shell",
"/bin/bash",
user,
]);
writeFileSync(sudoersSource, `${user} ALL=(ALL) NOPASSWD:ALL\n`);
runSudo([
"/usr/bin/install",
"--owner=root",
"--group=root",
"--mode=0440",
sudoersSource,
sudoersPath,
]);
const launcher = path.join(binDir, "codex");
const fakeCodexCopyPath = path.join(
binDir,
"fakeCodexLinuxIdentity.mjs"
);
copyFileSync(fakeCodexPath, fakeCodexCopyPath);
writeFileSync(
launcher,
`#!/bin/sh\nexec "${process.execPath}" "${fakeCodexCopyPath}" "$@"\n`
);
chmodSync(launcher, 0o755);
writeFileSync(groupFile, "group-only\n");
runSudo(["/usr/bin/chown", "-R", `${user}:${primaryGroup}`, tempDir]);
runSudo(["/usr/bin/chmod", "0711", tempDir]);
runSudo(["/usr/bin/chown", `root:${supplementaryGroup}`, groupFile]);
runSudo(["/usr/bin/chmod", "0640", groupFile]);
assert.equal(
spawnSync("/usr/bin/sudo", [
"-n", "-u", user, "/usr/bin/sudo", "-n", "true",
]).status,
0,
"synthetic runner must start with sudo"
);
const groupsBefore = runAsUser(user, ["/usr/bin/id", "-G"]).stdout
.trim()
.split(/\s+/);
const supplementaryGid = String(statSync(groupFile).gid);
assert.equal(groupsBefore.includes(supplementaryGid), true);
runAsUser(user, ["/usr/bin/cat", groupFile]);
const uid = run("/usr/bin/id", ["-u", user]).stdout.trim();
const gid = run("/usr/bin/id", ["-g", user]).stdout.trim();
const prompt = "first line\nsecond line\n";
const result = spawnCodexAs({
uid,
gid,
user,
home,
binDir,
actionPath,
workspace,
outputPath,
prompt,
extraEnv: {
CODEX_CAPTURE_PATH: capturePath,
SUPPLEMENTARY_GROUP_FILE: groupFile,
},
});
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /Confirmed sudo privilege is disabled/);
const capture = JSON.parse(readFileSync(capturePath, "utf8"));
assert.deepEqual(capture, {
identity: {
uid: Number(uid),
gid: Number(gid),
groups: "",
idGroups: gid,
accountGroups: capture.identity.accountGroups,
capInh: capture.identity.capInh,
capPrm: capture.identity.capPrm,
capEff: capture.identity.capEff,
capBnd: capture.identity.capBnd,
capAmb: capture.identity.capAmb,
noNewPrivs: "1",
sudoStatus: capture.identity.sudoStatus,
home,
cwd: workspace,
supplementaryFileReadable: false,
},
prompt,
});
for (const field of ["capInh", "capPrm", "capEff", "capBnd", "capAmb"]) {
assert.match(capture.identity[field], /^0+$/);
}
assert.notEqual(capture.identity.sudoStatus, 0);
assert.equal(
capture.identity.accountGroups.split(/\s+/).includes("sudo"),
false
);
assert.equal(readFileSync(outputPath, "utf8"), "fake final message\n");
assert.equal(
run("/usr/bin/id", ["-nG", user]).stdout.split(/\s+/).includes("sudo"),
false
);
const repeatedResult = spawnCodexAs({
uid,
gid,
user,
home,
binDir,
actionPath,
workspace,
outputPath,
prompt: "second invocation must remain gated",
});
assert.notEqual(repeatedResult.status, 0);
assert.match(
repeatedResult.stderr,
/If an earlier drop-sudo invocation already removed sudo/
);
}
);
test(
"Linux drop-sudo does not release Codex when sudo remains available",
{ skip: !isLinux },
(t) => {
const suffix = `${process.pid}${Date.now().toString(36).slice(-4)}f`;
const user = `cdxu${suffix}`;
const primaryGroup = `cdxp${suffix}`;
const sudoGroup = `cdxa${suffix}`;
const tempDir = mkdtempSync(path.join(tmpdir(), "codex-drop-sudo-fail-"));
const home = path.join(tempDir, "home");
const binDir = path.join(tempDir, "bin");
const actionPath = path.join(tempDir, "main.js");
const workspace = path.join(tempDir, "workspace");
const outputPath = path.join(tempDir, "output.md");
const startedPath = path.join(tempDir, "codex-started");
const sudoersSource = path.join(tempDir, "sudoers");
const sudoersPath = `/etc/sudoers.d/${user}`;
mkdirSync(binDir);
mkdirSync(workspace);
copyFileSync(mainPath, actionPath);
registerCleanup(t, {
user,
groups: [sudoGroup, primaryGroup],
sudoersPath,
tempDir,
});
runSudo(["/usr/sbin/groupadd", primaryGroup]);
runSudo(["/usr/sbin/groupadd", sudoGroup]);
runSudo([
"/usr/sbin/useradd",
"--create-home",
"--home-dir",
home,
"--gid",
primaryGroup,
"--groups",
sudoGroup,
"--shell",
"/bin/bash",
user,
]);
writeFileSync(sudoersSource, `%${sudoGroup} ALL=(ALL) NOPASSWD:ALL\n`);
runSudo([
"/usr/bin/install",
"--owner=root",
"--group=root",
"--mode=0440",
sudoersSource,
sudoersPath,
]);
const launcher = path.join(binDir, "codex");
writeFileSync(launcher, `#!/bin/sh\n: > "$CODEX_STARTED"\n`);
chmodSync(launcher, 0o755);
runSudo(["/usr/bin/chown", "-R", `${user}:${primaryGroup}`, tempDir]);
runSudo(["/usr/bin/chmod", "0711", tempDir]);
const uid = run("/usr/bin/id", ["-u", user]).stdout.trim();
const gid = run("/usr/bin/id", ["-g", user]).stdout.trim();
assert.equal(
runAsUser(user, ["/usr/bin/sudo", "-n", "true"]).status,
0
);
const result = spawnCodexAs({
uid,
gid,
user,
home,
binDir,
actionPath,
workspace,
outputPath,
prompt: "must remain gated",
extraEnv: { CODEX_STARTED: startedPath },
});
assert.notEqual(result.status, 0);
assert.match(result.stderr, /Expected sudo to be disabled/);
assert.equal(existsSync(startedPath), false);
}
);
+1 -1
View File
@@ -24,7 +24,7 @@ function runCodexExecWithFakeCodex({
sandbox = "",
permissionProfile = "",
extraArgs = "",
safetyStrategy = "unsafe",
safetyStrategy = "drop-sudo",
} = {}) {
const tempDir = mkdtempSync(path.join(tmpdir(), "codex-action-permissions-"));
const capturePath = path.join(tempDir, "args.json");