fix: generalize Linux privilege cleanup

Co-authored-by: Codex <[email protected]>
This commit is contained in:
viyatb-oai
2026-07-16 17:05:56 -07:00
co-authored by Codex
parent 5905733d38
commit 3bcbbd6a1d
5 changed files with 286 additions and 33 deletions
+20 -2
View File
@@ -47,9 +47,27 @@ jobs:
- name: Verify drop-sudo hardening
run: |
test -w /var/run/docker.sock
service_dir="/run/codex-action-drop-sudo-test"
service_socket="$service_dir/control.sock"
sudo mkdir "$service_dir"
sudo chown runner:users "$service_dir"
SOCKET_PATH="$service_socket" node -e \
'require("node:net").createServer().listen(process.env.SOCKET_PATH)' &
service_pid=$!
trap 'kill "$service_pid" 2>/dev/null || true' EXIT
for _ in {1..50}; do
if test -S "$service_socket"; then
break
fi
sleep 0.1
done
test -S "$service_socket"
sudo chown root:users "$service_socket"
sudo chmod 0660 "$service_socket"
sudo chown root:root "$service_dir"
test -w "$service_socket"
node dist/main.js drop-sudo --user runner --group sudo
if test -w /var/run/docker.sock; then
if test -w "$service_socket"; 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`; on Linux, they also cannot rely on privileged container services. 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, group-authorized access to root-owned service sockets is also removed. 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 -2
View File
@@ -76,8 +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, access to
privileged container services is also removed for the rest of the job.
removed permanently for the duration of the job. On Linux,
group-authorized access to root-owned service sockets is also removed.
* `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
+111 -14
View File
File diff suppressed because one or more lines are too long
+152 -14
View File
@@ -21,8 +21,18 @@ export interface DropSudoOptions {
const LINUX_PLATFORM = "linux";
const MACOS_PLATFORM = "darwin";
const LINUX_PRIVILEGED_GROUPS = ["docker"];
const LINUX_PRIVILEGED_SOCKETS = ["/var/run/docker.sock"];
const LINUX_RUNTIME_DIRECTORY = "/run";
interface LinuxGroup {
id: number;
name: string;
primary: boolean;
}
interface RootServiceSocket {
path: string;
groupId: number;
}
export async function dropSudo(options: DropSudoOptions): Promise<void> {
const platform = process.platform;
@@ -78,14 +88,20 @@ async function dropSudoWithPrivileges(options: DropSudoOptions): Promise<void> {
switch (process.platform) {
case LINUX_PLATFORM: {
const groups = new Set([options.group, ...LINUX_PRIVILEGED_GROUPS]);
const serviceSockets = await findRootServiceSocketsForUser(options.user);
const groups = new Set([
options.group,
...serviceSockets
.filter(({ group }) => !group.primary)
.map(({ group }) => group.name),
]);
for (const group of groups) {
if (await removeUserFromLinuxGroup(options.user, group)) {
changed = true;
}
}
for (const socketPath of LINUX_PRIVILEGED_SOCKETS) {
if (await restrictPrivilegedSocket(socketPath)) {
for (const { socket } of serviceSockets) {
if (await restrictRootServiceSocket(socket.path)) {
changed = true;
}
}
@@ -179,10 +195,120 @@ async function removeUserFromLinuxGroup(
return true;
}
async function restrictPrivilegedSocket(socketPath: string): Promise<boolean> {
async function findRootServiceSocketsForUser(
user: string
): Promise<Array<{ socket: RootServiceSocket; group: LinuxGroup }>> {
const groups = await getLinuxGroups(user);
const groupsById = new Map(groups.map((group) => [group.id, group]));
const sockets = await findRootServiceSockets(
LINUX_RUNTIME_DIRECTORY,
new Set(groupsById.keys())
);
return sockets.map((socket) => ({
socket,
group: groupsById.get(socket.groupId)!,
}));
}
async function getLinuxGroups(user: string): Promise<Array<LinuxGroup>> {
const [idsResult, namesResult, primaryResult] = await Promise.all([
execCommand("id", ["-G", user], { capture: true }),
execCommand("id", ["-Gn", user], { capture: true }),
execCommand("id", ["-g", user], { capture: true }),
]);
const ids = splitFields(idsResult.stdout).map(parseNumericId);
const names = splitFields(namesResult.stdout);
const primaryId = parseNumericId(primaryResult.stdout.trim());
if (ids.length !== names.length) {
throw new Error(`Could not resolve group names for ${user}.`);
}
return ids.map((id, index) => ({
id,
name: names[index],
primary: id === primaryId,
}));
}
function splitFields(value: string): Array<string> {
return value
.trim()
.split(/\s+/)
.filter((field) => field.length > 0);
}
function parseNumericId(value: string): number {
if (!/^\d+$/.test(value)) {
throw new Error(`Invalid numeric ID: ${value}`);
}
return Number.parseInt(value, 10);
}
async function findRootServiceSockets(
directory: string,
groupIds: Set<number>,
ignoreUnreadable = false
): Promise<Array<RootServiceSocket>> {
let entries;
try {
entries = await fs.readdir(directory, { withFileTypes: true });
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (
code === "ENOENT" ||
(ignoreUnreadable && (code === "EACCES" || code === "EPERM"))
) {
return [];
}
throw error;
}
const sockets: Array<RootServiceSocket> = [];
for (const entry of entries) {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
sockets.push(
...(await findRootServiceSockets(
entryPath,
groupIds,
ignoreUnreadable
))
);
continue;
}
if (!entry.isSocket()) {
continue;
}
let stats;
try {
stats = await fs.lstat(entryPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
continue;
}
throw error;
}
if (
stats.isSocket() &&
stats.uid === 0 &&
(stats.mode & 0o020) !== 0 &&
groupIds.has(stats.gid)
) {
sockets.push({ path: entryPath, groupId: stats.gid });
}
}
return sockets;
}
async function restrictRootServiceSocket(
socketPath: string
): Promise<boolean> {
let stats;
try {
stats = await fs.stat(socketPath);
stats = await fs.lstat(socketPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return false;
@@ -193,14 +319,15 @@ async function restrictPrivilegedSocket(socketPath: string): Promise<boolean> {
if (!stats.isSocket()) {
throw new Error(`Expected ${socketPath} to be a socket.`);
}
if (stats.uid === 0 && stats.gid === 0 && (stats.mode & 0o077) === 0) {
if (stats.uid !== 0) {
throw new Error(`Expected ${socketPath} to be owned by root.`);
}
if ((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);
await fs.chmod(socketPath, stats.mode & 0o700);
console.log(`Restricted access to ${socketPath}.`);
return true;
}
@@ -210,9 +337,20 @@ async function verifyPrivilegedSocketsRestricted(): Promise<void> {
return;
}
for (const socketPath of LINUX_PRIVILEGED_SOCKETS) {
const groupIds = new Set(
typeof process.getgroups === "function" ? process.getgroups() : []
);
if (typeof process.getgid === "function") {
groupIds.add(process.getgid());
}
const sockets = await findRootServiceSockets(
LINUX_RUNTIME_DIRECTORY,
groupIds,
true
);
for (const socket of sockets) {
try {
await fs.access(socketPath, fsConstants.W_OK);
await fs.access(socket.path, fsConstants.W_OK);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "EACCES" || code === "EPERM" || code === "ENOENT") {
@@ -220,7 +358,7 @@ async function verifyPrivilegedSocketsRestricted(): Promise<void> {
}
throw error;
}
throw new Error(`drop-sudo did not revoke access to ${socketPath}.`);
throw new Error(`drop-sudo did not revoke access to ${socket.path}.`);
}
}