fix: improve runner setup and configuration handling
Verify TypeScript Build / verify (push) Failing after 1m13s

Co-authored-by: Codex <[email protected]>
This commit is contained in:
viyatb-oai
2026-08-20 16:38:51 -07:00
co-authored by Codex
parent c385816875
commit 86365089eb
14 changed files with 2966 additions and 86 deletions
+7
View File
@@ -1,5 +1,12 @@
# codex-action Changelog
## [v1.12](https://github.com/openai/codex-action/tree/v1.12) (2026-08-20)
- Strengthen Linux runner privilege isolation and Responses API proxy credential handling.
- Reject Codex arguments and configuration overrides that conflict with protected execution settings.
- Require unprivileged user namespaces for Linux `drop-sudo`; run the action after steps that need `sudo`, Docker, or privileged service sockets.
- Document runner requirements, permission-profile behavior, and trusted configuration boundaries.
## [v1.11](https://github.com/openai/codex-action/tree/v1.11) (2026-07-04)
- [#116](https://github.com/openai/codex-action/pull/116) keep the permission profile helper backward compatible
+16 -7
View File
@@ -140,8 +140,17 @@ configured unprivileged user when selecting a profile.
For backward compatibility, omitting both `permission-profile` and `sandbox` still runs Codex with
the legacy `workspace-write` sandbox. Existing callers that set `sandbox` continue to use the legacy
model. Do not set `sandbox_mode` in `codex-args` or a loaded `config.toml` when selecting a permission
profile; Codex treats any legacy sandbox setting as opting out of permission profiles.
model. Existing `codex-args` such as `--search`, `--ephemeral`, `--model`, benign `-c` overrides
(including `model_reasoning_effort` and `service_tier`), and `--enable use_legacy_landlock` remain
supported. Protected runs reject overrides that change permissions, trust, model providers, or
command execution; alternate configuration profiles; additional writable directories; and conflicting
hook or sandbox bypasses. `--full-auto` cannot be combined with a permission profile or an effective
read-only sandbox. Image attachments and overrides loading local instruction, compaction, or catalog
files remain supported except with custom named permission profiles, where unsandboxed reads could
bypass scoped filesystem access. Explicitly unsafe, unrestricted runs retain argument pass-through.
Treat `codex-home/config.toml` and named profile definitions as trusted workflow configuration.
They are not sanitized by `codex-args` validation and must not come from an untrusted checkout.
For example, use the built-in `:workspace` profile for a workflow that needs to modify the checkout:
@@ -160,7 +169,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` access before invoking Codex. On Linux, it also removes access to existing root-owned service sockets under `/run` that the runner can write, including the Docker daemon. Codex starts with `no_new_privs`, no supplementary groups, and empty bounding, permitted, effective, inheritable, and ambient capability sets in the host user namespace. Descendants inherit `no_new_privs`; they can gain capabilities inside a new user namespace, but those do not grant host privileges. The account and socket mutations can outlive a job on reused self-hosted runners; use this strategy on disposable runners and run the action as the last step in a job.
- **`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,8 +177,8 @@ 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. 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.
- **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.
- **Linux/macOS**: All options for `safety-strategy` are supported. Linux `drop-sudo` requires passwordless `sudo` and `/usr/bin/setpriv` before Codex starts. Any mode that uses bubblewrap, including the default `workspace-write` sandbox and built-in `:workspace`/`:read-only` profiles, also requires working unprivileged user namespaces; setuid-only bubblewrap installations are unsupported because `no_new_privs` is enforced for every Linux `drop-sudo` Codex launch. Sandbox-backend startup errors occur after the irreversible account and socket cleanup, so validate self-hosted runner configurations on disposable hosts. Running the action again with a prompt after `sudo` has already been removed fails instead of running without these protections. Run subsequent work requiring elevated privileges in a fresh `job` on a new host.
- **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. Self-hosted Linux runners must enable unprivileged user namespaces, allow a sufficient `user.max_user_namespaces`, and configure AppArmor or SELinux to permit them before using bubblewrap-backed `drop-sudo` modes.
## Outputs
@@ -192,8 +201,8 @@ jobs:
- Run this action after `actions/checkout@v5` so Codex has access to your repository contents.
- 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.
- 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.)
- If you need more control over the CLI invocation, pass supported flags through `codex-args` or create a trusted `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.
- Calling `codex` directly from later scripts does not receive the action's `no_new_privs`, capability, or supplementary-group protections. Provide `prompt` or `prompt-file` to run Codex through the action; on Linux, use a new job if `drop-sudo` has already been applied.
## Azure
+28 -14
View File
@@ -45,7 +45,10 @@ inputs:
required: false
default: ""
codex-args:
description: "Additional args to pass through to `codex exec`. If this value starts with `[`, it will be parsed as a JSON array; otherwise, it will be parsed as a shell-like string."
description: |
Additional args to pass to `codex exec`, as a JSON array of strings or a shell-like string.
Existing safe flags and configuration overrides remain supported; protected runs reject
arguments that conflict with enforced permission, trust, provider, or command protections.
required: false
default: ""
output-schema:
@@ -65,7 +68,7 @@ inputs:
required: false
default: ""
codex-home:
description: "Directory to use as the Codex home directory. If empty, the default Codex home directory will be used."
description: "Trusted Codex home/config directory. If empty, the default Codex home directory will be used."
required: false
default: ""
safety-strategy:
@@ -75,8 +78,15 @@ inputs:
* `drop-sudo` (default, IRREVERSIBLE) Drop sudo privileges (if any) from
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.
irreversible: account and host changes can outlive a job on reused
self-hosted runners. On Linux, access to
existing root-owned service sockets under `/run` that the runner can
write, including Docker, is also removed. On Linux, Codex starts with
`no_new_privs`, no supplementary groups, and empty host-namespace
capability sets. Descendants inherit `no_new_privs`; capabilities in
new user namespaces do not grant host privileges. Bubblewrap-backed
modes require unprivileged user namespaces; setuid-only bubblewrap
installations are not supported.
* `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
@@ -205,7 +215,10 @@ runs:
# This is its own step to minimize the runtime logic that has access to the
# API key. Note we use `env -u PROXY_API_KEY` to ensure extra copies of the
# key do not end up in the memory of the `codex-responses-api-proxy`
# process where environment variables are stored.
# process where environment variables are stored. The background shell must
# replace itself so it does not retain the key for the proxy's lifetime.
# Replace inherited Node options so the proxy's JavaScript launcher and
# descendants disable SIGUSR1.
- name: Start Responses API proxy
if: ${{ inputs['openai-api-key'] != '' && steps.start_proxy.outputs.server_info_file_exists == 'false' }}
env:
@@ -225,7 +238,7 @@ runs:
fi
(
printenv PROXY_API_KEY | env -u PROXY_API_KEY "${args[@]}"
exec env -u PROXY_API_KEY -u NODE_OPTIONS NODE_OPTIONS=--disable-sigusr1 "${args[@]}" <<< "$PROXY_API_KEY"
) &
- name: Wait for Responses API proxy
@@ -280,11 +293,12 @@ runs:
run: |
set -euo pipefail
# Bubblewrap needs unprivileged user namespaces on GitHub-hosted Linux
# runners. This step runs before drop-sudo, then becomes a no-op on
# later codex-action invocations in the same job because the sysctls
# already have the desired values. See issue #75 for the failure mode
# this is working around on newer Ubuntu images.
# Linux drop-sudo sets no_new_privs before Codex starts, so bubblewrap
# must use unprivileged user namespaces instead of setuid elevation.
# This step runs before drop-sudo, then becomes a no-op on later
# codex-action invocations in the same job because the sysctls already
# have the desired values. See issue #75 for the failure mode this is
# working around on newer Ubuntu images.
current_userns="$(sysctl -n kernel.unprivileged_userns_clone 2>/dev/null || true)"
if [ -n "$current_userns" ] && [ "$current_userns" != "1" ]; then
echo "Enabling kernel.unprivileged_userns_clone for bubblewrap."
@@ -301,7 +315,7 @@ runs:
fi
- name: Drop sudo privilege, if appropriate
if: ${{ inputs['safety-strategy'] == 'drop-sudo' && inputs['openai-api-key'] != '' }}
if: ${{ inputs['safety-strategy'] == 'drop-sudo' && (inputs['openai-api-key'] != '' || inputs.prompt != '' || inputs['prompt-file'] != '') && (runner.os != 'Linux' || (inputs.prompt == '' && inputs['prompt-file'] == '')) }}
shell: bash
env:
ACTION_PATH: ${{ github.action_path }}
@@ -320,7 +334,7 @@ runs:
esac
- name: Verify sudo privilege removed
if: ${{ inputs['safety-strategy'] == 'drop-sudo' && inputs['openai-api-key'] != '' }}
if: ${{ inputs['safety-strategy'] == 'drop-sudo' && (inputs['openai-api-key'] != '' || inputs.prompt != '' || inputs['prompt-file'] != '') && (runner.os != 'Linux' || (inputs.prompt == '' && inputs['prompt-file'] == '')) }}
shell: bash
run: |
if sudo -n true 2>/dev/null; then
@@ -351,7 +365,7 @@ runs:
FORCE_COLOR: 1
shell: bash
run: |
node "$ACTION_PATH/dist/main.js" run-codex-exec \
exec env -u NODE_OPTIONS NODE_OPTIONS=--disable-sigusr1 node --disable-sigusr1 "$ACTION_PATH/dist/main.js" run-codex-exec \
--prompt "${CODEX_PROMPT}" \
--prompt-file "${CODEX_PROMPT_FILE}" \
--output-file "$CODEX_OUTPUT_FILE" \
+585 -29
View File
File diff suppressed because one or more lines are too long
+12 -3
View File
@@ -32,9 +32,18 @@ Permission profiles constrain commands that Codex runs; they do not replace the
or network access.
Permission profiles and the legacy `sandbox` input do not compose. The action rejects both inputs
together, but a `sandbox_mode` setting in `codex-args` or a loaded `config.toml` also opts Codex into
the legacy sandbox model. Review every loaded configuration layer when a workflow is expected to use
a permission profile.
together and rejects `codex-args` that would weaken protected permission, trust, provider, or
command-execution settings. Alternate configuration profiles, additional writable directories, and
conflicting hook or sandbox bypasses are also rejected; existing benign flags, configuration
overrides, and `--enable use_legacy_landlock` remain available. `--full-auto` conflicts with a
permission profile or an effective read-only sandbox. Image attachments and overrides loading local
instruction, compaction, or catalog files are rejected only for custom named permission profiles
because their unsandboxed reads can bypass scoped filesystem access. Explicitly unsafe, unrestricted
runs retain argument pass-through.
The existing `codex-home/config.toml` and named permission profiles remain trusted inputs and are not
sanitized by this validation. Do not point `codex-home` at configuration controlled by an untrusted
checkout; review every loaded configuration layer when a workflow relies on restricted permissions.
## Avoid shell injection in workflow steps
+419 -20
View File
@@ -1,10 +1,17 @@
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";
import {
captureLinuxRunnerCredentials,
includeAccountGroups,
LinuxRunnerCredentials,
parseLinuxRunnerCredentials,
} from "./linuxCredentials";
interface ExecOptions {
capture?: boolean;
ignoreFailure?: boolean;
inheritedFileDescriptor?: number;
}
interface ExecResult {
@@ -17,10 +24,30 @@ export interface DropSudoOptions {
user: string;
group: string;
rootPhase: boolean;
runnerCredentials?: string;
}
const LINUX_PLATFORM = "linux";
const MACOS_PLATFORM = "darwin";
const LINUX_RUNTIME_DIRECTORY = "/run";
const LINUX_O_PATH = 0o10000000;
interface LinuxGroup {
id: number;
name: string;
primary: boolean;
}
interface RootServiceSocket {
path: string;
groupId: number;
device: number;
inode: number;
}
interface LinuxSocketCredentials extends LinuxRunnerCredentials {
fallbackGroupId?: number;
}
export async function dropSudo(options: DropSudoOptions): Promise<void> {
const platform = process.platform;
@@ -36,6 +63,11 @@ export async function dropSudo(options: DropSudoOptions): Promise<void> {
return;
}
const runnerCredentials =
platform === LINUX_PLATFORM
? JSON.stringify(captureLinuxRunnerCredentials())
: undefined;
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.
@@ -56,11 +88,18 @@ export async function dropSudo(options: DropSudoOptions): Promise<void> {
options.user,
"--group",
options.group,
...(runnerCredentials === undefined
? []
: ["--runner-credentials", runnerCredentials]),
]);
// 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> {
@@ -68,30 +107,69 @@ async function dropSudoWithPrivileges(options: DropSudoOptions): Promise<void> {
throw new Error("drop-sudo root phase must run as root.");
}
process.env.PATH = "/usr/sbin:/usr/bin:/sbin:/bin";
let changed = false;
let originalLinuxGroupIds: Set<number> | undefined;
let linuxSocketCredentials: LinuxSocketCredentials | undefined;
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.");
}
changed = true;
} else {
console.log(
`${options.user} is not a member of the ${options.group} group.`
if (options.runnerCredentials === undefined) {
throw new Error(
"Linux drop-sudo requires the original runner credentials."
);
}
const originalCredentials = parseLinuxRunnerCredentials(
options.runnerCredentials
);
const userGroups = await getLinuxGroups(options.user);
const userId = await execCommand("id", ["-u", options.user], {
capture: true,
});
linuxSocketCredentials = includeAccountGroups(
originalCredentials,
parseNumericId(userId.stdout.trim()),
userGroups.map(({ id }) => id)
);
originalLinuxGroupIds = new Set([
linuxSocketCredentials.primaryGroupId,
...linuxSocketCredentials.supplementaryGroupIds,
]);
const fallbackGroup = await execCommand("id", ["-g", "nobody"], {
capture: true,
ignoreFailure: true,
});
if (fallbackGroup.code === 0) {
const fallbackGroupId = parseNumericId(fallbackGroup.stdout.trim());
if (fallbackGroupId !== 0) {
originalLinuxGroupIds.add(fallbackGroupId);
linuxSocketCredentials.fallbackGroupId = fallbackGroupId;
}
}
const groupsById = new Map(userGroups.map((group) => [group.id, group]));
const serviceSockets = await findRootServiceSockets(
LINUX_RUNTIME_DIRECTORY,
originalLinuxGroupIds,
linuxSocketCredentials
);
const groups = new Set([options.group]);
for (const socket of serviceSockets) {
const group = groupsById.get(socket.groupId);
if (group && !group.primary) {
groups.add(group.name);
}
}
for (const group of groups) {
if (await removeUserFromLinuxGroup(options.user, group)) {
changed = true;
}
}
for (const socket of serviceSockets) {
if (await restrictRootServiceSocket(socket)) {
changed = true;
}
}
break;
}
case MACOS_PLATFORM: {
@@ -158,6 +236,323 @@ async function dropSudoWithPrivileges(options: DropSudoOptions): Promise<void> {
console.log(
`Groups for ${options.user} after cleanup: ${groupsAfter.stdout.trim()}`
);
if (originalLinuxGroupIds) {
await verifyPrivilegedSocketsRestricted(
originalLinuxGroupIds,
linuxSocketCredentials
);
}
}
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 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>,
credentials?: LinuxSocketCredentials,
ignoreUnreadable = false,
displayDirectory = directory
): Promise<Array<RootServiceSocket>> {
let directoryHandle;
try {
directoryHandle = await fs.open(
directory,
fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW
);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (
code === "ENOENT" ||
(ignoreUnreadable && (code === "EACCES" || code === "EPERM"))
) {
return [];
}
throw error;
}
try {
const directoryDescriptorPath = `/proc/self/fd/${directoryHandle.fd}`;
const entries = await fs.readdir(directoryDescriptorPath, {
withFileTypes: true,
});
const sockets: Array<RootServiceSocket> = [];
for (const entry of entries) {
const entryPath = path.join(directoryDescriptorPath, entry.name);
const displayPath = path.join(displayDirectory, entry.name);
if (entry.isDirectory()) {
sockets.push(
...(await findRootServiceSockets(
entryPath,
groupIds,
credentials,
ignoreUnreadable,
displayPath
))
);
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;
}
const groupWritable =
(stats.mode & 0o020) !== 0 && groupIds.has(stats.gid);
const worldWritable = (stats.mode & 0o002) !== 0;
const aclWritable =
!groupWritable &&
!worldWritable &&
(stats.mode & 0o020) !== 0 &&
stats.isSocket() &&
stats.uid === 0 &&
(await hasWritableSocketAcl(entryPath, stats, credentials));
if (
stats.isSocket() &&
stats.uid === 0 &&
(groupWritable || worldWritable || aclWritable)
) {
sockets.push({
path: displayPath,
groupId: stats.gid,
device: stats.dev,
inode: stats.ino,
});
}
}
return sockets;
} finally {
await directoryHandle.close();
}
}
async function hasWritableSocketAcl(
socketPath: string,
expectedStats: Awaited<ReturnType<typeof fs.lstat>>,
credentials?: LinuxSocketCredentials
): Promise<boolean> {
if (!credentials) {
try {
await fs.access(socketPath, fsConstants.W_OK);
return true;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "EACCES" || code === "EPERM" || code === "ENOENT") {
return false;
}
throw error;
}
}
const socketHandle = await fs.open(
socketPath,
LINUX_O_PATH | fsConstants.O_NOFOLLOW
);
try {
const stats = await socketHandle.stat();
if (
!stats.isSocket() ||
stats.uid !== 0 ||
stats.dev !== expectedStats.dev ||
stats.ino !== expectedStats.ino
) {
throw new Error(`A privileged service socket changed during discovery.`);
}
const identityGroups = [
{
primaryGroupId: credentials.primaryGroupId,
supplementaryGroupIds: credentials.supplementaryGroupIds,
},
...(credentials.fallbackGroupId === undefined
? []
: [
{
primaryGroupId: credentials.fallbackGroupId,
supplementaryGroupIds: [],
},
]),
];
for (const identity of identityGroups) {
const groupArgument =
identity.supplementaryGroupIds.length === 0
? "--clear-groups"
: `--groups=${identity.supplementaryGroupIds.join(",")}`;
const result = await execCommand(
"/usr/bin/setpriv",
[
`--reuid=${credentials.userId}`,
`--regid=${identity.primaryGroupId}`,
groupArgument,
"--",
"/usr/bin/test",
"-w",
"/proc/self/fd/3",
],
{
capture: true,
ignoreFailure: true,
inheritedFileDescriptor: socketHandle.fd,
}
);
if (result.code === 0) {
return true;
}
if (result.code !== 1 || result.stderr.trim().length > 0) {
throw new Error(`Could not verify access to a privileged service socket.`);
}
}
return false;
} finally {
await socketHandle.close();
}
}
async function restrictRootServiceSocket(
socket: RootServiceSocket
): Promise<boolean> {
let socketHandle;
try {
socketHandle = await fs.open(
socket.path,
LINUX_O_PATH | fsConstants.O_NOFOLLOW
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return false;
}
throw error;
}
try {
const stats = await socketHandle.stat();
if (!stats.isSocket()) {
throw new Error(`Expected ${socket.path} to be a socket.`);
}
if (stats.uid !== 0) {
throw new Error(`Expected ${socket.path} to be owned by root.`);
}
if (stats.dev !== socket.device || stats.ino !== socket.inode) {
throw new Error(`${socket.path} changed while dropping privileges.`);
}
if ((stats.mode & 0o077) === 0) {
console.log(`Access to ${socket.path} is already restricted.`);
return false;
}
await fs.chmod(`/proc/self/fd/${socketHandle.fd}`, stats.mode & 0o700);
const restrictedStats = await socketHandle.stat();
if ((restrictedStats.mode & 0o077) !== 0) {
throw new Error(`Could not restrict access to ${socket.path}.`);
}
console.log(`Restricted access to ${socket.path}.`);
return true;
} finally {
await socketHandle.close();
}
}
async function verifyPrivilegedSocketsRestricted(
originalGroupIds?: Set<number>,
credentials?: LinuxSocketCredentials
): Promise<void> {
const groupIds =
originalGroupIds ??
new Set(typeof process.getgroups === "function" ? process.getgroups() : []);
if (!originalGroupIds && typeof process.getgid === "function") {
groupIds.add(process.getgid());
}
const sockets = await findRootServiceSockets(
LINUX_RUNTIME_DIRECTORY,
groupIds,
credentials,
!originalGroupIds
);
for (const socket of sockets) {
if (typeof process.getuid === "function" && process.getuid() === 0) {
throw new Error(`drop-sudo did not revoke access to ${socket.path}.`);
}
try {
await fs.access(socket.path, 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 ${socket.path}.`);
}
}
async function ensurePasswordlessSudo(): Promise<void> {
@@ -293,7 +688,11 @@ async function execCommand(
): Promise<ExecResult> {
const capture = options.capture ?? false;
const child = spawn(command, args, {
stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit",
stdio: capture
? options.inheritedFileDescriptor === undefined
? ["ignore", "pipe", "pipe"]
: ["ignore", "pipe", "pipe", options.inheritedFileDescriptor]
: "inherit",
});
let stdout = "";
+74
View File
@@ -0,0 +1,74 @@
export interface LinuxRunnerCredentials {
userId: number;
primaryGroupId: number;
supplementaryGroupIds: Array<number>;
}
function validId(value: unknown): value is number {
return (
typeof value === "number" &&
Number.isInteger(value) &&
value >= 0 &&
value < 0xffffffff
);
}
export function parseLinuxRunnerCredentials(
value: string
): LinuxRunnerCredentials {
const parsed: unknown = JSON.parse(value);
if (parsed == null || typeof parsed !== "object") {
throw new Error("Invalid original Linux runner credentials.");
}
const credentials = parsed as Partial<LinuxRunnerCredentials>;
if (
!validId(credentials.userId) ||
credentials.userId === 0 ||
!validId(credentials.primaryGroupId) ||
!Array.isArray(credentials.supplementaryGroupIds) ||
credentials.supplementaryGroupIds.length > 65536 ||
!credentials.supplementaryGroupIds.every(validId)
) {
throw new Error("Invalid original Linux runner credentials.");
}
return {
userId: credentials.userId,
primaryGroupId: credentials.primaryGroupId,
supplementaryGroupIds: [...new Set(credentials.supplementaryGroupIds)],
};
}
export function captureLinuxRunnerCredentials(): LinuxRunnerCredentials {
const userId = process.getuid?.();
const primaryGroupId = process.getgid?.();
const supplementaryGroupIds = process.getgroups?.();
if (
userId == null ||
userId === 0 ||
primaryGroupId == null ||
supplementaryGroupIds == null
) {
throw new Error("Linux drop-sudo requires a non-root runner user.");
}
return { userId, primaryGroupId, supplementaryGroupIds };
}
export function includeAccountGroups(
original: LinuxRunnerCredentials,
accountUserId: number,
accountGroupIds: Array<number>
): LinuxRunnerCredentials {
if (original.userId !== accountUserId) {
throw new Error(
"Original runner UID does not match the account being restricted."
);
}
// Checking the union is conservative: it covers both existing processes and
// fresh processes whose groups come from the current account database.
return {
...original,
supplementaryGroupIds: [
...new Set([...original.supplementaryGroupIds, ...accountGroupIds]),
].filter((id) => id !== original.primaryGroupId),
};
}
+16 -2
View File
@@ -106,12 +106,19 @@ export async function main() {
)
)
.addOption(new Option("--root-phase", "internal").default(false).hideHelp())
.addOption(new Option("--runner-credentials <json>", "internal").hideHelp())
.action(
async (options: { user: string; group: string; rootPhase: boolean }) => {
async (options: {
user: string;
group: string;
rootPhase: boolean;
runnerCredentials?: string;
}) => {
await dropSudo({
user: options.user,
group: options.group,
rootPhase: options.rootPhase,
runnerCredentials: options.runnerCredentials,
});
}
);
@@ -329,7 +336,14 @@ function parseExtraArgs(value: string): Array<string> {
}
if (value.startsWith("[")) {
return JSON.parse(value);
const parsed: unknown = JSON.parse(value);
if (
!Array.isArray(parsed) ||
!parsed.every((argument) => typeof argument === "string")
) {
throw new Error("`codex-args` must be a JSON array of strings.");
}
return parsed;
} else {
return parseArgsStringToArgv(value);
}
+285 -6
View File
@@ -4,6 +4,77 @@ import path from "path";
import os from "os";
import { setOutput } from "@actions/core";
import { checkOutput } from "./checkOutput";
import { captureLinuxRunnerCredentials } from "./linuxCredentials";
const LINUX_DROP_SUDO_SCRIPT = String.raw`
node="$1"
action="$2"
user="$3"
uid="$4"
home="$5"
runner_path="$6"
node_options="$7"
runner_credentials="$8"
shift 8
if [ ! -x /usr/bin/setpriv ]; then
echo "Linux drop-sudo requires /usr/bin/setpriv." >&2
exit 1
fi
nobody_gid="$(/usr/bin/id -g nobody 2>/dev/null)" || {
echo "Linux drop-sudo requires an unprivileged nobody account." >&2
exit 1
}
case "$nobody_gid" in
''|*[!0-9]*|0)
echo "Linux drop-sudo could not resolve a safe nobody primary group." >&2
exit 1
;;
esac
group_entry="$(/usr/bin/getent group "$nobody_gid" 2>/dev/null)" || {
echo "Linux drop-sudo could not resolve the nobody primary group." >&2
exit 1
}
case "$group_entry" in
nobody:*|nogroup:*) ;;
*)
echo "Linux drop-sudo refuses an unexpected nobody primary group." >&2
exit 1
;;
esac
/usr/bin/env -u NODE_OPTIONS "$node" "$action" drop-sudo --root-phase --user "$user" --group sudo --runner-credentials "$runner_credentials" || exit $?
unsafe_nobody_socket="$(/usr/bin/find /run -type s -uid 0 -gid "$nobody_gid" -perm -020 -print -quit)" || {
echo "Linux drop-sudo could not verify the nobody primary group." >&2
exit 1
}
if [ -n "$unsafe_nobody_socket" ]; then
echo "Linux drop-sudo refuses an unsafe nobody primary group." >&2
exit 1
fi
if /usr/bin/sudo -n -u "$user" -- /usr/bin/sudo -n true 2>/dev/null; then
echo "Expected sudo to be disabled, but sudo succeeded." >&2
exit 1
fi
echo "Confirmed the standard sudo probe is disabled."
set -- /usr/bin/setpriv \
--reuid="$uid" \
--regid="$nobody_gid" \
--clear-groups \
--no-new-privs \
--bounding-set=-all \
--inh-caps=-all \
--ambient-caps=-all \
-- /usr/bin/env \
-u SUDO_COMMAND -u SUDO_USER -u SUDO_UID -u SUDO_GID \
"HOME=$home" "USER=$user" "LOGNAME=$user" "PATH=$runner_path" \
"NODE_OPTIONS=$node_options" \
"$@"
echo "Launching Codex with no_new_privs and empty capability sets."
exec "$@"
`;
export type PromptSource =
| {
@@ -109,8 +180,56 @@ export async function runCodexExec({
const command: Array<string> = [];
const isLinuxDropSudo =
safetyStrategy === "drop-sudo" && process.platform === "linux";
let pathToCodex = "codex";
if (safetyStrategy === "unprivileged-user") {
if (isLinuxDropSudo) {
const uid = process.getuid?.();
if (uid == null || uid === 0) {
throw new Error("Linux drop-sudo requires a non-root runner user.");
}
const runnerCredentials = captureLinuxRunnerCredentials();
try {
await checkOutput(["/usr/bin/sudo", "-n", "true"]);
} catch {
throw new Error(
"Linux drop-sudo requires passwordless sudo before Codex starts. It cannot run again after sudo has been removed; use a separate job."
);
}
pathToCodex = (await checkOutput(["which", "codex"])).trim();
if (!pathToCodex) {
throw new Error("could not find 'codex' in PATH");
}
const user = os.userInfo();
command.push(
"/usr/bin/sudo",
"-n",
"-E",
"--",
"/usr/bin/env",
"-u",
"ENV",
"-u",
"BASH_ENV",
"-u",
"SHELLOPTS",
"/bin/sh",
"-c",
LINUX_DROP_SUDO_SCRIPT,
"codex-action-drop-sudo",
process.execPath,
process.argv[1],
user.username,
String(uid),
process.env.HOME ?? user.homedir,
process.env.PATH ?? "",
process.env.NODE_OPTIONS ?? "",
JSON.stringify(runnerCredentials)
);
} else if (safetyStrategy === "unprivileged-user") {
if (codexUser == null) {
throw new Error(
"codexUser must be specified when using the 'unprivileged-user' safety strategy."
@@ -371,11 +490,44 @@ function determinePermissionSelection({
"`permission-profile` cannot be combined with the `read-only` safety strategy because that strategy forces the legacy read-only sandbox."
);
}
const effectiveSandboxReadOnly =
safetyStrategy === "read-only" || requestedSandbox === "read-only";
if (
(permissionProfile != null || effectiveSandboxReadOnly) &&
extraArgs.some(
(arg) =>
arg === "--dangerously-bypass-approvals-and-sandbox" ||
arg === "--yolo" ||
arg === "--full-auto"
)
) {
throw new Error(
"`codex-args` cannot bypass sandbox protections when a `permission-profile` or read-only sandbox is selected."
);
}
if (permissionProfile != null && extraArgsSelectSandbox(extraArgs)) {
throw new Error(
"`permission-profile` cannot be combined with a sandbox override in `codex-args`."
);
}
const customPermissionProfile =
permissionProfile != null && !permissionProfile.startsWith(":");
if (
customPermissionProfile &&
extraArgs.some(
(arg) =>
arg === "--image" ||
arg.startsWith("--image=") ||
(arg.startsWith("-i") && !arg.startsWith("--"))
)
) {
throw new Error(
"`codex-args` cannot attach local images with a custom permission profile because image loading does not enforce its filesystem restrictions."
);
}
if (safetyStrategy !== "unsafe" || permissionProfile != null) {
validateProtectedExtraArgs(extraArgs, customPermissionProfile);
}
if (safetyStrategy === "read-only") {
return { type: "sandbox", mode: "read-only" };
}
@@ -385,13 +537,137 @@ function determinePermissionSelection({
return { type: "sandbox", mode: requestedSandbox ?? "workspace-write" };
}
const RESTRICTED_CONFIG_ROOTS = new Set([
"agents",
"approval_policy",
"approvals_reviewer",
"apps_mcp_product_sku",
"auto_review",
"chatgpt_base_url",
"debug",
"default_permissions",
"experimental_realtime_webrtc_call_base_url",
"experimental_realtime_ws_base_url",
"hooks",
"marketplaces",
"mcp_servers",
"model_provider",
"model_providers",
"notify",
"openai_base_url",
"oss_provider",
"otel",
"permissions",
"plugins",
"profile",
"profiles",
"projects",
"sandbox_mode",
"sandbox_workspace_write",
"shell_environment_policy",
"use_legacy_landlock",
]);
const CUSTOM_PROFILE_RESTRICTED_CONFIG_ROOTS = new Set([
"experimental_compact_prompt_file",
"model_catalog_json",
"model_instructions_file",
]);
function validateProtectedExtraArgs(
args: Array<string>,
customPermissionProfile: boolean
): void {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (
arg === "--dangerously-bypass-hook-trust" ||
arg.startsWith("--dangerously-bypass-hook-trust=") ||
arg === "--approve-for-me" ||
arg.startsWith("--approve-for-me=") ||
arg === "--not-so-yolo" ||
arg.startsWith("--not-so-yolo=") ||
arg === "--add-dir" ||
arg.startsWith("--add-dir=") ||
arg === "--oss" ||
arg.startsWith("--oss=") ||
arg === "--local-provider" ||
arg.startsWith("--local-provider=") ||
arg === "--ignore-user-config" ||
arg.startsWith("--ignore-user-config=") ||
arg === "--ignore-rules" ||
arg.startsWith("--ignore-rules=") ||
arg === "--profile" ||
arg.startsWith("--profile=") ||
(arg.startsWith("-p") && !arg.startsWith("--"))
) {
throw new Error(
`\`codex-args\` cannot use ${arg} with a protected safety strategy or permission profile.`
);
}
if (arg === "--enable" || arg.startsWith("--enable=")) {
const feature =
arg === "--enable" ? args[++index] : arg.slice("--enable=".length);
if (feature !== "use_legacy_landlock") {
throw new Error(
"`codex-args` can only enable `use_legacy_landlock` with a protected safety strategy or permission profile."
);
}
continue;
}
if (arg === "--disable" || arg.startsWith("--disable=")) {
throw new Error(
"`codex-args` cannot disable Codex features with a protected safety strategy or permission profile."
);
}
let override: string | undefined;
if (arg === "--config" || arg === "-c") {
override = args[++index];
} else if (arg.startsWith("--config=")) {
override = arg.slice("--config=".length);
} else if (arg.startsWith("-c") && !arg.startsWith("--")) {
override = arg.slice("-c".length);
if (override.startsWith("=")) {
override = override.slice(1);
}
} else {
continue;
}
const equalsIndex = override?.indexOf("=") ?? -1;
const key = override?.slice(0, equalsIndex).trim();
if (
equalsIndex < 1 ||
key === undefined ||
!/^[A-Za-z_][A-Za-z0-9_-]*(?:\.[A-Za-z_][A-Za-z0-9_-]*)*$/.test(key)
) {
throw new Error(
"`codex-args` contains an invalid or ambiguous configuration override for a protected safety strategy or permission profile."
);
}
const root = key.split(".", 1)[0];
if (
RESTRICTED_CONFIG_ROOTS.has(root) ||
(customPermissionProfile &&
CUSTOM_PROFILE_RESTRICTED_CONFIG_ROOTS.has(root)) ||
(root === "features" && key !== "features.use_legacy_landlock")
) {
throw new Error(
`\`codex-args\` cannot override \`${key}\` with a protected safety strategy or permission profile.`
);
}
}
}
function extraArgsSelectSandbox(args: Array<string>): boolean {
return args.some((arg, index) => {
if (
arg === "--sandbox" ||
arg.startsWith("--sandbox=") ||
arg === "-s" ||
arg.startsWith("-s=")
(arg.startsWith("-s") && !arg.startsWith("--"))
) {
return true;
}
@@ -401,14 +677,17 @@ function extraArgsSelectSandbox(args: Array<string>): boolean {
if (arg.startsWith("--config=")) {
return configOverrideSelectsSandbox(arg.slice("--config=".length));
}
if (arg.startsWith("-c=")) {
return configOverrideSelectsSandbox(arg.slice("-c=".length));
if (arg.startsWith("-c") && !arg.startsWith("--")) {
const override = arg.slice("-c".length);
return configOverrideSelectsSandbox(
override.startsWith("=") ? override.slice(1) : override
);
}
return false;
});
}
function configOverrideSelectsSandbox(override: string | undefined): boolean {
const key = override?.trimStart().split(/[=.]/, 1)[0];
const key = override?.trimStart().split(/[=.]/, 1)[0].trim();
return key === "sandbox_mode" || key === "sandbox_workspace_write";
}
+282
View File
@@ -0,0 +1,282 @@
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { once } from "node:events";
import {
chmodSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
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 actionStep(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);
return action.slice(start, next < 0 ? undefined : next);
}
test("Responses proxy replaces inherited Node options without exposing its API key", () => {
const step = actionStep("Start Responses API proxy");
assert.match(
step,
/exec env -u PROXY_API_KEY -u NODE_OPTIONS NODE_OPTIONS=--disable-sigusr1 "\$\{args\[@\]\}" <<< "\$PROXY_API_KEY"/
);
assert.doesNotMatch(step, /printenv PROXY_API_KEY\s*\|/);
});
test(
"Responses proxy environment removes unsafe Node options and its API key",
{ skip: process.platform === "win32" },
() => {
const result = spawnSync(
"env",
[
"-u",
"PROXY_API_KEY",
"-u",
"NODE_OPTIONS",
"NODE_OPTIONS=--disable-sigusr1",
process.execPath,
"-e",
`const { readFileSync } = require("node:fs");
process.stdout.write(JSON.stringify({
nodeOptions: process.env.NODE_OPTIONS,
apiKeyInEnvironment: Object.hasOwn(process.env, "PROXY_API_KEY"),
receivedApiKey: readFileSync(0, "utf8") === "synthetic-proxy-key\\n",
}));`,
],
{
encoding: "utf8",
input: "synthetic-proxy-key\n",
env: {
...process.env,
NODE_OPTIONS: "--require=/nonexistent-codex-action-preload.js",
PROXY_API_KEY: "synthetic-proxy-key",
},
}
);
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(JSON.parse(result.stdout), {
nodeOptions: "--disable-sigusr1",
apiKeyInEnvironment: false,
receivedApiKey: true,
});
}
);
test(
"Responses proxy startup leaves no credential-bearing background process",
{ skip: process.platform !== "linux", timeout: 10_000 },
async (t) => {
const directory = mkdtempSync(path.join(tmpdir(), "codex-proxy-process-"));
const executable = path.join(directory, "codex-responses-api-proxy");
const readyPath = path.join(directory, "ready.json");
const secret = `codex-proxy-synthetic-${process.pid}-${randomUUID()}`;
const cleanupPids = new Set();
t.after(() => {
for (const pid of cleanupPids) {
if (pid <= 1 || pid === process.pid || pid === process.ppid) {
continue;
}
try {
process.kill(pid, "SIGKILL");
} catch (error) {
if (error.code !== "ESRCH") {
throw error;
}
}
}
rmSync(directory, { recursive: true, force: true });
});
writeFileSync(
executable,
`#!/usr/bin/env node
const { readFileSync, writeFileSync } = require("node:fs");
writeFileSync(process.env.PROXY_READY_FILE, JSON.stringify({
pid: process.pid,
nodeOptions: process.env.NODE_OPTIONS,
apiKeyInEnvironment: Object.hasOwn(process.env, "PROXY_API_KEY"),
receivedApiKey: readFileSync(0, "utf8") === ${JSON.stringify(`${secret}\n`)},
}));
setInterval(() => {}, 1000);
`
);
chmodSync(executable, 0o755);
const step = actionStep("Start Responses API proxy");
const marker = " run: |\n";
const scriptStart = step.indexOf(marker);
assert.notEqual(scriptStart, -1, "missing proxy launcher script");
const script = step
.slice(scriptStart + marker.length)
.split("\n")
.map((line) => (line.startsWith(" ") ? line.slice(8) : line))
.join("\n");
const launcher = spawn("bash", ["-c", script], {
env: {
...process.env,
NODE_OPTIONS: "--require=/nonexistent-codex-action-preload.js",
PATH: `${directory}${path.delimiter}${process.env.PATH ?? ""}`,
PROXY_API_KEY: secret,
PROXY_READY_FILE: readyPath,
SERVER_INFO_FILE: path.join(directory, "server-info.json"),
UPSTREAM_URL: "",
},
stdio: "ignore",
});
t.after(() => launcher.kill());
assert.deepEqual(await once(launcher, "exit"), [0, null]);
let proxy;
const deadline = Date.now() + 5_000;
while (Date.now() < deadline) {
try {
proxy = JSON.parse(readFileSync(readyPath, "utf8"));
break;
} catch (error) {
if (error.code !== "ENOENT" && !(error instanceof SyntaxError)) {
throw error;
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
}
assert.ok(proxy, "background proxy did not report ready");
cleanupPids.add(proxy.pid);
assert.equal(proxy.nodeOptions, "--disable-sigusr1");
assert.equal(proxy.apiKeyInEnvironment, false);
assert.equal(proxy.receivedApiKey, true);
const secretBearingPids = [];
for (const entry of readdirSync("/proc")) {
if (!/^\d+$/.test(entry)) {
continue;
}
try {
const environment = readFileSync(`/proc/${entry}/environ`, "utf8");
if (environment.split("\0").includes(`PROXY_API_KEY=${secret}`)) {
const pid = Number(entry);
cleanupPids.add(pid);
secretBearingPids.push(pid);
}
} catch (error) {
if (!["EACCES", "ENOENT", "EPERM", "ESRCH"].includes(error.code)) {
throw error;
}
}
}
assert.deepEqual(
secretBearingPids,
[],
"a persistent background process retained PROXY_API_KEY"
);
}
);
test("Codex action and its descendants replace inherited Node options", () => {
const step = actionStep("Run codex exec");
assert.match(
step,
/exec env -u NODE_OPTIONS NODE_OPTIONS=--disable-sigusr1 node --disable-sigusr1 "\$ACTION_PATH\/dist\/main\.js" run-codex-exec/
);
});
test(
"nested Codex Node launchers inherit SIGUSR1 protection",
{ skip: process.platform === "win32", timeout: 5_000 },
async (t) => {
const nested = `const inspector = require("node:inspector");
process.stdout.write(JSON.stringify({
pid: process.pid,
nodeOptions: process.env.NODE_OPTIONS,
inspector: inspector.url() ?? null,
}) + "\\n");
process.stdin.once("data", () => {
process.stdout.write(JSON.stringify({ inspector: inspector.url() ?? null }) + "\\n");
});`;
const parent = `const { spawn } = require("node:child_process");
const child = spawn(process.execPath, ["-e", ${JSON.stringify(nested)}], {
stdio: ["pipe", "pipe", "pipe"],
});
child.stdout.pipe(process.stdout);
child.stderr.pipe(process.stderr);
process.stdin.pipe(child.stdin);
child.once("exit", (code) => { process.exitCode = code ?? 1; });`;
const child = spawn(
"env",
[
"-u",
"NODE_OPTIONS",
"NODE_OPTIONS=--disable-sigusr1",
process.execPath,
"--disable-sigusr1",
"-e",
parent,
],
{
env: {
...process.env,
NODE_OPTIONS: "--require=/nonexistent-codex-action-preload.js",
},
stdio: ["pipe", "pipe", "pipe"],
}
);
t.after(() => child.kill());
let stderr = "";
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
const [ready] = await once(child.stdout, "data");
const status = JSON.parse(ready.toString());
assert.equal(status.nodeOptions, "--disable-sigusr1");
assert.equal(status.inspector, null);
process.kill(status.pid, "SIGUSR1");
await new Promise((resolve) => setTimeout(resolve, 100));
const checked = once(child.stdout, "data");
const exited = once(child, "exit");
child.stdin.end("check");
const [afterSignal] = await checked;
assert.deepEqual(JSON.parse(afterSignal.toString()), { inspector: null });
assert.deepEqual(await exited, [0, null]);
assert.doesNotMatch(stderr, /Debugger listening/);
}
);
test("defers Linux sudo cleanup to the protected Codex launcher", () => {
const expected = `\${{ inputs['safety-strategy'] == 'drop-sudo' && (inputs['openai-api-key'] != '' || inputs.prompt != '' || inputs['prompt-file'] != '') && (runner.os != 'Linux' || (inputs.prompt == '' && inputs['prompt-file'] == '')) }}`;
for (const name of [
"Drop sudo privilege, if appropriate",
"Verify sudo privilege removed",
]) {
const condition = actionStep(name)
.split("\n")
.find((line) => line.startsWith(" if: "));
assert.equal(condition, ` if: ${expected}`);
}
});
+620
View File
@@ -0,0 +1,620 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import {
chmodSync,
copyFileSync,
existsSync,
mkdtempSync,
readFileSync,
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
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 canTestLinuxIsolation =
process.platform === "linux" &&
process.getuid?.() !== 0 &&
existsSync(dockerSocket) &&
spawnSync("sudo", ["-n", "true"]).status === 0;
const canTestSocketAcls =
canTestLinuxIsolation &&
spawnSync("/usr/bin/setfacl", ["--version"], { stdio: "ignore" }).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();
}
function snapshotRootSockets() {
const output = sudo([
"find",
"/run",
"-type",
"s",
"-uid",
"0",
"-printf",
"%p\t%U\t%G\t%m\n",
]);
return output.length === 0
? []
: output.split("\n").map((line) => {
const [socketPath, uid, gid, mode] = line.split("\t");
return { socketPath, uid, gid, mode };
});
}
function restoreRootSockets(sockets) {
for (const { socketPath, uid, gid, mode } of sockets) {
const exists = spawnSync("sudo", ["-n", "--", "test", "-S", socketPath]);
if (exists.status === 0) {
sudo(["chown", `${uid}:${gid}`, socketPath]);
sudo(["chmod", mode, socketPath]);
}
}
}
function createRootSocket(socketPath, group, mode) {
sudo([
process.execPath,
"-e",
'require("node:net").createServer().listen(process.argv[1], () => process.exit(0))',
socketPath,
]);
sudo(["chown", `0:${group}`, socketPath]);
sudo(["chmod", mode, socketPath]);
}
test(
"drop-sudo closes inherited Linux credential and service-socket bypasses",
{ skip: !canTestLinuxIsolation, timeout: 300_000 },
async (t) => {
if (!canTestSocketAcls) {
t.diagnostic("setfacl unavailable; named socket ACL coverage skipped");
}
for (const [index, scenario] of [
{
name: "workspace sandbox preserves compatible workflow arguments",
extraArgs: [
"--enable",
"use_legacy_landlock",
"--search",
"--ephemeral",
"--model",
"gpt-5.4",
"-c",
'model_reasoning_effort="high"',
"--config",
'service_tier="fast"',
],
verifyForwardedArgs: true,
},
{ name: "stale live groups cannot retain socket access", staleGroups: true },
{
name: "permission profiles cannot gain privileges",
profile: ":workspace",
},
{
name: "built-in read-only profiles cannot gain privileges",
profile: ":read-only",
},
{
name: "named permission profiles cannot gain privileges",
profile: "public-review",
},
{
name: "unconfined execution cannot gain privileges",
sandbox: "danger-full-access",
},
{
name: "the unconfined permission profile cannot gain privileges",
profile: ":danger-full-access",
},
{
name: "the explicit sandbox bypass cannot gain privileges",
extraArgs: ["--dangerously-bypass-approvals-and-sandbox"],
},
{
name: "the sandbox bypass alias cannot gain privileges",
extraArgs: ["--yolo"],
},
{
name: "hook trust bypasses fail before irreversible cleanup",
extraArgs: ["--dangerously-bypass-hook-trust"],
rejectedBeforeCleanup: true,
},
{
name: "read-only full-auto conflicts fail before irreversible cleanup",
sandbox: "read-only",
extraArgs: ["--full-auto"],
rejectedBeforeCleanup: true,
},
{
name: "MCP command overrides fail before irreversible cleanup",
extraArgs: ["--config", 'mcp_servers.evil.command="/bin/sh"'],
rejectedBeforeCleanup: true,
},
{
name: "the replacement primary group cannot inherit sudo policy",
replacementGroupSudoGrant: true,
},
{
name: "a command-specific User_Alias cannot gain privileges",
userAliasSudoGrant: true,
},
{ name: "remaining sudo grants fail closed", remainingSudoGrant: true },
].entries()) {
await t.test(scenario.name, { timeout: 45_000 }, (subtest) => {
const suffix = `${process.pid}${Date.now().toString(36)}${index}`;
const user = `codexdrop${suffix}`;
const privilegedGroup = `cdxp${suffix}`;
const worldGroup = `cdxw${suffix}`;
const remainingGroup = `cdxr${suffix}`;
const tempDir = mkdtempSync(
path.join(tmpdir(), "codex-action-drop-sudo-")
);
const capturePath = path.join(tempDir, "capture.json");
const outputPath = path.join(tempDir, "output.md");
const codexPath = path.join(tempDir, "codex");
const bundledActionPath = path.join(tempDir, "main.js");
const sudoersPath = `/etc/sudoers.d/${user}`;
const serviceSocket = `/run/codex-action-${suffix}-service.sock`;
const worldSocket = `/run/codex-action-${suffix}-world.sock`;
const fallbackSocket = `/run/codex-action-${suffix}-fallback.sock`;
const namedUserAclSocket = `/run/codex-action-${suffix}-acl-user.sock`;
const fallbackAclSocket = `/run/codex-action-${suffix}-acl-fallback.sock`;
const staleAclSocket = `/run/codex-action-${suffix}-acl-stale.sock`;
const liveGroupsPath = path.join(tempDir, "live-groups.txt");
const originalDockerSocket = statSync(dockerSocket);
const safeGroup = Number(sudo(["id", "-g", "nobody"]));
const safeGroupName = sudo(["id", "-gn", "nobody"]);
const sudoAlias = `CODEX_DROP_${suffix}`.toUpperCase();
const groupsCreated = [];
let userCreated = false;
let originalSockets = [];
subtest.after(() => {
sudo(["rm", "-f", sudoersPath]);
restoreRootSockets(originalSockets);
sudo([
"rm",
"-f",
serviceSocket,
worldSocket,
fallbackSocket,
namedUserAclSocket,
fallbackAclSocket,
staleAclSocket,
]);
if (userCreated) {
sudo(["userdel", user]);
}
for (const group of groupsCreated) {
sudo(["groupdel", group]);
}
sudo(["rm", "-rf", tempDir]);
});
for (const group of [
privilegedGroup,
worldGroup,
...(scenario.remainingSudoGrant ? [remainingGroup] : []),
]) {
sudo(["groupadd", group]);
groupsCreated.push(group);
}
const supplementaryGroups = [
"sudo",
privilegedGroup,
...(scenario.remainingSudoGrant ? [remainingGroup] : []),
];
sudo([
"useradd",
"--no-create-home",
"--gid",
String(originalDockerSocket.gid),
"--groups",
supplementaryGroups.join(","),
user,
]);
userCreated = true;
const userId = Number(sudo(["id", "-u", user]));
const sudoersSource = path.join(tempDir, "sudoers");
const sudoRules = [`${user} ALL=(ALL) NOPASSWD:ALL`];
if (scenario.remainingSudoGrant) {
sudoRules.push(`%${remainingGroup} ALL=(ALL) NOPASSWD:ALL`);
}
if (scenario.replacementGroupSudoGrant) {
sudoRules.push(
`%${safeGroupName} ALL=(root) NOPASSWD: /usr/bin/true`
);
}
if (scenario.userAliasSudoGrant) {
sudoRules.push(
`User_Alias ${sudoAlias} = ${user}`,
`${sudoAlias} ALL=(root) NOPASSWD: /usr/bin/id -u`
);
}
writeFileSync(
sudoersSource,
`${sudoRules.join("\n")}\n`
);
sudo(["install", "--mode=0440", sudoersSource, sudoersPath]);
createRootSocket(serviceSocket, privilegedGroup, "0660");
createRootSocket(worldSocket, worldGroup, "0666");
createRootSocket(fallbackSocket, String(safeGroup), "0660");
if (canTestSocketAcls) {
createRootSocket(namedUserAclSocket, worldGroup, "0660");
createRootSocket(fallbackAclSocket, worldGroup, "0660");
sudo([
"/usr/bin/setfacl",
"-m",
`u:${user}:rw`,
namedUserAclSocket,
]);
sudo([
"/usr/bin/setfacl",
"-m",
`g:${safeGroup}:rw`,
fallbackAclSocket,
]);
if (scenario.staleGroups) {
createRootSocket(staleAclSocket, worldGroup, "0660");
sudo(["/usr/bin/setfacl", "-m", `g:${privilegedGroup}:rw`, staleAclSocket]);
}
}
originalSockets = snapshotRootSockets();
for (const socket of [
dockerSocket,
serviceSocket,
worldSocket,
...(canTestSocketAcls ? [namedUserAclSocket] : []),
...(canTestSocketAcls && scenario.staleGroups ? [staleAclSocket] : []),
]) {
sudo(["/usr/bin/sudo", "-n", "-u", user, "--", "test", "-w", socket]);
}
assert.notEqual(
spawnSync("sudo", [
"-n",
"-u",
user,
"--",
"test",
"-w",
fallbackSocket,
]).status,
0
);
if (canTestSocketAcls) {
assert.notEqual(
spawnSync("sudo", [
"-n",
"-u",
user,
"--",
"test",
"-w",
fallbackAclSocket,
]).status,
0
);
sudo([
"/usr/bin/setpriv",
`--reuid=${userId}`,
`--regid=${safeGroup}`,
"--clear-groups",
"--",
"/usr/bin/test",
"-w",
fallbackAclSocket,
]);
}
copyFileSync(mainPath, bundledActionPath);
writeFileSync(
codexPath,
`#!${process.execPath}
const { accessSync, constants, readFileSync, writeFileSync } = require("node:fs");
const { spawnSync } = require("node:child_process");
const args = process.argv.slice(2);
const output = args[args.indexOf("--output-last-message") + 1];
function canWrite(socket) {
try {
accessSync(socket, constants.W_OK);
return true;
} catch {
return false;
}
}
const status = Object.fromEntries(
readFileSync("/proc/self/status", "utf8")
.split("\\n")
.map((line) => line.split(/:\\s*/, 2))
);
const child = spawnSync(process.execPath, ["-p", "process.env.NODE_OPTIONS"], {
encoding: "utf8",
});
const sudoTrue = spawnSync("/usr/bin/sudo", ["-n", "/usr/bin/true"], {
encoding: "utf8",
});
const sudoId = spawnSync("/usr/bin/sudo", ["-n", "/usr/bin/id", "-u"], {
encoding: "utf8",
});
writeFileSync(process.env.CODEX_CAPTURE_PATH, JSON.stringify({
args,
uid: process.getuid(),
gid: process.getgid(),
groups: process.getgroups(),
supplementaryGroups: status.Groups.trim(),
dockerAccessible: canWrite("/var/run/docker.sock"),
serviceAccessible: canWrite(process.env.CODEX_SERVICE_SOCKET),
worldAccessible: canWrite(process.env.CODEX_WORLD_SOCKET),
fallbackAccessible: canWrite(process.env.CODEX_FALLBACK_SOCKET),
namedUserAclAccessible: process.env.CODEX_NAMED_USER_ACL_SOCKET
? canWrite(process.env.CODEX_NAMED_USER_ACL_SOCKET)
: null,
fallbackAclAccessible: process.env.CODEX_FALLBACK_ACL_SOCKET
? canWrite(process.env.CODEX_FALLBACK_ACL_SOCKET)
: null,
sudoStatus: sudoTrue.status,
sudoIdStatus: sudoId.status,
sudoIdStdout: sudoId.stdout.trim(),
noNewPrivs: status.NoNewPrivs,
capBounding: status.CapBnd,
capEffective: status.CapEff,
capInheritable: status.CapInh,
capPermitted: status.CapPrm,
capAmbient: status.CapAmb,
nodeOptions: process.env.NODE_OPTIONS,
childNodeOptions: child.stdout.trim(),
home: process.env.HOME,
marker: process.env.CODEX_TEST_MARKER,
prompt: readFileSync(0, "utf8"),
}));
writeFileSync(output, "fake final message\\n");
`
);
chmodSync(codexPath, 0o755);
chmodSync(tempDir, 0o711);
sudo(["chown", "-R", user, tempDir]);
const command = [
"-n",
"-u",
user,
"--",
"/usr/bin/env",
`HOME=${tempDir}`,
`PATH=${tempDir}:${process.env.PATH ?? ""}`,
"NODE_OPTIONS=--disable-sigusr1",
`CODEX_CAPTURE_PATH=${capturePath}`,
`CODEX_SERVICE_SOCKET=${serviceSocket}`,
`CODEX_WORLD_SOCKET=${worldSocket}`,
`CODEX_FALLBACK_SOCKET=${fallbackSocket}`,
...(canTestSocketAcls
? [
`CODEX_NAMED_USER_ACL_SOCKET=${namedUserAclSocket}`,
`CODEX_FALLBACK_ACL_SOCKET=${fallbackAclSocket}`,
]
: []),
"CODEX_TEST_MARKER=preserved",
process.execPath,
bundledActionPath,
"run-codex-exec",
"--prompt",
"test prompt\nsecond line",
"--prompt-file",
"",
"--codex-home",
"",
"--cd",
tempDir,
"--extra-args",
scenario.extraArgs ? JSON.stringify(scenario.extraArgs) : "",
"--output-file",
outputPath,
"--output-schema-file",
"",
"--output-schema",
"",
"--sandbox",
scenario.sandbox ?? "",
"--permission-profile",
scenario.profile ?? "",
"--model",
"",
"--effort",
"",
"--safety-strategy",
"drop-sudo",
"--codex-user",
"",
];
const launchCommand = scenario.staleGroups
? [
...command.slice(0, 4),
"/bin/sh", "-ec",
'/usr/bin/sudo -n /usr/bin/gpasswd -d "$1" "$2"; /usr/bin/id -G > "$3"; shift 3; exec "$@"',
"codex-stale-groups", user, privilegedGroup, liveGroupsPath,
...command.slice(4),
]
: command;
const result = spawnSync("sudo", launchCommand, {
encoding: "utf8",
timeout: 35_000,
});
if (scenario.rejectedBeforeCleanup) {
assert.notEqual(result.status, 0);
assert.match(result.stderr, /codex-args/);
assert.equal(existsSync(capturePath), false);
const stillPrivileged = spawnSync(
"/usr/bin/sudo",
["-n", "-u", user, "--", "/usr/bin/sudo", "-n", "/usr/bin/true"],
{ encoding: "utf8" }
);
assert.equal(stillPrivileged.status, 0, stillPrivileged.stderr);
const accountGroups = sudo(["id", "-nG", user]).split(/\s+/);
assert.equal(accountGroups.includes("sudo"), true);
assert.equal(accountGroups.includes(privilegedGroup), true);
for (const { socketPath, mode } of originalSockets) {
assert.equal(
Number.parseInt(sudo(["stat", "-c", "%a", socketPath]), 8),
Number.parseInt(mode, 8),
`${socketPath} changed before argument rejection`
);
}
return;
}
if (scenario.remainingSudoGrant) {
assert.notEqual(result.status, 0);
assert.match(result.stderr, /Expected sudo to be disabled/);
assert.equal(existsSync(capturePath), false);
return;
}
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.match(
result.stdout,
/Confirmed the standard sudo probe is disabled/
);
assert.match(
result.stdout,
/Launching Codex with no_new_privs and empty capability sets/
);
const capture = JSON.parse(readFileSync(capturePath, "utf8"));
assert.equal(capture.uid, userId);
assert.equal(capture.gid, safeGroup);
assert.notEqual(capture.gid, originalDockerSocket.gid);
assert.deepEqual(capture.groups, [safeGroup]);
assert.equal(capture.supplementaryGroups, "");
assert.equal(capture.dockerAccessible, false);
assert.equal(capture.serviceAccessible, false);
assert.equal(capture.worldAccessible, false);
assert.equal(capture.fallbackAccessible, false);
assert.equal(
capture.namedUserAclAccessible,
canTestSocketAcls ? false : null
);
assert.equal(
capture.fallbackAclAccessible,
canTestSocketAcls ? false : null
);
assert.ok(Number.isInteger(capture.sudoStatus));
assert.notEqual(capture.sudoStatus, 0);
assert.ok(Number.isInteger(capture.sudoIdStatus));
assert.notEqual(capture.sudoIdStatus, 0);
assert.notEqual(capture.sudoIdStdout, "0");
assert.equal(capture.noNewPrivs, "1");
assert.equal(capture.capBounding, "0000000000000000");
assert.equal(capture.capEffective, "0000000000000000");
assert.equal(capture.capInheritable, "0000000000000000");
assert.equal(capture.capPermitted, "0000000000000000");
assert.equal(capture.capAmbient, "0000000000000000");
assert.equal(capture.nodeOptions, "--disable-sigusr1");
assert.equal(capture.childNodeOptions, "--disable-sigusr1");
assert.equal(capture.home, tempDir);
assert.equal(capture.marker, "preserved");
assert.equal(capture.prompt, "test prompt\nsecond line");
if (scenario.verifyForwardedArgs) {
const forwardedStart = capture.args.indexOf(scenario.extraArgs[0]);
assert.notEqual(forwardedStart, -1);
assert.deepEqual(
capture.args.slice(
forwardedStart,
forwardedStart + scenario.extraArgs.length
),
scenario.extraArgs
);
}
assert.equal(readFileSync(outputPath, "utf8"), "fake final message\n");
if (
scenario.replacementGroupSudoGrant ||
scenario.userAliasSudoGrant
) {
const grantedCommand = scenario.replacementGroupSudoGrant
? ["/usr/bin/true"]
: ["/usr/bin/id", "-u"];
const unprotected = spawnSync(
"/usr/bin/sudo",
[
"-n",
"--",
"/usr/bin/setpriv",
`--reuid=${userId}`,
`--regid=${safeGroup}`,
"--clear-groups",
"--",
"/usr/bin/sudo",
"-n",
...grantedCommand,
],
{ encoding: "utf8" }
);
assert.equal(
unprotected.status,
0,
`expected the residual sudo rule to remain effective without no_new_privs: ${unprotected.stderr}`
);
if (scenario.userAliasSudoGrant) {
assert.equal(unprotected.stdout.trim(), "0");
}
}
const accountGroups = sudo(["id", "-nG", user]).split(/\s+/);
assert.equal(accountGroups.includes("sudo"), false);
assert.equal(accountGroups.includes(privilegedGroup), false);
if (scenario.staleGroups) {
const staleGid = sudo(["getent", "group", privilegedGroup]).split(":")[2];
assert.ok(readFileSync(liveGroupsPath, "utf8").trim().split(/\s+/).includes(staleGid));
}
for (const socket of [
dockerSocket,
serviceSocket,
worldSocket,
fallbackSocket,
...(canTestSocketAcls
? [namedUserAclSocket, fallbackAclSocket]
: []),
...(canTestSocketAcls && scenario.staleGroups ? [staleAclSocket] : []),
]) {
assert.equal(statSync(socket).mode & 0o077, 0);
const access = spawnSync("sudo", [
"-n",
"-u",
user,
"--",
"test",
"-w",
socket,
]);
assert.notEqual(access.status, 0);
}
const failed = spawnSync("sudo", command, {
encoding: "utf8",
timeout: 30_000,
});
assert.notEqual(failed.status, 0);
assert.match(failed.stderr, /requires passwordless sudo.*separate job/);
assert.deepEqual(JSON.parse(readFileSync(capturePath, "utf8")), capture);
});
}
}
);
+72
View File
@@ -0,0 +1,72 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import {
chmodSync,
existsSync,
mkdtempSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
const mainPath = fileURLToPath(new URL("../dist/main.js", import.meta.url));
const canTestRootPath =
process.platform === "linux" &&
process.getuid?.() !== 0 &&
spawnSync("/usr/bin/sudo", ["-n", "true"], { stdio: "ignore" }).status === 0;
test(
"drop-sudo ignores caller-controlled PATH before running privileged commands",
{ skip: !canTestRootPath },
() => {
const tempDir = mkdtempSync(path.join(tmpdir(), "codex-action-root-path-"));
const markerPath = path.join(tempDir, "executed-as-root");
const missingUser = `cdxpath${process.pid}${Date.now().toString(36)}`;
try {
for (const command of ["id", "sh", "deluser", "gpasswd"]) {
const fakeCommand = path.join(tempDir, command);
writeFileSync(
fakeCommand,
`#!/bin/sh\n/usr/bin/touch "$CODEX_ROOT_PATH_MARKER"\nexit 97\n`
);
chmodSync(fakeCommand, 0o755);
}
const result = spawnSync(
"/usr/bin/sudo",
[
"-n",
"--",
"/usr/bin/env",
`PATH=${tempDir}:${process.env.PATH ?? ""}`,
`CODEX_ROOT_PATH_MARKER=${markerPath}`,
process.execPath,
mainPath,
"drop-sudo",
"--root-phase",
"--user",
missingUser,
"--group",
"sudo",
"--runner-credentials",
JSON.stringify({
userId: process.getuid(),
primaryGroupId: process.getgid(),
supplementaryGroupIds: process.getgroups(),
}),
],
{ encoding: "utf8", timeout: 10_000 }
);
assert.notEqual(result.status, 0);
assert.match(result.stderr, /Command failed: id /);
assert.equal(existsSync(markerPath), false, result.stderr);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
}
);
+107
View File
@@ -0,0 +1,107 @@
import assert from "node:assert/strict";
import { buildSync } from "esbuild";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";
import { EventEmitter } from "node:events";
import vm from "node:vm";
const { outputFiles } = buildSync({
entryPoints: [fileURLToPath(new URL("../src/linuxCredentials.ts", import.meta.url))],
bundle: true,
format: "esm",
platform: "node",
write: false,
});
const { parseLinuxRunnerCredentials, includeAccountGroups } = await import(
`data:text/javascript;base64,${Buffer.from(outputFiles[0].text).toString("base64")}`
);
const original = { userId: 1001, primaryGroupId: 999, supplementaryGroupIds: [27, 998] };
const require = createRequire(import.meta.url);
function loadLauncher(file, spawn) {
const { outputFiles } = buildSync({
entryPoints: [fileURLToPath(new URL(`../src/${file}.ts`, import.meta.url))],
bundle: true, format: "cjs", platform: "node", write: false,
external: ["./checkOutput", "@actions/core"],
});
const module = { exports: {} };
vm.runInNewContext(outputFiles[0].text, {
module, exports: module.exports,
require(name) {
if (name === "child_process" || name === "node:child_process") return { spawn };
if (name === "./checkOutput") return { checkOutput: async (args) => args[0] === "which" ? "/synthetic/codex" : "" };
if (name === "@actions/core") return { setOutput() {} };
if (name === "os") return { userInfo: () => ({ username: "runner", homedir: "/synthetic" }) };
return require(name);
},
process: { platform: "linux", getuid: () => original.userId,
getgid: () => original.primaryGroupId, getgroups: () => original.supplementaryGroupIds,
execPath: "/synthetic/node", argv: ["node", "/synthetic/main.js"], execArgv: [], env: {} },
console: { log() {} },
});
return module.exports;
}
test("both Linux launch paths pass the original process credentials", async () => {
const stopped = new Error("captured before privileged execution");
let captured;
const run = loadLauncher("runCodexExec", (program, args) => {
captured = { program, args };
throw stopped;
});
await assert.rejects(run.runCodexExec({
prompt: { type: "inline", content: "test" }, codexHome: null, cd: "/synthetic",
extraArgs: [], explicitOutputFile: "/synthetic/output", outputSchema: null,
model: null, effort: null, safetyStrategy: "drop-sudo", codexUser: null,
sandbox: null, permissionProfile: null,
}), (error) => error === stopped);
const marker = captured.args.indexOf("codex-action-drop-sudo");
assert.deepEqual(JSON.parse(captured.args[marker + 8]), original);
assert.equal(captured.args[marker + 9], "/synthetic/codex");
assert.match(captured.args[captured.args.indexOf("-c") + 1], /--runner-credentials "\$runner_credentials"/);
const drop = loadLauncher("dropSudo", (program, args) => {
if (args.includes("--root-phase")) {
captured = { program, args };
throw stopped;
}
const child = new EventEmitter();
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.stdout.setEncoding = child.stderr.setEncoding = () => {};
process.nextTick(() => child.emit("close", 0));
return child;
});
await assert.rejects(drop.dropSudo({ user: "runner", group: "sudo", rootPhase: false }), (error) => error === stopped);
assert.deepEqual(JSON.parse(captured.args[captured.args.indexOf("--runner-credentials") + 1]), original);
});
test("captures the live credentials without serializing them", () => {
const { captureLinuxRunnerCredentials } = loadLauncher("linuxCredentials", () => {
assert.fail("capturing credentials must not launch a process");
});
const captured = captureLinuxRunnerCredentials();
assert.equal(captured.userId, original.userId);
assert.equal(captured.primaryGroupId, original.primaryGroupId);
assert.equal(captured.supplementaryGroupIds, original.supplementaryGroupIds);
});
test("retains live-only primary and supplementary groups", () => {
const result = includeAccountGroups(original, 1001, [1001, 27, 997]);
assert.equal(result.primaryGroupId, 999);
assert.deepEqual(result.supplementaryGroupIds, [27, 998, 1001, 997]);
assert.throws(() => includeAccountGroups(original, 1002, [1002]), /does not match/);
});
test("rejects malformed or root runner credentials", () => {
for (const value of [
null, {}, { ...original, userId: 0 }, { ...original, userId: -1 },
{ ...original, userId: "1001" }, { ...original, primaryGroupId: 0xffffffff },
{ ...original, supplementaryGroupIds: [1.5] },
{ ...original, supplementaryGroupIds: [null] },
{ ...original, supplementaryGroupIds: Array(65537).fill(1) },
]) {
assert.throws(() => parseLinuxRunnerCredentials(JSON.stringify(value)), /Invalid original/);
}
});
+443 -5
View File
@@ -24,7 +24,7 @@ function runCodexExecWithFakeCodex({
sandbox = "",
permissionProfile = "",
extraArgs = "",
safetyStrategy = "drop-sudo",
safetyStrategy = "unsafe",
} = {}) {
const tempDir = mkdtempSync(path.join(tmpdir(), "codex-action-permissions-"));
const capturePath = path.join(tempDir, "args.json");
@@ -162,18 +162,456 @@ test("rejects permission-profile with the read-only safety strategy", () => {
assert.match(result.stderr, /forces the legacy read-only sandbox/);
});
test("rejects permission-profile with a sandbox in codex-args", () => {
for (const extraArgs of [
'["--sandbox", "read-only"]',
'["--sandbox=read-only"]',
'["-s", "read-only"]',
'["-s=read-only"]',
'["-sworkspace-write"]',
]) {
test(`rejects permission-profile with ${extraArgs} in codex-args`, () => {
const { result, capturedArgs } = runCodexExecWithFakeCodex({
permissionProfile: "public-review",
extraArgs,
});
assert.notEqual(result.status, 0);
assert.equal(capturedArgs, null);
assert.match(result.stderr, /sandbox override in `codex-args`/);
});
}
for (const [name, override] of [
["notifications", 'notify=["sh","-c","touch /tmp/outside"]'],
["MCP servers", 'mcp_servers.evil.command="/bin/sh"'],
["inline hooks", 'hooks.SessionStart=[{hooks=[{type="command",command="id"}]}]'],
["hook trust state", 'hooks.state={"hook:session_start:0:0"={trusted_hash="sha256:test"}}'],
["model provider selection", 'model_provider="attacker"'],
[
"model provider commands",
'model_providers.codex-action-responses-proxy.auth.command="/bin/sh"',
],
["provider endpoint", 'openai_base_url="https://attacker.example"'],
["ChatGPT endpoint", 'chatgpt_base_url="https://attacker.example"'],
["apps MCP identity", 'apps_mcp_product_sku="attacker"'],
[
"realtime WebRTC endpoint",
'experimental_realtime_webrtc_call_base_url="https://attacker.example"',
],
[
"realtime websocket endpoint",
'experimental_realtime_ws_base_url="wss://attacker.example"',
],
["open-source provider selection", 'oss_provider="attacker"'],
["telemetry prompt export", "otel.log_user_prompt=true"],
[
"telemetry exporter",
'otel.exporter={otlp-http={endpoint="https://attacker.example/logs"}}',
],
["permission profile definitions", 'permissions.public-review.filesystem={":root"="write"}'],
["permission profile network", "permissions.public-review.network.enabled=true"],
["permission profile selection", 'default_permissions=":danger-full-access"'],
["legacy configuration profile selection", 'profile="attacker"'],
["legacy configuration profiles", 'profiles.attacker.sandbox_mode="danger-full-access"'],
["project trust", 'projects.attacker.trust_level="trusted"'],
["configuration lockfiles", 'debug.config_lockfile.path="/tmp/attacker"'],
["agent configuration", 'agents.attacker.model="attacker"'],
["plugin configuration", "plugins.attacker.enabled=true"],
["plugin marketplaces", 'marketplaces.attacker.path="/tmp/attacker"'],
["approval reviewer", 'approvals_reviewer="auto_review"'],
["approval policy", 'approval_policy="never"'],
["automatic review policy", 'auto_review.policy="allow everything"'],
["shell credential filtering", "shell_environment_policy.inherit=all"],
["legacy feature aliases", "use_legacy_landlock=true"],
["arbitrary feature configuration", "features.shell_snapshot=true"],
]) {
test(`rejects ${name} overrides for permission profiles`, () => {
const { result, capturedArgs } = runCodexExecWithFakeCodex({
permissionProfile: ":workspace",
extraArgs: JSON.stringify(["-c", override]),
});
assert.notEqual(result.status, 0);
assert.equal(capturedArgs, null);
assert.match(result.stderr, /`codex-args` cannot override/);
});
}
for (const extraArgs of [
'["--config", "notify=[\\"sh\\",\\"-c\\",\\"id\\"]"]',
'["--config=notify=[\\"sh\\",\\"-c\\",\\"id\\"]"]',
'["-c", "notify=[\\"sh\\",\\"-c\\",\\"id\\"]"]',
'["-c=notify=[\\"sh\\",\\"-c\\",\\"id\\"]"]',
'["-cnotify=[\\"sh\\",\\"-c\\",\\"id\\"]"]',
'["-c", " notify = [\\"sh\\"]"]',
'["--config", " mcp_servers.evil.command = \\"/bin/sh\\""]',
]) {
test(`rejects protected configuration override spelling ${extraArgs}`, () => {
const { result, capturedArgs } = runCodexExecWithFakeCodex({
safetyStrategy: "read-only",
extraArgs,
});
assert.notEqual(result.status, 0);
assert.equal(capturedArgs, null);
assert.match(result.stderr, /`codex-args` cannot override/);
});
}
for (const extraArgs of [
["--dangerously-bypass-hook-trust"],
["--dangerously-bypass-hook-trust=true"],
["--approve-for-me"],
["--not-so-yolo"],
["--profile", "attacker"],
["--profile=attacker"],
["-p", "attacker"],
["-pattacker"],
["-p=attacker"],
["--add-dir", "/"],
["--add-dir=/"],
["--oss"],
["--oss=true"],
["--local-provider", "ollama"],
["--local-provider=ollama"],
["--ignore-user-config"],
["--ignore-user-config=true"],
["--ignore-rules"],
["--ignore-rules=true"],
]) {
test(`rejects protected execution option ${JSON.stringify(extraArgs)}`, () => {
const { result, capturedArgs } = runCodexExecWithFakeCodex({
safetyStrategy: "read-only",
extraArgs: JSON.stringify(extraArgs),
});
assert.notEqual(result.status, 0);
assert.equal(capturedArgs, null);
assert.match(result.stderr, /`codex-args` cannot use/);
});
}
for (const extraArgs of [
["--enable", "hooks"],
["--enable=hooks"],
["--enable"],
["--disable", "use_legacy_landlock"],
["--disable=hooks"],
]) {
test(`rejects unsafe protected feature toggle ${JSON.stringify(extraArgs)}`, () => {
const { result, capturedArgs } = runCodexExecWithFakeCodex({
permissionProfile: ":workspace",
extraArgs: JSON.stringify(extraArgs),
});
assert.notEqual(result.status, 0);
assert.equal(capturedArgs, null);
assert.match(result.stderr, /`codex-args` can/);
});
}
for (const override of [
'"hooks".SessionStart=[]',
"hooks .SessionStart=[]",
"hooks[0]=[]",
"=value",
"missing_equals",
]) {
test(`rejects ambiguous protected configuration key ${override}`, () => {
const { result, capturedArgs } = runCodexExecWithFakeCodex({
safetyStrategy: "read-only",
extraArgs: JSON.stringify(["-c", override]),
});
assert.notEqual(result.status, 0);
assert.equal(capturedArgs, null);
assert.match(result.stderr, /invalid or ambiguous configuration override/);
});
}
test("rejects unsafe configuration before protected drop-sudo cleanup", () => {
const { result, capturedArgs } = runCodexExecWithFakeCodex({
permissionProfile: "public-review",
extraArgs: '["--sandbox", "read-only"]',
safetyStrategy: "drop-sudo",
extraArgs: '["-c", "mcp_servers.evil.command=sh"]',
});
assert.notEqual(result.status, 0);
assert.equal(capturedArgs, null);
assert.match(result.stderr, /sandbox override in `codex-args`/);
assert.match(result.stderr, /cannot override `mcp_servers.evil.command`/);
});
for (const bypassFlag of [
"--dangerously-bypass-approvals-and-sandbox",
"--yolo",
"--full-auto",
]) {
test(`rejects ${bypassFlag} with an explicitly read-only sandbox`, () => {
const { result, capturedArgs } = runCodexExecWithFakeCodex({
sandbox: "read-only",
extraArgs: JSON.stringify([bypassFlag]),
});
assert.notEqual(result.status, 0);
assert.equal(capturedArgs, null);
assert.match(result.stderr, /cannot bypass sandbox protections/);
});
}
test("rejects --full-auto with an explicit permission profile", () => {
const { result, capturedArgs } = runCodexExecWithFakeCodex({
permissionProfile: ":workspace",
extraArgs: '["--full-auto"]',
});
assert.notEqual(result.status, 0);
assert.equal(capturedArgs, null);
assert.match(result.stderr, /cannot bypass sandbox protections/);
});
test("rejects --full-auto with the read-only safety strategy", () => {
const { result, capturedArgs } = runCodexExecWithFakeCodex({
safetyStrategy: "read-only",
extraArgs: '["--full-auto"]',
});
assert.notEqual(result.status, 0);
assert.equal(capturedArgs, null);
assert.match(result.stderr, /cannot bypass sandbox protections/);
});
for (const [name, override] of [
["model instruction files", 'model_instructions_file="/tmp/sensitive.txt"'],
["compaction prompt files", 'experimental_compact_prompt_file="/tmp/sensitive.txt"'],
["model catalog files", 'model_catalog_json="/tmp/sensitive.json"'],
]) {
test(`rejects unsandboxed ${name} reads for custom permission profiles`, () => {
const { result, capturedArgs } = runCodexExecWithFakeCodex({
permissionProfile: "public-review",
extraArgs: JSON.stringify(["-c", override]),
});
assert.notEqual(result.status, 0);
assert.equal(capturedArgs, null);
assert.match(result.stderr, /`codex-args` cannot override/);
});
}
test("preserves instruction and catalog overrides for root-readable profiles", () => {
const extraArgs = [
"-c",
'model_instructions_file="/tmp/instructions.txt"',
"-c",
'experimental_compact_prompt_file="/tmp/compact.txt"',
"-c",
'model_catalog_json="/tmp/models.json"',
];
const { result, capturedArgs } = runCodexExecWithFakeCodex({
permissionProfile: ":workspace",
extraArgs: JSON.stringify(extraArgs),
});
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(capturedArgs.slice(-extraArgs.length - 2, -2), extraArgs);
});
test("preserves ordinary parent-process state directory configuration", () => {
const extraArgs = [
"-c",
'sqlite_home="/tmp/codex-state"',
"-c",
'log_dir="/tmp/codex-logs"',
];
const { result, capturedArgs } = runCodexExecWithFakeCodex({
safetyStrategy: "read-only",
extraArgs: JSON.stringify(extraArgs),
});
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(capturedArgs.slice(-extraArgs.length - 2, -2), extraArgs);
});
for (const extraArgs of [
["--image", "/tmp/sensitive.png"],
["--image=/tmp/sensitive.png"],
["-i", "/tmp/sensitive.png"],
["-i/tmp/sensitive.png"],
]) {
test(`rejects custom-profile image read ${JSON.stringify(extraArgs)}`, () => {
const { result, capturedArgs } = runCodexExecWithFakeCodex({
permissionProfile: "public-review",
extraArgs: JSON.stringify(extraArgs),
});
assert.notEqual(result.status, 0);
assert.equal(capturedArgs, null);
assert.match(result.stderr, /cannot attach local images with a custom permission profile/);
});
}
for (const permissionProfile of [":workspace", ":read-only"]) {
test(`preserves image inputs for built-in ${permissionProfile} profiles`, () => {
const { result, capturedArgs } = runCodexExecWithFakeCodex({
permissionProfile,
extraArgs: '["--image", "/tmp/screenshot.png"]',
});
assert.equal(result.status, 0, result.stderr);
assert.ok(capturedArgs.includes("--image"));
});
}
test("preserves image inputs without a custom permission profile", () => {
const { result, capturedArgs } = runCodexExecWithFakeCodex({
safetyStrategy: "read-only",
extraArgs: '["-i", "/tmp/screenshot.png"]',
});
assert.equal(result.status, 0, result.stderr);
assert.ok(capturedArgs.includes("-i"));
});
test("preserves safe Codex options with an explicit permission profile", () => {
const extraArgs = ["--search", "--ephemeral", "--model", "gpt-5.4"];
const { result, capturedArgs } = runCodexExecWithFakeCodex({
permissionProfile: ":workspace",
extraArgs: JSON.stringify(extraArgs),
});
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(capturedArgs.slice(-6, -2), extraArgs);
});
test("preserves shell-style safe options and benign configuration overrides", () => {
const extraArgs = [
"--search",
"--ephemeral",
"--model",
"gpt-5.4",
"-c",
"model_reasoning_effort=medium",
"-c",
"service_tier=flex",
];
const { result, capturedArgs } = runCodexExecWithFakeCodex({
permissionProfile: ":workspace",
extraArgs: extraArgs.join(" "),
});
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(capturedArgs.slice(-extraArgs.length - 2, -2), extraArgs);
});
test("preserves compatible feature flags and benign protected config overrides", () => {
const extraArgs = [
"--enable",
"use_legacy_landlock",
"--search",
"--ephemeral",
"--model",
"gpt-5.4",
"-c",
'model_reasoning_effort="high"',
"--config",
'service_tier="fast"',
"--config=features.use_legacy_landlock=true",
];
const { result, capturedArgs } = runCodexExecWithFakeCodex({
safetyStrategy: "read-only",
extraArgs: JSON.stringify(extraArgs),
});
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(capturedArgs.slice(-extraArgs.length - 2, -2), extraArgs);
assert.deepEqual(capturedArgs.slice(-2), ["--sandbox", "read-only"]);
});
test("preserves the equals form of the compatible legacy Landlock flag", () => {
const { result, capturedArgs } = runCodexExecWithFakeCodex({
permissionProfile: ":workspace",
extraArgs: '["--enable=use_legacy_landlock"]',
});
assert.equal(result.status, 0, result.stderr);
assert.ok(capturedArgs.includes("--enable=use_legacy_landlock"));
});
test("preserves ordinary unknown Codex options in protected modes", () => {
const { result, capturedArgs } = runCodexExecWithFakeCodex({
permissionProfile: ":workspace",
extraArgs: '["--future-safe-option", "value"]',
});
assert.equal(result.status, 0, result.stderr);
assert.ok(capturedArgs.includes("--future-safe-option"));
});
test("preserves unrestricted extra arguments for explicitly unsafe execution", () => {
const extraArgs = [
"--dangerously-bypass-hook-trust",
"--add-dir",
"/",
"--oss",
"--ignore-user-config",
"--enable",
"hooks",
"-c",
'notify=["sh","-c","id"]',
];
const { result, capturedArgs } = runCodexExecWithFakeCodex({
safetyStrategy: "unsafe",
extraArgs: JSON.stringify(extraArgs),
});
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(capturedArgs.slice(-extraArgs.length - 2, -2), extraArgs);
});
for (const extraArgs of ["[1]", "[null]", "[{}]", "[[]]", '["--json", true]']) {
test(`rejects non-string JSON Codex arguments ${extraArgs}`, () => {
const { result, capturedArgs } = runCodexExecWithFakeCodex({ extraArgs });
assert.notEqual(result.status, 0);
assert.equal(capturedArgs, null);
assert.match(result.stderr, /`codex-args` must be a JSON array of strings/);
});
}
for (const bypassFlag of [
"--dangerously-bypass-approvals-and-sandbox",
"--yolo",
]) {
test(`rejects permission-profile with ${bypassFlag} in codex-args`, () => {
const { result, capturedArgs } = runCodexExecWithFakeCodex({
permissionProfile: "public-review",
extraArgs: JSON.stringify([bypassFlag]),
});
assert.notEqual(result.status, 0);
assert.equal(capturedArgs, null);
assert.match(result.stderr, /cannot bypass sandbox protections/);
});
test(`rejects read-only safety strategy with ${bypassFlag} in codex-args`, () => {
const { result, capturedArgs } = runCodexExecWithFakeCodex({
safetyStrategy: "read-only",
extraArgs: JSON.stringify([bypassFlag]),
});
assert.notEqual(result.status, 0);
assert.equal(capturedArgs, null);
assert.match(result.stderr, /cannot bypass sandbox protections/);
});
}
for (const extraArgs of [
'["--config", "sandbox_mode=danger-full-access"]',
'["--config=sandbox_mode=danger-full-access"]',
'["-c", "sandbox_mode=danger-full-access"]',
'["-c=sandbox_mode=danger-full-access"]',
'["-csandbox_mode=danger-full-access"]',
'["-csandbox_workspace_write.network_access=true"]',
'["--config", " sandbox_mode = danger-full-access"]',
'["-c", "sandbox_workspace_write = {}"]',
'["--config", "sandbox_workspace_write.network_access=true"]',
'["--config=sandbox_workspace_write.network_access=true"]',
]) {