Add Codex permission profile support

This commit is contained in:
Michael Bolin
2026-07-02 22:56:16 -07:00
parent cbc89a95f1
commit ecc38bf2ac
7 changed files with 434 additions and 40 deletions
+42 -5
View File
@@ -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.
@@ -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 }}.
@@ -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`. | `""` |
@@ -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.
@@ -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
+13 -2
View File
@@ -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
@@ -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'] }}
@@ -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" \
+78 -17
View File
File diff suppressed because one or more lines are too long
+19
View File
@@ -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.
+16 -6
View File
@@ -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")
@@ -171,6 +175,7 @@ export async function main() {
outputSchemaFile: string;
outputSchema: string;
sandbox: string;
permissionProfile: string;
model: string;
effort: string;
safetyStrategy: string;
@@ -186,6 +191,7 @@ export async function main() {
outputSchema,
outputSchemaFile,
sandbox,
permissionProfile,
model,
effort,
safetyStrategy,
@@ -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),
@@ -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'.`
);
}
}
+86 -10
View File
@@ -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";
@@ -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,
@@ -48,6 +61,7 @@ export async function runCodexExec({
safetyStrategy,
codexUser,
sandbox,
permissionProfile,
}: {
prompt: PromptSource;
codexHome: string | null;
@@ -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) {
@@ -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> = [];
@@ -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) {
@@ -323,16 +350,65 @@ async function createTempDir(
}
}
async function determineSandboxMode({
function determinePermissionSelection({
safetyStrategy,
requestedSandbox,
permissionProfile,
extraArgs,
}: {
safetyStrategy: SafetyStrategy;
requestedSandbox: SandboxMode;
}): Promise<SandboxMode> {
if (safetyStrategy === "read-only") {
return "read-only";
} else {
return requestedSandbox;
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 { 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";
}
+180
View File
@@ -0,0 +1,180 @@
import assert from "node:assert/strict";
import {
chmodSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
const mainPath = fileURLToPath(new URL("../dist/main.js", import.meta.url));
/**
* Runs the bundled `run-codex-exec` command with a generated fake `codex` executable first on
* `PATH`. The fake captures its arguments and writes a final-message file without making an API
* request, so successful cases exercise command construction, process spawning, and output
* handling without an API key. Validation-error cases assert that the fake was never spawned.
*/
function runCodexExecWithFakeCodex({
sandbox = "",
permissionProfile = "",
extraArgs = "",
safetyStrategy = "drop-sudo",
} = {}) {
const tempDir = mkdtempSync(path.join(tmpdir(), "codex-action-permissions-"));
const capturePath = path.join(tempDir, "args.json");
const outputPath = path.join(tempDir, "output.txt");
const fakeCodexPath = path.join(tempDir, "codex.mjs");
writeFileSync(
fakeCodexPath,
`import { writeFileSync } from "node:fs";
const args = process.argv.slice(2);
writeFileSync(process.env.CODEX_CAPTURE_ARGS, JSON.stringify(args));
const outputIndex = args.indexOf("--output-last-message");
if (outputIndex < 0 || outputIndex + 1 >= args.length) {
throw new Error("missing --output-last-message");
}
writeFileSync(args[outputIndex + 1], "fake final message\\n");
`,
"utf8"
);
const posixLauncher = path.join(tempDir, "codex");
writeFileSync(
posixLauncher,
`#!/bin/sh\nexec node "${fakeCodexPath}" "$@"\n`,
"utf8"
);
chmodSync(posixLauncher, 0o755);
writeFileSync(
path.join(tempDir, "codex.cmd"),
`@node "${fakeCodexPath}" %*\r\n`,
"utf8"
);
const result = spawnSync(
process.execPath,
[
mainPath,
"run-codex-exec",
"--prompt",
"test prompt",
"--prompt-file",
"",
"--codex-home",
"",
"--cd",
tempDir,
"--extra-args",
extraArgs,
"--output-file",
outputPath,
"--output-schema-file",
"",
"--output-schema",
"",
"--sandbox",
sandbox,
"--permission-profile",
permissionProfile,
"--model",
"",
"--effort",
"",
"--safety-strategy",
safetyStrategy,
"--codex-user",
"",
],
{
encoding: "utf8",
env: {
...process.env,
PATH: `${tempDir}${path.delimiter}${process.env.PATH ?? ""}`,
CODEX_CAPTURE_ARGS: capturePath,
},
}
);
let capturedArgs = null;
try {
capturedArgs = JSON.parse(readFileSync(capturePath, "utf8"));
} catch {
// Expected when argument validation rejects the invocation before spawning Codex.
}
rmSync(tempDir, { recursive: true, force: true });
return { result, capturedArgs };
}
test("preserves workspace-write as the default legacy sandbox", () => {
const { result, capturedArgs } = runCodexExecWithFakeCodex();
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(capturedArgs.slice(-2), ["--sandbox", "workspace-write"]);
});
test("selects a permission profile without passing --sandbox", () => {
const { result, capturedArgs } = runCodexExecWithFakeCodex({
permissionProfile: "public-review",
});
assert.equal(result.status, 0, result.stderr);
assert.equal(capturedArgs.includes("--sandbox"), false);
assert.deepEqual(capturedArgs.slice(-2), [
"--config",
'default_permissions="public-review"',
]);
});
test("rejects permission-profile with sandbox", () => {
const { result, capturedArgs } = runCodexExecWithFakeCodex({
permissionProfile: "public-review",
sandbox: "read-only",
});
assert.notEqual(result.status, 0);
assert.equal(capturedArgs, null);
assert.match(result.stderr, /mutually exclusive/);
});
test("rejects permission-profile with the read-only safety strategy", () => {
const { result, capturedArgs } = runCodexExecWithFakeCodex({
permissionProfile: "public-review",
safetyStrategy: "read-only",
});
assert.notEqual(result.status, 0);
assert.equal(capturedArgs, null);
assert.match(result.stderr, /forces the legacy read-only sandbox/);
});
test("rejects permission-profile with a sandbox in codex-args", () => {
const { result, capturedArgs } = runCodexExecWithFakeCodex({
permissionProfile: "public-review",
extraArgs: '["--sandbox", "read-only"]',
});
assert.notEqual(result.status, 0);
assert.equal(capturedArgs, null);
assert.match(result.stderr, /sandbox override in `codex-args`/);
});
for (const extraArgs of [
'["--config", "sandbox_workspace_write.network_access=true"]',
'["--config=sandbox_workspace_write.network_access=true"]',
]) {
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`/);
});
}