Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 42 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ In the following example, we define a workflow that is triggered whenever a user
- Runs Codex with a `prompt` that includes the details specific to the PR.
- Takes the output from Codex and posts it as a comment on the PR.

See [`security.md`](./docs/security.md) for tips on using `openai/codex-action` securely.
See [`security.md`](./docs/security.md) for tips on using `openai/codex-action` securely and the
[Codex permissions documentation](https://developers.openai.com/codex/permissions) for configuring
filesystem and network access.

```yaml
name: Perform a code review when a pull request is created.
Expand Down Expand Up @@ -48,14 +50,15 @@ jobs:
"+refs/pull/$PR_NUMBER/head"

# If you want Codex to build and run code, install any dependencies that
# need to be downloaded before the "Run Codex" step because Codex's
# default sandbox disables network access.
# need to be downloaded before the "Run Codex" step. The recommended
# :workspace permission profile does not grant network access.

- name: Run Codex
id: run_codex
uses: openai/codex-action@v1
with:
openai-api-key: ${{ secrets.OPENAI_API_KEY }}
permission-profile: ":workspace"
prompt: |
This is PR #${{ github.event.pull_request.number }} for ${{ github.repository }}.

Expand Down Expand Up @@ -103,7 +106,8 @@ jobs:
| `prompt-file` | Path (relative to the repository root) of a file that contains the prompt. Provide this or `prompt`. | `""` |
| `output-file` | File where the final Codex message is written. Leave empty to skip writing a file. | `""` |
| `working-directory` | Directory passed to `codex exec --cd`. Defaults to the repository root. | `""` |
| `sandbox` | Sandbox mode for Codex. One of `workspace-write` (default), `read-only` or `danger-full-access`. | `""` |
| `sandbox` | Legacy sandbox mode. Prefer `permission-profile: ":workspace"` for new workflows. Mutually exclusive with `permission-profile`. | `""` |
| `permission-profile` | Built-in or configured [Codex permission profile](https://developers.openai.com/codex/permissions) selected through `default_permissions`. | `""` |
| `codex-version` | Version of `@openai/codex` to install. | `""` |
| `codex-args` | Extra arguments forwarded to `codex exec`. Accepts JSON arrays (`["--flag", "value"]`) or shell-style strings. | `""` |
| `output-schema` | Inline schema contents written to a temp file and passed to `codex exec --output-schema`. Mutually exclusive with `output-schema-file`. | `""` |
Expand All @@ -117,6 +121,39 @@ jobs:
| `allow-bots` | Allow runs triggered by trusted GitHub bot accounts (`github-actions[bot]`) to bypass the write-access check. | `false` |
| `allow-bot-users` | List of GitHub bot usernames that can bypass the write-access check. `*` is not supported; list trusted bots explicitly. | `""` |

## Permission profiles

Codex permission profiles independently describe filesystem and network access. For workflows that
need to edit the checked-out repository, prefer `permission-profile: ":workspace"` over relying on
the action's legacy `workspace-write` fallback. Use `:read-only` for read-only workflows, or select a
named profile defined in the `config.toml` under `codex-home` when the workflow needs a more specific
policy. See the
[Codex permissions documentation](https://developers.openai.com/codex/permissions) for the profile
schema and enforcement details. Permission profiles are beta and require Codex CLI `0.138.0` or
later; do not select one while pinning an older `codex-version`.

The action does not pass `--sandbox` when `permission-profile` is set because the profile and legacy
sandbox systems do not compose. Supplying both inputs fails before Codex starts. The
`safety-strategy: read-only` option also forces the legacy read-only sandbox and therefore cannot be
combined with a permission profile. Keep `safety-strategy: drop-sudo` or use a deliberately
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.

For example, use the built-in `:workspace` profile for a workflow that needs to modify the checkout:

```yaml
- name: Run Codex with a permission profile
uses: openai/codex-action@v1
with:
openai-api-key: ${{ secrets.OPENAI_API_KEY }}
permission-profile: ":workspace"
prompt: Review the public change.
```

## Safety Strategy

The `safety-strategy` input determines how much access Codex receives on the runner. Choosing the right option is critical, especially when sensitive secrets (like your OpenAI API key) are present.
Expand Down Expand Up @@ -155,7 +192,7 @@ 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`.
- 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.)

