fix(runner): harden dormant provider boundaries (#12654)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Paperclip Runner currently enables only the Codex production path.
> - The package also contains dormant OpenCode and ACPX provider
boundaries.
> - Dormant boundaries must still fail safe before later activation
work.
> - Provider children must not inherit unrelated server secrets or host
homes.
> - Permission defaults must require interaction instead of broad
automatic approval.
> - This pull request hardens those boundaries without activating them.
> - The benefit is a safer base for later provider-specific runnerd
work.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

This improves the inactive OpenCode and ACPX provider boundary in
Paperclip Runner.

**Subsystem affected**

The adapter permission contract, Runner provider environment, and native
execution input builder.

**Current behavior**

Dormant OpenCode code can inherit the full server environment. Its
default permission mode allows operations. ACPX also defaults to broad
approval. The provider guard can accept inherited object property names.

**Proposed behavior**

Use exact provider identifiers. Use interactive defaults. Allow only
required OpenCode environment keys. Reject invalid proxy permission
modes.

**Reason and benefit**

This reduces accidental authority and secret exposure before future
provider activation.

**Breaking changes**

No production provider is activated. Codex runtime selection and Codex
credential-home discovery do not change. Dormant OpenCode and ACPX
callers that omit permission modes now receive safer defaults.

## What Changed

- Change dormant OpenCode and ACPX permission defaults to interactive
modes.
- Reject prototype property names as provider identifiers.
- Default dormant ACPX input to the qualified Codex agent profile.
- Add an explicit OpenCode runner environment allowlist.
- Exclude host homes, server credentials, database values, and Node
injection options.
- Add a fail-closed OpenCode proxy permission parser.
- Add focused tests for defaults, filtering, and invalid values.

## Verification

GitHub Actions must run:

- Adapter utility tests.
- Paperclip Runner tests, type checks, and build.
- Server native runtime tests.
- Repository test, type-check, build, policy, and security gates.

No local test command was run. The repository owner requested
GitHub-only verification.

## Risks

Future OpenCode credential providers must add required variables to the
allowlist through review. The safer defaults can pause dormant internal
scenarios that relied on implicit broad approval. Production Codex
behavior is unchanged.

## Model Used

OpenAI Codex with the GPT-5 agent model. The work used high reasoning,
repository inspection, tool use, and parallel security review.

## 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
- [ ] 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-09-01 04:46:36 -05:00 committed by GitHub
parent bfb98aff5d
commit 1ed29abaa6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 249 additions and 12 deletions

View File

@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import {
isPaperclipRunnerProvider,
resolvePaperclipRunnerPermissionMode,
} from "./paperclip-runner-permissions.js";
describe("Paperclip Runner permission defaults", () => {
it("uses interactive defaults for dormant non-Codex providers", () => {
expect(resolvePaperclipRunnerPermissionMode("opencode", undefined)).toBe(
"ask",
);
expect(resolvePaperclipRunnerPermissionMode("acpx", undefined)).toBe(
"approve-reads",
);
});
it("recognizes only exact provider identifiers", () => {
expect(isPaperclipRunnerProvider("codex")).toBe(true);
expect(isPaperclipRunnerProvider("opencode")).toBe(true);
expect(isPaperclipRunnerProvider("acpx")).toBe(true);
expect(isPaperclipRunnerProvider("toString")).toBe(false);
expect(isPaperclipRunnerProvider("__proto__")).toBe(false);
});
});

View File

@ -49,7 +49,7 @@ export const PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES = {
opencode: {
configurable: true,
configKey: "opencodePermissionMode",
defaultMode: "allow",
defaultMode: "ask",
description: "Controls OpenCode tool permissions inside the assigned Paperclip environment.",
options: [
{ value: "allow", label: "Full auto (allow)", description: "Allow OpenCode operations without approval pauses." },
@ -60,7 +60,7 @@ export const PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES = {
acpx: {
configurable: true,
configKey: "acpxPermissionMode",
defaultMode: "approve-all",
defaultMode: "approve-reads",
description: "Controls ACPX agent operations inside the assigned Paperclip environment.",
options: [
{ value: "approve-all", label: "Full auto (approve all)", description: "Approve ACPX operations without approval pauses." },
@ -71,7 +71,7 @@ export const PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES = {
} as const satisfies Record<PaperclipRunnerProvider, PaperclipRunnerPermissionCapability>;
export function isPaperclipRunnerProvider(value: unknown): value is PaperclipRunnerProvider {
return typeof value === "string" && value in PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES;
return value === "codex" || value === "opencode" || value === "acpx";
}
export function resolvePaperclipRunnerPermissionMode(

View File

@ -17,6 +17,7 @@ import {
assertOpenCodeProxyCollaborationMode,
openCodeProxyCollaborationModes,
} from "./opencode-proxy-collaboration-mode.js";
import { parseOpenCodeProxyPermissionMode } from "./opencode-proxy-permission-mode.js";
type RpcMessage = { id?: string | number; method?: string; params?: unknown; result?: unknown; error?: unknown };
@ -78,6 +79,9 @@ async function open(params: Record<string, unknown>, resume: boolean): Promise<R
: null;
driver = new OpenCodeServerDriver({
model,
permissionMode: parseOpenCodeProxyPermissionMode(
process.env.PAPERCLIP_OPENCODE_PERMISSION_MODE,
),
command: openCodeCommand(),
runtimeDirectory: runtimeDirectory(),
environment: process.env,

View File

@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { parseOpenCodeProxyPermissionMode } from "./opencode-proxy-permission-mode.js";
describe("OpenCode runnerd proxy permission mode", () => {
it.each(["allow", "ask", "deny"] as const)(
"admits the exact %s mode",
(mode) => {
expect(parseOpenCodeProxyPermissionMode(mode)).toBe(mode);
},
);
it("defaults an unset or empty mode to ask", () => {
expect(parseOpenCodeProxyPermissionMode(undefined)).toBe("ask");
expect(parseOpenCodeProxyPermissionMode(" ")).toBe("ask");
});
it("rejects an unknown mode", () => {
expect(() => parseOpenCodeProxyPermissionMode("approve-all")).toThrow(
"PAPERCLIP_OPENCODE_PERMISSION_MODE is invalid",
);
});
});

View File

@ -0,0 +1,16 @@
export type OpenCodeProxyPermissionMode = "allow" | "ask" | "deny";
export function parseOpenCodeProxyPermissionMode(
value: string | undefined,
): OpenCodeProxyPermissionMode {
const configured = value?.trim();
if (!configured) return "ask";
if (
configured === "allow"
|| configured === "ask"
|| configured === "deny"
) {
return configured;
}
throw new Error("PAPERCLIP_OPENCODE_PERMISSION_MODE is invalid");
}

View File

@ -13,7 +13,11 @@ import { describe, expect, it } from "vitest";
import { validatePrpEvent } from "../protocol/replay-contract.js";
import { digestPaperclipSemanticContent } from "../semantic-tools/receipts.js";
import { DurablePrpControlPlane } from "./durable-prp-control-plane.js";
import {
DurablePrpControlPlane,
spawnRunner,
type RunnerProcessLaunchSpec,
} from "./durable-prp-control-plane.js";
import type { DurableRecoveryIdentity } from "./prp-transport-types.js";
const identity: DurableRecoveryIdentity = {
@ -27,6 +31,59 @@ const identity: DurableRecoveryIdentity = {
const expectedRunnerVersion = "0.3.0";
const expectedRunnerDigest = `sha256:${"a".repeat(64)}`;
it("preserves an explicit OpenCode permission mode at the runner spawn boundary", () => {
const launches: RunnerProcessLaunchSpec[] = [];
spawnRunner({
connection: { mode: "connect", connectUrl: "ws://127.0.0.1:43127" },
stateDirectory: "/tmp/paperclip-runner-test",
identity,
ticket: "bootstrap-ticket",
maxOutboxBytes: 256 * 1024,
p0ReserveBytes: 64 * 1024,
runnerVersion: expectedRunnerVersion,
runnerDigest: expectedRunnerDigest,
environment: {
PATH: "/bin",
OPENROUTER_API_KEY: "provider-key",
PAPERCLIP_OPENCODE_COMMAND: "/provider-pack/opencode",
PAPERCLIP_OPENCODE_PERMISSION_MODE: "deny",
PAPERCLIP_OPENCODE_RUNTIME_DIR: "/runner/opencode",
DATABASE_URL: "must-not-reach-runnerd",
PAPERCLIP_API_KEY: "must-not-reach-runnerd",
NODE_OPTIONS: "--require=/untrusted/bootstrap.cjs",
},
processLauncher: (spec) => {
launches.push(spec);
return {
child: {
pid: 42,
exitCode: null,
signalCode: null,
kill: () => true,
},
completion: Promise.resolve({
code: 0,
signal: null,
stdout: "",
stderr: "",
}),
};
},
});
expect(launches).toHaveLength(1);
expect(launches[0]!.environment).toMatchObject({
PATH: "/bin",
OPENROUTER_API_KEY: "provider-key",
PAPERCLIP_OPENCODE_COMMAND: "/provider-pack/opencode",
PAPERCLIP_OPENCODE_PERMISSION_MODE: "deny",
PAPERCLIP_OPENCODE_RUNTIME_DIR: "/runner/opencode",
});
expect(launches[0]!.environment.DATABASE_URL).toBeUndefined();
expect(launches[0]!.environment.PAPERCLIP_API_KEY).toBeUndefined();
expect(launches[0]!.environment.NODE_OPTIONS).toBeUndefined();
});
function domainDigest(domain: string, parts: readonly Buffer[]): Buffer {
const digest = createHash("sha256")
.update(domain)

View File

@ -1876,6 +1876,7 @@ const runnerExplicitProviderEnvironmentKeys = [
"AWS_CONTAINER_CREDENTIALS_FULL_URI",
"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",
"PAPERCLIP_OPENCODE_COMMAND",
"PAPERCLIP_OPENCODE_PERMISSION_MODE",
"PAPERCLIP_OPENCODE_RUNTIME_DIR",
"PAPERCLIP_RUNNER_INSTANCE_ID",
"PAPERCLIP_RUN_ID",

View File

@ -62,7 +62,16 @@ it("preserves OpenCode runtime bindings when a durable runner is respawned", ()
options: {
provider: "opencode",
stateDirectory: "/isolated/session",
environment: { PATH: "/bin", OPENROUTER_API_KEY: "test-provider-key" },
opencodePermissionMode: "deny",
environment: {
PATH: "/bin",
OPENROUTER_API_KEY: "test-provider-key",
HOME: "/host/home",
CODEX_HOME: "/host/codex-home",
DATABASE_URL: "must-not-reach-runnerd",
PAPERCLIP_API_KEY: "must-not-reach-runnerd",
NODE_OPTIONS: "--require=/untrusted/bootstrap.cjs",
},
opencodeCommand: "/provider-pack/opencode",
opencodeRuntimeDirectory: "/isolated/session/opencode",
},
@ -80,6 +89,7 @@ it("preserves OpenCode runtime bindings when a durable runner is respawned", ()
});
expect(environment).toMatchObject({
PAPERCLIP_OPENCODE_COMMAND: "/provider-pack/opencode",
PAPERCLIP_OPENCODE_PERMISSION_MODE: "deny",
PAPERCLIP_OPENCODE_RUNTIME_DIR: "/isolated/session/opencode",
PAPERCLIP_RUNNER_INSTANCE_ID: "runner-1",
PAPERCLIP_RUN_ID: "run-1",
@ -87,6 +97,35 @@ it("preserves OpenCode runtime bindings when a durable runner is respawned", ()
PAPERCLIP_NATIVE_RUNTIME_CONTEXT_PATH: "/isolated/runtime-context.json",
OPENROUTER_API_KEY: "test-provider-key",
});
expect(environment.HOME).toBeUndefined();
expect(environment.CODEX_HOME).toBeUndefined();
expect(environment.DATABASE_URL).toBeUndefined();
expect(environment.PAPERCLIP_API_KEY).toBeUndefined();
expect(environment.NODE_OPTIONS).toBeUndefined();
const defaultPermissionEnvironment =
createCapabilityRunnerdProviderEnvironment({
provider: "opencode",
options: {
provider: "opencode",
stateDirectory: "/isolated/session",
environment: { PATH: "/bin" },
},
identity: {
runnerInstanceId: "runner-1",
environmentLeaseId: "lease-1",
runId: "run-1",
normalizedSessionId: "session-1",
turnId: "turn-1",
itemId: "item-1",
},
codexHome: "/isolated/codex-home",
runtimeContextPath: "/isolated/runtime-context.json",
hasRuntimeContext: false,
});
expect(defaultPermissionEnvironment.PAPERCLIP_OPENCODE_PERMISSION_MODE).toBe(
"ask",
);
});
it("passes the configured Codex API key only through the provider process environment", () => {

View File

@ -42,7 +42,10 @@ import {
} from "../drivers/acpx/qualified-profiles.js";
import { createSanitizedAcpxSpawnInput } from "../drivers/acpx/environment.js";
import type { NativeRuntimeContextSnapshot } from "../contracts/runtime-context.js";
import type { NativeAcpxPermissionMode } from "../contracts/native-execution.js";
import type {
NativeAcpxPermissionMode,
NativeOpenCodePermissionMode,
} from "../contracts/native-execution.js";
import { nativeMcpLaunchBinding } from "../drivers/native-mcp.js";
import {
prepareIsolatedCodexHome,
@ -235,6 +238,7 @@ export interface CapabilityRunnerdProcessEvidence {
export interface CapabilityRunnerdCodexTransportOptions {
provider?: "codex" | "opencode" | "acpx";
opencodePermissionMode?: NativeOpenCodePermissionMode;
acpxAgent?: QualifiedAcpxAgent;
acpxPermissionMode?: NativeAcpxPermissionMode;
acpxPermissionModePinned?: boolean;
@ -828,9 +832,10 @@ export function createCapabilityRunnerdProviderEnvironment(input: {
};
if (input.provider === "opencode") {
return {
...process.env,
...input.options.environment,
...createSanitizedOpenCodeRunnerEnvironment(input.options.environment),
PAPERCLIP_OPENCODE_COMMAND: input.options.opencodeCommand ?? "opencode",
PAPERCLIP_OPENCODE_PERMISSION_MODE:
input.options.opencodePermissionMode ?? "ask",
PAPERCLIP_OPENCODE_RUNTIME_DIR:
input.options.opencodeRuntimeDirectory ??
resolve(input.options.stateDirectory ?? tmpdir(), "opencode"),
@ -865,6 +870,51 @@ export function createCapabilityRunnerdProviderEnvironment(input: {
return environment;
}
const OPEN_CODE_RUNNER_ENVIRONMENT_KEYS = new Set([
"PATH",
"LANG",
"LANGUAGE",
"LC_ALL",
"LC_CTYPE",
"TZ",
"TMPDIR",
"TEMP",
"TMP",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
"NODE_EXTRA_CA_CERTS",
"HTTP_PROXY",
"HTTPS_PROXY",
"NO_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"no_proxy",
"all_proxy",
"SystemRoot",
"PATHEXT",
"WINDIR",
"RUST_BACKTRACE",
"OPENROUTER_API_KEY",
"PAPERCLIP_NATIVE_MCP_NAME",
"PAPERCLIP_NATIVE_MCP_URL",
"PAPERCLIP_NATIVE_MCP_TOKEN",
]);
function createSanitizedOpenCodeRunnerEnvironment(
source: NodeJS.ProcessEnv | undefined,
): NodeJS.ProcessEnv {
const candidate = { ...process.env, ...source };
return Object.fromEntries(
Object.entries(candidate).filter(
([key, value]) =>
typeof value === "string"
&& (OPEN_CODE_RUNNER_ENVIRONMENT_KEYS.has(key)
|| /^LC_[A-Z0-9_]{1,32}$/.test(key)),
),
);
}
export function resolveSourceCodexHome(
environment: NodeJS.ProcessEnv | undefined,
): string | null {
@ -1003,7 +1053,9 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
options.environment,
options.acpxAgent ?? "codex",
).env
: createSanitizedCodexEnvironment(options.environment),
: options.provider === "opencode"
? createSanitizedOpenCodeRunnerEnvironment(options.environment)
: createSanitizedCodexEnvironment(options.environment),
).sort(),
diagnostics: ["lab transport selected authenticated durable PRP"],
};

View File

@ -69,7 +69,7 @@ export function buildNativeExecutionInput(input: {
?? (input.issue.workMode === "planning" ? "plan" : "default");
const acpxProfile = input.provider === "acpx"
? resolveQualifiedAcpxProfile(
input.acpxAgent ?? "pi",
input.acpxAgent ?? "codex",
input.model ?? "",
)
: null;
@ -119,7 +119,7 @@ export function buildNativeExecutionInput(input: {
kind: "acpx",
agent: acpxProfile!.agent,
model: input.model,
permissionMode: input.acpxPermissionMode ?? "approve-all",
permissionMode: input.acpxPermissionMode ?? "approve-reads",
profile: {
driverKind: acpxProfile!.driverKind,
protocolVersion: acpxProfile!.protocolVersion,
@ -137,7 +137,7 @@ export function buildNativeExecutionInput(input: {
? {
kind: "opencode",
model: input.model,
permissionMode: input.opencodePermissionMode ?? "allow",
permissionMode: input.opencodePermissionMode ?? "ask",
}
: {
kind: "codex",

View File

@ -221,6 +221,16 @@ describe("buildNativeExecutionInput wake projection", () => {
model: "claude-sonnet-5",
acpxPermissionMode: "deny-all",
});
const defaultOpenCode = buildNativeExecutionInput({
...common,
provider: "opencode",
model: "openrouter/z-ai/glm-5.2",
});
const defaultAcpx = buildNativeExecutionInput({
...common,
provider: "acpx",
model: "gpt-5.6-sol",
});
expect(codex).toMatchObject({
schema: "paperclip.native-execution-input.v4",
@ -234,6 +244,16 @@ describe("buildNativeExecutionInput wake projection", () => {
schema: "paperclip.native-execution-input.v4",
provider: { kind: "acpx", permissionMode: "deny-all" },
});
expect(defaultOpenCode).toMatchObject({
provider: { kind: "opencode", permissionMode: "ask" },
});
expect(defaultAcpx).toMatchObject({
provider: {
kind: "acpx",
agent: "codex",
permissionMode: "approve-reads",
},
});
expect(JSON.stringify([codex, opencode, acpx]))
.not.toMatch(/OPENAI_API_KEY|ANTHROPIC_API_KEY|AWS_SECRET_ACCESS_KEY|PAPERCLIP_API_KEY/);
});