feat(runner): isolate Codex security configuration (#12364)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Codex executes commands inside the runner workspace
> - The provider process must not inherit host credentials or broad file
access
> - Planning mode also needs a read-only workspace boundary
> - This pull request defines the isolated launch and thread
configuration
> - A later pull request will use it from the Codex driver
> - The benefit is an independently reviewed security boundary

## Linked Issues or Issue Description

**Subsystem affected**

`packages/paperclip-runner` Codex launch security.

**Problem or motivation**

A provider process can inherit host paths, environment secrets, network
access, or write permissions unless the runner constructs a closed
configuration.

**Proposed solution**

Build explicit app-server arguments and thread parameters. Deny host
roots and network access, allow only a small environment list, and use a
read-only profile for plan mode.

**Alternatives considered**

Relying on ambient Codex defaults would make runner safety depend on
each host installation.

**Roadmap alignment**

This supports the Codex-first experimental runner. It does not enable
the runner adapter.

## What Changed

- Added deny-by-default filesystem rules.
- Added separate execution and planning permission profiles.
- Added network denial.
- Added an explicit environment allowlist.
- Disabled host apps, plugins, memories, multi-agent behavior, and image
generation.
- Added security configuration tests.

## Verification

- `pnpm --filter @paperclipai/paperclip-runner test:typescript`
- `pnpm -r typecheck`
- `pnpm build`
- The focused security configuration test has 3 passing cases.

## Risks

The risk is an overly broad provider launch. Tests verify denied host
roots, read-only context roots, workspace permissions, network denial,
and secret exclusion.

## Model Used

OpenAI Codex with GPT-5.6 and repository tool use.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Dotta 2026-08-30 00:35:56 -05:00 committed by GitHub
parent 3942739906
commit 9d7d9ea724
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 195 additions and 0 deletions

View File

@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import {
createIsolatedCodexAppServerArgs,
createSecuredCodexThreadParams,
createSkilllessCodexThreadConfig,
} from "./codex-security-config.js";
describe("Codex security configuration", () => {
it("disables host extensions and makes collaboration instructions explicit", () => {
expect(createSkilllessCodexThreadConfig("/workspace", {}, false)).toEqual({
"skills.include_instructions": false,
include_apps_instructions: false,
include_collaboration_mode_instructions: false,
"features.apps": false,
"features.plugins": false,
"features.multi_agent": false,
"features.memories": false,
"features.image_generation": false,
});
});
it("denies host roots, network access, and unlisted environment variables", () => {
const args = createIsolatedCodexAppServerArgs({
HOME: "/host/home",
CODEX_HOME: "/host/codex",
PATH: "/safe/bin",
LANG: "C.UTF-8",
OPENAI_API_KEY: "must-not-cross",
}, ["/runner/context"]);
const serialized = args.join("\n");
expect(serialized).toContain('"/host/home"="none"');
expect(serialized).toContain('"/host/codex"="none"');
expect(serialized).toContain('"/runner/context"="read"');
expect(serialized).toContain('":workspace_roots"={"."="write"}');
expect(serialized).toContain('":workspace_roots"={"."="read"}');
expect(serialized).toContain("network.enabled=false");
expect(serialized).toContain('PATH="/safe/bin"');
expect(serialized).toContain('LANG="C.UTF-8"');
expect(serialized).not.toContain("OPENAI_API_KEY");
expect(serialized).not.toContain("must-not-cross");
});
it("uses a read-only permission profile for plan mode", () => {
expect(createSecuredCodexThreadParams("/workspace", "plan")).toMatchObject({
cwd: "/workspace",
permissions: "paperclip-runner-workspace-read-only",
runtimeWorkspaceRoots: ["/workspace"],
config: {
"skills.include_instructions": false,
include_collaboration_mode_instructions: true,
},
});
});
});

View File

@ -0,0 +1,139 @@
import { resolve } from "node:path";
const SKILLLESS_PERMISSION_PROFILE = "paperclip-runner-workspace-only";
const PLANNING_PERMISSION_PROFILE = "paperclip-runner-workspace-read-only";
const SKILLLESS_BASE_CONFIG = {
"skills.include_instructions": false,
include_apps_instructions: false,
include_collaboration_mode_instructions: true,
"features.apps": false,
"features.plugins": false,
"features.multi_agent": false,
"features.memories": false,
"features.image_generation": false,
} as const;
function commandEnvironment(
source: NodeJS.ProcessEnv = process.env,
): Record<string, string> {
const environment: Record<string, string> = {};
for (const key of [
"PATH",
"PATHEXT",
"SystemRoot",
"WINDIR",
"LANG",
"LC_ALL",
] as const) {
const value = source[key];
if (value !== undefined) environment[key] = value;
}
return environment;
}
export function createSkilllessCodexThreadConfig(
_workingDirectory: string,
_source: NodeJS.ProcessEnv = process.env,
includeCollaborationModeInstructions = true,
): Record<string, unknown> {
return {
...SKILLLESS_BASE_CONFIG,
include_collaboration_mode_instructions:
includeCollaborationModeInstructions,
};
}
function collaborationThreadConfig(
includeCollaborationModeInstructions = true,
includeSkillInstructions = false,
) {
return {
...SKILLLESS_BASE_CONFIG,
"skills.include_instructions": includeSkillInstructions,
include_collaboration_mode_instructions:
includeCollaborationModeInstructions,
};
}
function tomlString(value: string): string {
return JSON.stringify(value);
}
export function createIsolatedCodexAppServerArgs(
source: NodeJS.ProcessEnv = process.env,
readOnlyRoots: string[] = [],
): string[] {
const deniedHostRoots = [
...new Set(
[source.HOME, source.CODEX_HOME]
.filter(
(value): value is string =>
typeof value === "string" && value.trim().length > 0,
)
.map((value) => resolve(value)),
),
];
const filesystemRules = [
`":root"="none"`,
`":minimal"="read"`,
`":tmpdir"="none"`,
...deniedHostRoots.map((path) => `${tomlString(path)}="none"`),
...readOnlyRoots.map((path) => `${tomlString(resolve(path))}="read"`),
`":workspace_roots"={"."="write"}`,
].join(",");
const planningFilesystemRules = [
`":root"="none"`,
`":minimal"="read"`,
`":tmpdir"="none"`,
...deniedHostRoots.map((path) => `${tomlString(path)}="none"`),
...readOnlyRoots.map((path) => `${tomlString(resolve(path))}="read"`),
`":workspace_roots"={"."="read"}`,
].join(",");
const commandEnv = Object.entries(commandEnvironment(source))
.map(([key, value]) => `${key}=${tomlString(value)}`)
.join(",");
return [
"-c",
`default_permissions=${tomlString(SKILLLESS_PERMISSION_PROFILE)}`,
"-c",
`permissions.${SKILLLESS_PERMISSION_PROFILE}.filesystem={${filesystemRules}}`,
"-c",
`permissions.${SKILLLESS_PERMISSION_PROFILE}.network.enabled=false`,
"-c",
`permissions.${PLANNING_PERMISSION_PROFILE}.filesystem={${planningFilesystemRules}}`,
"-c",
`permissions.${PLANNING_PERMISSION_PROFILE}.network.enabled=false`,
"-c",
`shell_environment_policy.inherit="none"`,
"-c",
"shell_environment_policy.ignore_default_excludes=false",
...(commandEnv.length > 0
? ["-c", `shell_environment_policy.set={${commandEnv}}`]
: []),
"--disable",
"image_generation",
"app-server",
];
}
export function createSecuredCodexThreadParams(
workingDirectory: string,
mode: "default" | "plan" = "default",
includeCollaborationModeInstructions = true,
includeSkillInstructions = false,
): Record<string, unknown> {
const permissionProfile =
mode === "plan"
? PLANNING_PERMISSION_PROFILE
: SKILLLESS_PERMISSION_PROFILE;
return {
cwd: workingDirectory,
config: collaborationThreadConfig(
includeCollaborationModeInstructions,
includeSkillInstructions,
),
permissions: permissionProfile,
runtimeWorkspaceRoots: [workingDirectory],
};
}