## Why
Codex permission profiles provide the new unified filesystem and network
policy model. However,
`codex-action` currently appends `--sandbox` to every `codex exec`
invocation, which explicitly opts
the session into the legacy sandbox model and prevents
`default_permissions` from taking effect.
This makes it impossible for action users to select profiles such as a
read-only workspace with a
narrow network allowlist.
## What changed
- Add a `permission-profile` action input that selects a built-in or
configured profile through
`default_permissions`.
- Omit `--sandbox` when a permission profile is selected.
- Keep existing behavior compatible: when neither input is supplied, the
action still invokes Codex
with the legacy `workspace-write` sandbox; explicit `sandbox` callers
continue to use that model.
- Reject combinations that would silently disable profiles, including an
explicit `sandbox`, the
`read-only` safety strategy, or a sandbox override in `codex-args`.
- Document profile configuration, the Codex CLI `0.138.0` minimum, and
the distinction between
command permissions and process-level `safety-strategy` protections.
- Rebuild the checked-in action bundle.
Example action configuration after defining `public-review` in the
selected Codex home:
```yaml
- uses: openai/codex-action@v1
with:
openai-api-key: ${{ secrets.OPENAI_API_KEY }}
permission-profile: public-review
prompt: Review the public change.
```
See the [Codex permission profile
documentation](https://developers.openai.com/codex/permissions) for the
profile schema.
## Testing
- Add end-to-end command-construction tests with a fake `codex`
executable. These cover the legacy
default, profile selection without `--sandbox`, and incompatible input
combinations.
- `pnpm test`
- `pnpm run check`
## Documentation
After this input is released, the Codex GitHub Action page on
`developers.openai.com` should list
`permission-profile` and explain its relationship with the legacy
`sandbox` input.
181 lines
5.2 KiB
JavaScript
181 lines
5.2 KiB
JavaScript
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`/);
|
|
});
|
|
}
|