## Azure
Expand Down
15 changes: 13 additions & 2 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,18 @@ inputs:
default: ""
sandbox:
description: |
Sandbox mode for Codex. One of `workspace-write` (default), `read-only` or `danger-full-access`.
Legacy sandbox mode for Codex. One of `workspace-write`, `read-only` or `danger-full-access`.
Prefer `permission-profile: ":workspace"` for new workflows. Leave empty to use the legacy
`workspace-write` fallback unless `permission-profile` is set.
required: false
default: "workspace-write"
default: ""
permission-profile:
description: |
Codex permission profile to select through `default_permissions`, such as `:read-only`,
`:workspace`, or a named profile defined in `codex-home/config.toml`. Mutually exclusive
with `sandbox`. See https://developers.openai.com/codex/permissions.
required: false
default: ""
codex-version:
description: "Version of `@openai/codex` to install."
required: false
Expand Down Expand Up @@ -330,6 +339,7 @@ runs:
CODEX_HOME: ${{ steps.resolve_home.outputs.codex-home }}
CODEX_WORKING_DIRECTORY: ${{ inputs['working-directory'] || github.workspace }}
CODEX_SANDBOX: ${{ inputs.sandbox }}
CODEX_PERMISSION_PROFILE: ${{ inputs['permission-profile'] }}
CODEX_ARGS: ${{ inputs['codex-args'] }}
CODEX_OUTPUT_SCHEMA: ${{ inputs['output-schema'] }}
CODEX_OUTPUT_SCHEMA_FILE: ${{ inputs['output-schema-file'] }}
Expand All @@ -351,6 +361,7 @@ runs:
--output-schema "$CODEX_OUTPUT_SCHEMA" \
--output-schema-file "$CODEX_OUTPUT_SCHEMA_FILE" \
--sandbox "$CODEX_SANDBOX" \
--permission-profile "$CODEX_PERMISSION_PROFILE" \
--model "$CODEX_MODEL" \
--effort "$CODEX_EFFORT" \
--safety-strategy "$CODEX_SAFETY_STRATEGY" \
Expand Down
93 changes: 77 additions & 16 deletions dist/main.js

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,25 @@ There is a lot of valuable context that can be used to fuel your invocation of C
- **Repository instruction files**: when Codex operates on pull request-controlled content, files such as `AGENTS.md`, `AGENTS.override.md`, or configured fallback project docs from that content should be considered part of the untrusted input surface.
- **Screenshots**: screenshots and other media have been known to be used as vehicles for prompt injection.

## Limit command permissions

