fix: verify safety-strategy: unprivileged-user works end-to-end

This commit is contained in:
Michael Bolin
2025-10-06 10:16:58 -07:00
parent 07db793949
commit bcb3128d4d
6 changed files with 303 additions and 103 deletions
+5 -3
View File
@@ -135,7 +135,8 @@ runs:
node "${{ github.action_path }}/dist/main.js" resolve-codex-home \
--codex-home-override "${{ inputs['codex-home'] }}" \
--safety-strategy "${{ inputs['safety-strategy'] }}" \
--codex-user "${{ inputs['codex-user'] }}"
--codex-user "${{ inputs['codex-user'] }}" \
--github-run-id "${{ github.run_id }}"
- name: Determine server info path
id: derive_server_info
@@ -188,7 +189,7 @@ runs:
fi
if [ "${RUNNER_OS}" != "Windows" ]; then
chmod 444 "$server_info_file"
sudo chmod 444 "$server_info_file"
sudo chown root "$server_info_file"
fi
@@ -205,7 +206,8 @@ runs:
run: |
node "${{ github.action_path }}/dist/main.js" write-proxy-config \
--codex-home "${{ steps.resolve_home.outputs.codex-home }}" \
--port "${{ steps.read_server_info.outputs.port }}"
--port "${{ steps.read_server_info.outputs.port }}" \
--safety-strategy "${{ inputs['safety-strategy'] }}"
- name: Drop sudo privilege, if appropriate
if: ${{ inputs['safety-strategy'] == 'drop-sudo' && inputs['openai-api-key'] != '' }}
+127 -69
View File
File diff suppressed because one or more lines are too long
+40
View File
@@ -0,0 +1,40 @@
name: "Example: Run Codex as an unprivileged user"
on:
workflow_dispatch:
jobs:
run-codex-with-unprivileged-user:
runs-on: ubuntu-latest
steps:
- name: Create unprivileged user
run: |
sudo adduser --system --home /home/guest --shell /bin/bash --group guest
# Ensure the default runner account can collaborate with the guest user.
sudo usermod -a -G guest runner
# Allow the guest user to traverse runner-owned directories.
# In particular, actions/checkout@v5 checks out code under
# /home/runner/work, but /home/runner has permissions 750.
sudo usermod -a -G runner guest
- uses: actions/checkout@v5
- name: Change ownership
run: |
# Change the group ownership of the checked-out files to the `guest` group.
sudo chown -R runner:guest "$GITHUB_WORKSPACE"
# Extend user permissions to the group.
sudo chmod -R g+rwX "$GITHUB_WORKSPACE"
# Set the setgid bit so new directories stay in the shared group.
sudo find "$GITHUB_WORKSPACE" -type d -exec chmod g+s {} +
- name: Run Codex as unprivileged user
uses: openai/codex-action@main
with:
openai-api-key: ${{ secrets.OPENAI_API_KEY }}
safety-strategy: unprivileged-user
codex-user: guest
prompt: |
Report $USER, $HOME, and whoami.
+55 -8
View File
@@ -51,18 +51,21 @@ export async function main() {
"--codex-user <user>",
"Codex user to consider when safety strategy is 'unprivileged-user'"
)
.requiredOption("--github-run-id <id>", "GitHub run ID")
.action(
async (options: {
codexHomeOverride: string;
safetyStrategy: string;
codexUser: string;
githubRunId: string;
}) => {
const safetyStrategy = toSafetyStrategy(options.safetyStrategy);
const codexUser = emptyAsNull(options.codexUser);
const resolved = await resolveCodexHome(
emptyAsNull(options.codexHomeOverride),
safetyStrategy,
codexUser
codexUser,
options.githubRunId
);
// Ensure directory exists for downstream steps that will write files here.
await fs.mkdir(resolved, { recursive: true });
@@ -82,9 +85,20 @@ export async function main() {
)
.requiredOption("--codex-home <DIRECTORY>", "Path to Codex home directory")
.requiredOption("--port <port>", "Proxy server port", parseIntStrict)
.action(async (options: { codexHome: string; port: number }) => {
await writeProxyConfig(options.codexHome, options.port);
});
.requiredOption(
"--safety-strategy <strategy>",
"Safety strategy to use. One of 'drop-sudo', 'read-only', 'unprivileged-user', or 'unsafe'."
)
.action(
async (options: {
codexHome: string;
port: number;
safetyStrategy: string;
}) => {
const safetyStrategy = toSafetyStrategy(options.safetyStrategy);
await writeProxyConfig(options.codexHome, options.port, safetyStrategy);
}
);
program
.command("drop-sudo")
@@ -352,7 +366,8 @@ main();
async function resolveCodexHome(
inputCodexHome: string | null,
safetyStrategy: SafetyStrategy,
codexUser: string | null
codexUser: string | null,
githubRunId: string
): Promise<string> {
if (inputCodexHome != null) {
return expandTilde(inputCodexHome);
@@ -368,7 +383,10 @@ async function resolveCodexHome(
);
}
return await deriveSharedCodexHomeForUser(codexUser);
return await deriveSharedCodexHomeForUnprivilegedUser(
codexUser,
githubRunId
);
}
return path.join(os.homedir(), ".codex");
}
@@ -384,14 +402,43 @@ async function ensureDirIsWorldReadable(dir: string): Promise<void> {
}
}
async function deriveSharedCodexHomeForUser(user: string): Promise<string> {
async function deriveSharedCodexHomeForUnprivilegedUser(
user: string,
githubRunId: string
): Promise<string> {
const home = (
await checkOutput(["sudo", "-u", user, "--", "printenv", "HOME"])
).trim();
if (!home) {
throw new Error(`Could not determine home directory for user '${user}'.`);
}
return path.join(home, ".codex");
const codexHome = path.join(home, ".codex");
try {
const stat = await fs.stat(codexHome);
if (stat.isDirectory()) {
// Directory already exists and may contain a config.toml created by the
// user (or a previous invocation of codex-action), so assume it's
// correctly permissioned.
return codexHome;
}
} catch {
// Ignore stat errors and try to create the directory.
}
// We must use sudo for the following file system operations because we
// are writing to the home directory of a different user.
await checkOutput(["sudo", "mkdir", codexHome]);
await checkOutput(["sudo", "chown", `${user}`, codexHome]);
await checkOutput(["sudo", "chmod", "755", codexHome]);
// codex-responses-api-proxy will need to write the server info file.
const serverInfoFile = path.join(codexHome, `${githubRunId}.json`);
await checkOutput(["sudo", "touch", serverInfoFile]);
// Make the file world-writable for the moment, but this will be locked down
// to read-only by root before the action completes.
await checkOutput(["sudo", "chmod", "666", serverInfoFile]);
return codexHome;
}
function expandTilde(p: string): string {
+56 -20
View File
@@ -69,18 +69,19 @@ export async function runCodexExec({
break;
}
const needsSharedTempDir = safetyStrategy === "unprivileged-user";
const runAsUser: string | null =
safetyStrategy === "unprivileged-user" ? codexUser : null;
let outputFile: OutputFile;
if (explicitOutputFile != null) {
outputFile = { type: "explicit", file: explicitOutputFile };
} else {
outputFile = await createTempOutputFile({ shared: needsSharedTempDir });
outputFile = await createTempOutputFile({ runAsUser });
}
const resolvedOutputSchema = await resolveOutputSchema(
outputSchema,
needsSharedTempDir
runAsUser
);
const sandboxMode = await determineSandboxMode({
safetyStrategy,
@@ -168,7 +169,7 @@ export async function runCodexExec({
}
try {
await finalizeExecution(outputFile);
await finalizeExecution(outputFile, runAsUser);
resolve(undefined);
} catch (err) {
reject(err);
@@ -180,12 +181,27 @@ export async function runCodexExec({
}
}
async function finalizeExecution(outputFile: OutputFile): Promise<void> {
async function finalizeExecution(
outputFile: OutputFile,
runAsUser: string | null
): Promise<void> {
try {
const lastMessage = await readFile(outputFile.file, "utf8");
let lastMessage: string;
if (runAsUser == null) {
lastMessage = await readFile(outputFile.file, "utf8");
setOutput("final-message", lastMessage);
} else {
lastMessage = await checkOutput([
"sudo",
"-u",
runAsUser,
"cat",
outputFile.file,
]);
}
setOutput("final-message", lastMessage);
} finally {
await cleanupTempOutput(outputFile);
await cleanupTempOutput(outputFile, runAsUser);
}
}
@@ -211,23 +227,30 @@ type ResolvedOutputSchema =
};
async function createTempOutputFile({
shared,
runAsUser,
}: {
shared: boolean;
runAsUser: string | null;
}): Promise<OutputFile> {
const dir = await createTempDir("codex-exec-", shared);
const dir = await createTempDir("codex-exec-", runAsUser);
return { type: "temp", file: path.join(dir, "output.md") };
}
async function cleanupTempOutput(outputFile: OutputFile): Promise<void> {
async function cleanupTempOutput(
outputFile: OutputFile,
runAsUser: string | null
): Promise<void> {
switch (outputFile.type) {
case "explicit":
// Do not delete user-specified output files.
return;
case "temp": {
const { file } = outputFile;
const dir = path.dirname(file);
await rm(dir, { recursive: true, force: true });
if (runAsUser == null) {
const dir = path.dirname(file);
await rm(dir, { recursive: true, force: true });
} else {
await checkOutput(["sudo", "rm", "-rf", path.dirname(file)]);
}
break;
}
}
@@ -235,7 +258,7 @@ async function cleanupTempOutput(outputFile: OutputFile): Promise<void> {
async function resolveOutputSchema(
schema: OutputSchemaSource | null,
sharedTempDir: boolean
runAsUser: string | null
): Promise<ResolvedOutputSchema | null> {
if (schema == null) {
return null;
@@ -245,7 +268,7 @@ async function resolveOutputSchema(
case "file":
return { type: "explicit", file: schema.path };
case "inline": {
const dir = await createTempDir("codex-output-schema-", sharedTempDir);
const dir = await createTempDir("codex-output-schema-", runAsUser);
const file = path.join(dir, "schema.json");
await writeFile(file, schema.content);
return { type: "temp", file, dir };
@@ -269,12 +292,25 @@ async function cleanupOutputSchema(
}
}
async function createTempDir(prefix: string, shared: boolean): Promise<string> {
const dir = await mkdtemp(path.join(os.tmpdir(), prefix));
if (shared) {
await chmod(dir, 0o755);
async function createTempDir(
prefix: string,
runAsUser: string | null
): Promise<string> {
if (runAsUser == null) {
return await mkdtemp(path.join(os.tmpdir(), prefix));
} else {
return (
await checkOutput([
"sudo",
"-u",
runAsUser,
"mktemp",
"-d",
"-t",
`${prefix}.XXXXXX`,
])
).trim();
}
return dir;
}
async function determineSandboxMode({
+20 -3
View File
@@ -1,11 +1,15 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import * as os from "node:os";
import { SafetyStrategy } from "./runCodexExec";
import { checkOutput } from "./checkOutput";
const MODEL_PROVIDER = "openai-proxy";
export async function writeProxyConfig(
codexHome: string,
port: number
port: number,
safetyStrategy: SafetyStrategy
): Promise<void> {
const configPath = path.join(codexHome, "config.toml");
@@ -33,6 +37,19 @@ wire_api = "responses"
// Prepend model_provider at the very top.
let output = `${header}${existing}${table}`;
await fs.mkdir(codexHome, { recursive: true });
await fs.writeFile(configPath, output, "utf8");
if (safetyStrategy === "unprivileged-user") {
// We know we have already created the CODEX_HOME directory, but it is owned
// by another user, so we need to use sudo to write the file.
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "codex-config"));
try {
const tempConfigPath = path.join(tempDir, "config.toml");
await fs.writeFile(tempConfigPath, output, "utf8");
await checkOutput(["sudo", "mv", tempConfigPath, configPath]);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
} else {
await fs.mkdir(codexHome, { recursive: true });
await fs.writeFile(configPath, output, "utf8");
}
}