fix: harden drop-sudo on Linux

Co-authored-by: Codex <[email protected]>
This commit is contained in:
viyatb-oai
2026-07-16 16:57:35 -07:00
co-authored by Codex
parent dd78cb6538
commit 5905733d38
5 changed files with 165 additions and 40 deletions
+9
View File
@@ -44,3 +44,12 @@ jobs:
echo "$status_output"
exit 1
fi
- name: Verify drop-sudo hardening
run: |
test -w /var/run/docker.sock
node dist/main.js drop-sudo --user runner --group sudo
if test -w /var/run/docker.sock; then
echo '::error::drop-sudo left a privileged service accessible.'
exit 1
fi
+1 -1
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. Codex then runs as that user without superuser privileges. 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. Codex then runs as that user without superuser privileges. This change lasts for the rest of the job, so subsequent steps cannot rely on `sudo`; on Linux, they also cannot rely on privileged container services. 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.
+2 -1
View File
@@ -76,7 +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.
removed permanently for the duration of the job. On Linux, access to
privileged container services is also removed for the rest 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
+70 -19
View File
File diff suppressed because one or more lines are too long
+83 -19
View File
@@ -1,5 +1,5 @@
import { spawn } from "node:child_process";
import { promises as fs } from "node:fs";
import { constants as fsConstants, promises as fs } from "node:fs";
import * as path from "node:path";
interface ExecOptions {
@@ -21,6 +21,8 @@ export interface DropSudoOptions {
const LINUX_PLATFORM = "linux";
const MACOS_PLATFORM = "darwin";
const LINUX_PRIVILEGED_GROUPS = ["docker"];
const LINUX_PRIVILEGED_SOCKETS = ["/var/run/docker.sock"];
export async function dropSudo(options: DropSudoOptions): Promise<void> {
const platform = process.platform;
@@ -61,6 +63,10 @@ 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", ["-K"], { ignoreFailure: true });
if (platform === LINUX_PLATFORM) {
await verifyPrivilegedSocketsRestricted();
}
}
async function dropSudoWithPrivileges(options: DropSudoOptions): Promise<void> {
@@ -72,25 +78,16 @@ 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.`
);
} else {
throw new Error("Neither deluser nor gpasswd available.");
const groups = new Set([options.group, ...LINUX_PRIVILEGED_GROUPS]);
for (const group of groups) {
if (await removeUserFromLinuxGroup(options.user, group)) {
changed = true;
}
}
for (const socketPath of LINUX_PRIVILEGED_SOCKETS) {
if (await restrictPrivilegedSocket(socketPath)) {
changed = true;
}
changed = true;
} else {
console.log(
`${options.user} is not a member of the ${options.group} group.`
);
}
break;
}
@@ -160,6 +157,73 @@ async function dropSudoWithPrivileges(options: DropSudoOptions): Promise<void> {
);
}
async function removeUserFromLinuxGroup(
user: string,
group: string
): Promise<boolean> {
if (!(await isUserInGroup(user, group))) {
console.log(`${user} is not a member of the ${group} group.`);
return false;
}
if (await commandExists("deluser")) {
await execCommand("deluser", [user, group]);
console.log(`Used 'deluser ${user} ${group}' to drop group access.`);
} else if (await commandExists("gpasswd")) {
await execCommand("gpasswd", ["-d", user, group]);
console.log(`Used 'gpasswd -d ${user} ${group}' to drop group access.`);
} else {
throw new Error("Neither deluser nor gpasswd available.");
}
return true;
}
async function restrictPrivilegedSocket(socketPath: string): Promise<boolean> {
let stats;
try {
stats = await fs.stat(socketPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return false;
}
throw error;
}
if (!stats.isSocket()) {
throw new Error(`Expected ${socketPath} to be a socket.`);
}
if (stats.uid === 0 && stats.gid === 0 && (stats.mode & 0o077) === 0) {
console.log(`Access to ${socketPath} is already restricted.`);
return false;
}
await fs.chown(socketPath, 0, 0);
await fs.chmod(socketPath, 0o600);
console.log(`Restricted access to ${socketPath}.`);
return true;
}
async function verifyPrivilegedSocketsRestricted(): Promise<void> {
if (typeof process.getuid === "function" && process.getuid() === 0) {
return;
}
for (const socketPath of LINUX_PRIVILEGED_SOCKETS) {
try {
await fs.access(socketPath, fsConstants.W_OK);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "EACCES" || code === "EPERM" || code === "ENOENT") {
continue;
}
throw error;
}
throw new Error(`drop-sudo did not revoke access to ${socketPath}.`);
}
}
async function ensurePasswordlessSudo(): Promise<void> {
try {
await execCommand("sudo", ["-n", "true"], { capture: true });