Use `permission-profile` to select the narrowest filesystem and network policy that still lets Codex
complete the task. For workflows that edit the checkout, prefer the built-in `:workspace` profile
over the legacy `sandbox: workspace-write` setting. Use a custom profile when the workflow needs a
more specific policy. See the
[Codex permissions documentation](https://developers.openai.com/codex/permissions) for the available
built-in profiles and configuration schema.

Permission profiles constrain commands that Codex runs; they do not replace the action's
`safety-strategy`, which controls the privileges of the Codex process itself. Continue to use
`drop-sudo` or a deliberately configured `unprivileged-user` when a profile grants filesystem writes
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.

## Avoid shell injection in workflow steps

GitHub Actions expands `${{ ... }}` expressions before the shell runs your `run:` script. If you splice untrusted values such as branch names, issue titles, comment bodies, or action inputs directly into the script, those values can break shell quoting and execute arbitrary commands.
Expand Down
22 changes: 16 additions & 6 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,11 @@ export async function main() {
)
.requiredOption(
"--sandbox <SANDBOX>",
"Sandbox mode override to pass to `codex exec`."
"Legacy sandbox mode override to pass to `codex exec` (may be empty)."
)
.requiredOption(
"--permission-profile <PROFILE>",
"Permission profile to select through `default_permissions` (may be empty)."
)
.requiredOption("--model <model>", "Model the agent should use")
.requiredOption("--effort <effort>", "Reasoning effort the agent should use")
Expand All @@ -171,6 +175,7 @@ export async function main() {
outputSchemaFile: string;
outputSchema: string;
sandbox: string;
permissionProfile: string;
model: string;
effort: string;
safetyStrategy: string;
Expand All @@ -186,6 +191,7 @@ export async function main() {
outputSchema,
outputSchemaFile,
sandbox,
permissionProfile,
model,
effort,
safetyStrategy,
Expand Down Expand Up @@ -245,7 +251,8 @@ export async function main() {
extraArgs,
explicitOutputFile: emptyAsNull(outputFile),
outputSchema: outputSchemaSource,
sandbox: toSandboxMode(sandbox),
sandbox: toOptionalSandboxMode(sandbox),
permissionProfile: emptyAsNull(permissionProfile),
model: emptyAsNull(model),
effort: emptyAsNull(effort),
safetyStrategy: toSafetyStrategy(safetyStrategy),
Expand Down Expand Up @@ -341,15 +348,18 @@ function toSafetyStrategy(value: string): SafetyStrategy {
}
}

function toSandboxMode(value: string): SandboxMode {
switch (value) {
function toOptionalSandboxMode(value: string): SandboxMode | null {
const normalized = emptyAsNull(value);
switch (normalized) {
case null:
return null;
case "read-only":
case "workspace-write":
case "danger-full-access":
return value;
return normalized;
default:
throw new Error(
`Invalid sandbox: ${value}. Must be one of 'read-only', 'workspace-write', or 'danger-full-access'.`
`Invalid sandbox: ${normalized}. Must be one of 'read-only', 'workspace-write', or 'danger-full-access'.`
);
}
}
Expand Down
94 changes: 85 additions & 9 deletions src/runCodexExec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ export type SandboxMode =
| "workspace-write"
| "danger-full-access";

type PermissionSelection =
| { type: "sandbox"; mode: SandboxMode }
| { type: "profile"; name: string };

export type OutputSchemaSource =
| {
type: "file";
Expand All @@ -36,6 +40,15 @@ export type OutputSchemaSource =
content: string;
};

/**
* Builds and runs a `codex exec` command, writes the prompt to its standard input, and publishes
* the command's final message as the action output.
*
* Authentication is intentionally outside this function. The composite action starts or reuses
* the Responses API proxy and writes the corresponding Codex configuration before invoking this
* command. Keeping that setup separate also lets tests put a fake `codex` executable on `PATH` to
* verify command construction and output handling without an API key or network request.
*/
export async function runCodexExec({
prompt,
codexHome,
Expand All @@ -48,6 +61,7 @@ export async function runCodexExec({
safetyStrategy,
codexUser,
sandbox,
permissionProfile,
}: {
prompt: PromptSource;
codexHome: string | null;
Expand All @@ -59,7 +73,8 @@ export async function runCodexExec({
effort: string | null;
safetyStrategy: SafetyStrategy;
codexUser: string | null;
sandbox: SandboxMode;
sandbox: SandboxMode | null;
permissionProfile: string | null;
}): Promise<void> {
let input: string;
switch (prompt.type) {
Expand All @@ -85,9 +100,11 @@ export async function runCodexExec({
outputSchema,
runAsUser
);
const sandboxMode = await determineSandboxMode({
const permissionSelection = determinePermissionSelection({
safetyStrategy,
requestedSandbox: sandbox,
permissionProfile,
extraArgs,
});

const command: Array<string> = [];
Expand Down Expand Up @@ -143,7 +160,17 @@ export async function runCodexExec({

command.push(...extraArgs);

command.push("--sandbox", sandboxMode);
switch (permissionSelection.type) {
case "sandbox":
command.push("--sandbox", permissionSelection.mode);
break;
case "profile":
command.push(
"--config",
`default_permissions=${JSON.stringify(permissionSelection.name)}`
);
break;
}

const env = { ...process.env };
if (!env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE) {
Expand Down Expand Up @@ -323,16 +350,65 @@ async function createTempDir(
}
}

async function determineSandboxMode({
function determinePermissionSelection({
safetyStrategy,
requestedSandbox,
permissionProfile,
extraArgs,
}: {
safetyStrategy: SafetyStrategy;
requestedSandbox: SandboxMode;
}): Promise<SandboxMode> {
requestedSandbox: SandboxMode | null;
permissionProfile: string | null;
extraArgs: Array<string>;
}): PermissionSelection {
if (permissionProfile != null && requestedSandbox != null) {
throw new Error(
"`permission-profile` and `sandbox` are mutually exclusive. Permission profiles do not compose with legacy sandbox settings."
);
}
if (permissionProfile != null && safetyStrategy === "read-only") {
throw new Error(
"`permission-profile` cannot be combined with the `read-only` safety strategy because that strategy forces the legacy read-only sandbox."
);
}
if (permissionProfile != null && extraArgsSelectSandbox(extraArgs)) {
throw new Error(
"`permission-profile` cannot be combined with a sandbox override in `codex-args`."
);
}
if (safetyStrategy === "read-only") {
return "read-only";
} else {
return requestedSandbox;
return { type: "sandbox", mode: "read-only" };
}
if (permissionProfile != null) {
return { type: "profile", name: permissionProfile };
}
return { type: "sandbox", mode: requestedSandbox ?? "workspace-write" };
}

function extraArgsSelectSandbox(args: Array<string>): boolean {
return args.some((arg, index) => {
if (
arg === "--sandbox" ||
arg.startsWith("--sandbox=") ||
arg === "-s" ||
arg.startsWith("-s=")
) {
return true;
}
if (arg === "--config" || arg === "-c") {
return configOverrideSelectsSandbox(args[index + 1]);
}
if (arg.startsWith("--config=")) {
return configOverrideSelectsSandbox(arg.slice("--config=".length));
}
if (arg.startsWith("-c=")) {
return configOverrideSelectsSandbox(arg.slice("-c=".length));
}
return false;
});
}

function configOverrideSelectsSandbox(override: string | undefined): boolean {
const key = override?.trimStart().split(/[=.]/, 1)[0];
return key === "sandbox_mode" || key === "sandbox_workspace_write";
}
Loading
Loading