fix(adapters): default legacy harnesses and connected tools to full auto (#13693)

## Thinking Path

> - Paperclip lets people manage AI agents and their work.
> - Legacy adapters launch provider CLIs and expose connected tools.
> - Existing defaults did not consistently grant full automatic
permission.
> - Remote Claude used a fixed tool list that omitted MCP tools and
future tools.
> - Direct Codex launches and OpenCode configuration also used narrower
defaults.
> - This change gives all these paths the same full-auto default as
native runners.
> - Explicit restrictive settings continue to work.

## Linked Issues or Issue Description

Refs #13686. This PR is stacked on that native-runner and
task-reassignment PR. Merge #13686 first.

Related: #831 (constructed Claude agents), #1935 (adapter-switching
permission defaults).

## What Changed

- Use actual Claude permission bypass for local and remote runs and
probes. Remove the fixed tool list so MCP and future provider tools are
included.
- Identify actual managed sandbox targets to Claude with `IS_SANDBOX=1`.
Do not mark ordinary host execution as a sandbox.
- Default direct Codex execution to approval and sandbox bypass,
matching agent creation. Preserve explicit false, CLI profiles, sandbox
modes, approval policy, and network restrictions.
- Set OpenCode's full-auto runtime permission to `allow` for every tool
and connection. Preserve the existing explicit opt-out.
- Default Gemini probes to the same YOLO mode as execution. Make the
legacy ACP `default` alias use `approve-all` for fresh and resumed
sessions.
- Add default, opt-out, remote, probe, connected-tool, and resume
regression tests. Update adapter configuration documentation.
- Other adapter paths already request full automatic permission or have
no provider approval gate.

## Verification

- Full workspace `pnpm -r typecheck` and `pnpm build` passed locally
after rebasing onto current master. Targeted adapter/server and legacy
ACP tests passed, including defaults, explicit opt-outs, remote
launches, connected tools, and fresh/resumed sessions.
- Greptile reviewed current head
`8ca135eaffcf9cfdba6f1368e896a781a0891d50` at **5/5**. The security
reviewer acknowledged the documented full-auto requirement. Acknowledged
discussions are resolved.
- Current head has **54 passing checks**. [PR
checks](https://github.com/paperclipai/paperclip/pull/13693/checks). The
process-adapter signoff browser shard passed on one retry after its
first attempt exceeded a three-second issue-run wait.
- **Six native Claude/Codex real-provider cases passed on their first
attempt, with cleanup passing**, against the combined branch: plans,
reassignment, and backlog creation/status. [Campaign and downloadable
evidence](https://github.com/paperclipai/paperclip/actions/runs/35469926548).
This does not claim a real-provider run of every legacy adapter.
- The live-tested revision is
`a37881c824dcd7170380fc4b788732fc743e5da7`. The current head differs
only in the corrected heartbeat test expectation; application code is
identical.
- The campaign result-enforcement job passed. The separate report
publisher failed during frozen dependency installation because the
trusted workflow's patched-dependency configuration does not match its
lockfile. Passing case evidence remains downloadable from the workflow.
- Full-suite coverage comes from CI partitions. The separate unsharded
local run was stopped after the corresponding CI partitions passed; it
is not counted as a completed local run.

## Risks

- Missing permission settings now grant all provider operations,
including connected tools. OpenCode full-auto also overrides ambient
provider permission rules. An explicit Paperclip permission opt-out
preserves restrictive behavior.
- Claude refuses full bypass as root outside an identified sandbox.
Ordinary host deployments must run Claude as a non-root user. Managed
sandbox launches include the required marker.
- These defaults do not grant additional Paperclip roles, connections,
or company access. Existing controller authorization and governance
still apply.
- This PR depends on #13686. Retarget it to master after that PR merges.

## Model Used

OpenAI Codex, based on GPT-6, with code execution and repository tools.
The exact deployment model ID and context-window size are not exposed in
this session.

## 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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-09-19 16:49:18 -05:00
1 parent 7bc03e0acd
commit 45c99a0d06
20 files changed
+119 -220

No files matched your search

+1 -1
View File
@@ -53,7 +53,7 @@ Core fields:
- chrome (boolean, optional): pass --chrome when running Claude
- promptTemplate (string, optional): run prompt template
- maxTurnsPerRun (number, optional): max turns for one run
- dangerouslySkipPermissions (boolean, optional, default true): allow non-interactive Claude runs to proceed without approval prompts. Local targets receive --dangerously-skip-permissions; remote targets receive a curated --allowedTools list so they do not inherit local bypass permissions.
- dangerouslySkipPermissions (boolean, optional, default true): allow non-interactive Claude runs to proceed without approval prompts. Local and remote targets receive --dangerously-skip-permissions for all built-in and connected tools. Managed sandbox targets also identify themselves to Claude so root container launches support bypass. Non-sandbox root processes must run Claude as a non-root user; Paperclip does not silently downgrade the requested mode.
- command (string, optional): defaults to "claude"
- extraArgs (string[], optional): additional CLI args
- env (object, optional): KEY=VALUE environment variables
@@ -54,7 +54,7 @@ import {
import { createWorkspaceRestoreTeardown } from "@paperclipai/adapter-utils/workspace-restore-teardown";
import { buildLocalAdapterTestProbeEnv } from "./probe-env.js";
import { detectClaudeLoginRequired, extractClaudeRetryNotBefore, isClaudeProviderQuotaError, parseClaudeStreamJson } from "./parse.js";
import { buildClaudeProbePermissionArgs } from "./permissions.js";
import { buildClaudeProbePermissionArgs, claudeSandboxPermissionEnv } from "./permissions.js";
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "./auth-check.js";
import { resolveClaudeModel, SANDBOX_INSTALL_COMMAND } from "../index.js";
@@ -647,6 +647,9 @@ export async function probeClaudeAcpSandboxLogin(input: {
cwd = asString(config.cwd, process.cwd());
}
Object.assign(env, claudeSandboxPermissionEnv({
dangerouslySkipPermissions: asBoolean(config.dangerouslySkipPermissions, true), targetIsSandbox,
}));
const args = ["--print", "-", "--output-format", "stream-json", "--verbose"];
if (config.managedAiConnection) args.push("--setting-sources", "user");
args.push(
@@ -193,11 +193,8 @@ describe("claude remote execution", () => {
| [string, string, string[], { env: Record<string, string>; remoteExecution?: { remoteCwd: string } | null }]
| undefined;
expect(call?.[2]).toEqual(expect.arrayContaining(["--model", "claude-opus-5"]));
expect(call?.[2]).toContain("--allowedTools");
expect(call?.[2]).toContain(
"Task AskUserQuestion Bash CronCreate CronDelete CronList Edit EnterPlanMode EnterWorktree ExitPlanMode ExitWorktree Glob Grep Monitor NotebookEdit PushNotification Read RemoteTrigger ScheduleWakeup Skill TaskOutput TaskStop TodoWrite ToolSearch WebFetch WebSearch Write",
);
expect(call?.[2]).not.toContain("--dangerously-skip-permissions");
expect(call?.[2]).toContain("--dangerously-skip-permissions");
expect(call?.[2]).not.toContain("--allowedTools");
expect(call?.[2]).toContain("--append-system-prompt-file");
expect(call?.[2]).toContain(
`${managedRemoteWorkspace}/.paperclip-runtime/claude/skills/agent-instructions.md`,
@@ -92,7 +92,7 @@ import {
import { resolveClaudeDesiredSkillNames } from "./skills.js";
import { isBedrockModelId } from "./models.js";
import { prepareClaudePromptBundle } from "./prompt-cache.js";
import { buildClaudeExecutionPermissionArgs } from "./permissions.js";
import { buildClaudeExecutionPermissionArgs, claudeSandboxPermissionEnv } from "./permissions.js";
import { resolveClaudeModel, SANDBOX_INSTALL_COMMAND } from "../index.js";
import {
createClaudeAcpExecutor,
@@ -480,6 +480,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
graceSec,
extraArgs,
} = runtimeConfig;
Object.assign(env, claudeSandboxPermissionEnv({ dangerouslySkipPermissions, targetIsSandbox: executionTargetIsSandbox }));
let loggedEnv = initialLoggedEnv;
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
const terminalResultCleanupGraceMs = Math.max(
@@ -937,7 +938,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}
if (dangerouslySkipPermissions && executionTargetIsRemote) {
commandNotes.push(
"Using a broad --allowedTools whitelist for remote execution so hosted targets do not inherit local Claude bypass permissions.",
"Using full Claude permission bypass for remote execution, including connected tools.",
);
}
if (attemptInstructionsFilePath && !resumeSessionId) {
@@ -1,79 +1,23 @@
import { describe, expect, it } from "vitest";
import { buildClaudeExecutionPermissionArgs, buildClaudeProbePermissionArgs } from "./permissions.js";
import { buildClaudeExecutionPermissionArgs, buildClaudeProbePermissionArgs, claudeSandboxPermissionEnv } from "./permissions.js";
const SANDBOX_ALLOWED_TOOLS =
"Task AskUserQuestion Bash CronCreate CronDelete CronList Edit " +
"EnterPlanMode EnterWorktree ExitPlanMode ExitWorktree Glob Grep Monitor " +
"NotebookEdit PushNotification Read RemoteTrigger ScheduleWakeup Skill " +
"TaskOutput TaskStop TodoWrite ToolSearch WebFetch WebSearch Write";
describe("claude-local remote permission args", () => {
it("uses the canonical Bash tool grant for remote execution", () => {
expect(buildClaudeExecutionPermissionArgs({ dangerouslySkipPermissions: true, targetIsRemote: true })).toEqual([
"--allowedTools",
SANDBOX_ALLOWED_TOOLS,
]);
});
it("uses the canonical Bash tool grant for remote probes", () => {
expect(buildClaudeProbePermissionArgs({ dangerouslySkipPermissions: true, targetIsRemote: true })).toEqual([
"--allowedTools",
SANDBOX_ALLOWED_TOOLS,
]);
});
it("does not use Bash(*) because Claude Code treats Bash grants as command-prefix patterns", () => {
const [, allowedTools] = buildClaudeExecutionPermissionArgs({
dangerouslySkipPermissions: true,
targetIsRemote: true,
describe("Claude full-auto permission args", () => {
for (const [name, build] of [["execution", buildClaudeExecutionPermissionArgs], ["probe", buildClaudeProbePermissionArgs]] as const) {
it.each([
{ targetIsRemote: false, localProcessUid: 1000 },
{ targetIsRemote: true, localProcessUid: 1000 },
{ targetIsRemote: false, localProcessUid: 0 },
{ targetIsRemote: true, localProcessUid: 0 },
])(`${name} requests full bypass for %j`, (target) => {
expect(build({ ...target, dangerouslySkipPermissions: true }))
.toEqual(["--dangerously-skip-permissions"]);
expect(build({ ...target, dangerouslySkipPermissions: false })).toEqual([]);
});
}
expect(allowedTools.split(" ")).toContain("Bash");
expect(allowedTools).not.toContain("Bash(*)");
});
it("does not pass permission flags when skip-permissions is disabled", () => {
expect(buildClaudeExecutionPermissionArgs({ dangerouslySkipPermissions: false, targetIsRemote: true })).toEqual([]);
expect(buildClaudeProbePermissionArgs({ dangerouslySkipPermissions: false, targetIsRemote: true })).toEqual([]);
});
it("uses dangerously-skip-permissions for non-root local execution", () => {
expect(
buildClaudeExecutionPermissionArgs({
dangerouslySkipPermissions: true,
targetIsRemote: false,
localProcessUid: 1000,
}),
).toEqual(["--dangerously-skip-permissions"]);
});
it("uses dangerously-skip-permissions for non-root local probes", () => {
expect(
buildClaudeProbePermissionArgs({
dangerouslySkipPermissions: true,
targetIsRemote: false,
localProcessUid: 1000,
}),
).toEqual(["--dangerously-skip-permissions"]);
});
it("uses allowedTools for local root execution because Claude refuses dangerously-skip-permissions as root", () => {
expect(
buildClaudeExecutionPermissionArgs({
dangerouslySkipPermissions: true,
targetIsRemote: false,
localProcessUid: 0,
}),
).toEqual(["--allowedTools", SANDBOX_ALLOWED_TOOLS]);
});
it("uses allowedTools for local root probes because Claude refuses dangerously-skip-permissions as root", () => {
expect(
buildClaudeProbePermissionArgs({
dangerouslySkipPermissions: true,
targetIsRemote: false,
localProcessUid: 0,
}),
).toEqual(["--allowedTools", SANDBOX_ALLOWED_TOOLS]);
it("identifies managed sandboxes for Claude's root launch check only when full auto is enabled", () => {
expect(claudeSandboxPermissionEnv({ dangerouslySkipPermissions: true, targetIsSandbox: true })).toEqual({ IS_SANDBOX: "1" });
expect(claudeSandboxPermissionEnv({ dangerouslySkipPermissions: false, targetIsSandbox: true })).toEqual({});
expect(claudeSandboxPermissionEnv({ dangerouslySkipPermissions: true, targetIsSandbox: false })).toEqual({});
});
});
@@ -1,53 +1,23 @@
// Explicit allowlist of Claude Code tools we permit when running on a remote
// target. We use this instead of `--dangerously-skip-permissions` for remote
// targets because the permission-approval prompts can't be answered by a
// human inside a non-interactive run, but blanket-allowing every tool would
// defeat the point of having a separate hosted/sandbox code path.
//
// Maintenance: this list must be reviewed when Claude Code releases a new
// tool. The canonical list of built-in tools is documented at
// https://docs.claude.com/en/docs/claude-code/built-in-tools — when a tool
// is added there, decide whether it should be allowed in remote runs and
// either add it here or document the deliberate exclusion. Omitting a tool
// silently disables it inside remote targets, which can look like the tool is
// "broken" rather than intentionally gated.
const SANDBOX_ALLOWED_TOOLS =
"Task AskUserQuestion Bash CronCreate CronDelete CronList Edit " +
"EnterPlanMode EnterWorktree ExitPlanMode ExitWorktree Glob Grep Monitor " +
"NotebookEdit PushNotification Read RemoteTrigger ScheduleWakeup Skill " +
"TaskOutput TaskStop TodoWrite ToolSearch WebFetch WebSearch Write";
function shouldUseAllowedTools(input: { targetIsRemote: boolean; localProcessUid?: number | null }): boolean {
// Claude Code refuses `--dangerously-skip-permissions` when the process runs
// as root. Use the same explicit allowlist that remote targets use so local
// Docker/root probes and executions fail safe instead of hard-failing before
// auth/runtime validation can complete.
return input.targetIsRemote || input.localProcessUid === 0;
}
export function buildClaudeProbePermissionArgs(input: {
interface ClaudePermissionInput {
dangerouslySkipPermissions: boolean;
targetIsRemote: boolean;
localProcessUid?: number | null;
}): string[] {
if (!input.dangerouslySkipPermissions) return [];
// For remote targets and local root processes, mirror the execution path:
// pass `--allowedTools` with the curated allowlist instead of dropping the
// flag entirely. The hello probe is a one-shot prompt that should never
// trigger a tool, but if a future probe prompt does, we don't want Claude CLI
// to stall on an interactive permission prompt that no human can answer.
if (shouldUseAllowedTools(input)) return ["--allowedTools", SANDBOX_ALLOWED_TOOLS];
return ["--dangerously-skip-permissions"];
}
export function buildClaudeExecutionPermissionArgs(input: {
// Permission defaults are identical for local, remote, and connected tools.
// A tool allowlist is not equivalent to full bypass: it misses MCP tools and
// tools added by later provider releases. Let Claude enforce its own launch
// requirements rather than silently downgrading the requested permission mode.
export function buildClaudeExecutionPermissionArgs(input: ClaudePermissionInput): string[] {
return input.dangerouslySkipPermissions ? ["--dangerously-skip-permissions"] : [];
}
export const buildClaudeProbePermissionArgs = buildClaudeExecutionPermissionArgs;
/** Claude permits full bypass as root only inside an identified sandbox. */
export function claudeSandboxPermissionEnv(input: {
dangerouslySkipPermissions: boolean;
targetIsRemote: boolean;
localProcessUid?: number | null;
}): string[] {
if (!input.dangerouslySkipPermissions) return [];
if (shouldUseAllowedTools(input)) {
return ["--allowedTools", SANDBOX_ALLOWED_TOOLS];
}
return ["--dangerously-skip-permissions"];
targetIsSandbox: boolean;
}): Record<string, string> {
return input.dangerouslySkipPermissions && input.targetIsSandbox ? { IS_SANDBOX: "1" } : {};
}
@@ -32,7 +32,7 @@ import {
readClaudeCommandVersion,
} from "./cli-capabilities.js";
import { isBedrockModelId } from "./models.js";
import { buildClaudeProbePermissionArgs } from "./permissions.js";
import { buildClaudeProbePermissionArgs, claudeSandboxPermissionEnv } from "./permissions.js";
import { prepareSandboxClaudeProbeRuntime } from "./claude-config.js";
import { resolveClaudeModel, SANDBOX_INSTALL_COMMAND } from "../index.js";
import { resolveClaudeExecutionEngineForRun, testClaudeAcpEnvironment } from "./acp.js";
@@ -322,6 +322,7 @@ export async function testEnvironment(
const chrome = asBoolean(config.chrome, false);
const maxTurns = asNumber(config.maxTurnsPerRun, 0);
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, true);
Object.assign(env, claudeSandboxPermissionEnv({ dangerouslySkipPermissions, targetIsSandbox }));
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
@@ -2,6 +2,13 @@ import { describe, expect, it } from "vitest";
import { buildCodexExecArgs } from "./codex-args.js";
describe("buildCodexExecArgs", () => {
it.each([null, "existing-session"])("defaults direct and resumed launches to full bypass (%s)", (resumeSessionId) => {
const { args } = buildCodexExecArgs({}, { resumeSessionId });
expect(args).toContain("--dangerously-bypass-approvals-and-sandbox");
expect(args).not.toContain('sandbox_mode="workspace-write"');
if (resumeSessionId) expect(args.slice(-3)).toEqual(["resume", resumeSessionId, "-"]);
});
it("forwards GPT-6 Astra, its ultra reasoning effort, and fast mode", () => {
const result = buildCodexExecArgs({
model: "gpt-6-astra",
@@ -15,10 +22,7 @@ describe("buildCodexExecArgs", () => {
expect(result.args).toEqual([
"exec",
"--json",
"-c",
'sandbox_mode="workspace-write"',
"-c",
"sandbox_workspace_write.network_access=true",
"--dangerously-bypass-approvals-and-sandbox",
"--model",
"gpt-6-astra",
"-c",
@@ -58,10 +62,7 @@ describe("buildCodexExecArgs", () => {
"--search",
"exec",
"--json",
"-c",
'sandbox_mode="workspace-write"',
"-c",
"sandbox_workspace_write.network_access=true",
"--dangerously-bypass-approvals-and-sandbox",
"--model",
"gpt-5.4",
"-c",
@@ -84,10 +85,7 @@ describe("buildCodexExecArgs", () => {
expect(result.args).toEqual([
"exec",
"--json",
"-c",
'sandbox_mode="workspace-write"',
"-c",
"sandbox_workspace_write.network_access=true",
"--dangerously-bypass-approvals-and-sandbox",
"--model",
"gpt-5.5",
"-c",
@@ -110,10 +108,7 @@ describe("buildCodexExecArgs", () => {
expect(result.args).toEqual([
"exec",
"--json",
"-c",
'sandbox_mode="workspace-write"',
"-c",
"sandbox_workspace_write.network_access=true",
"--dangerously-bypass-approvals-and-sandbox",
"--model",
"future-codex-model",
"-c",
@@ -135,10 +130,7 @@ describe("buildCodexExecArgs", () => {
expect(result.args).toEqual([
"exec",
"--json",
"-c",
'sandbox_mode="workspace-write"',
"-c",
"sandbox_workspace_write.network_access=true",
"--dangerously-bypass-approvals-and-sandbox",
"-c",
'service_tier="fast"',
"-c",
@@ -161,10 +153,7 @@ describe("buildCodexExecArgs", () => {
expect(result.args).toEqual([
"exec",
"--json",
"-c",
'sandbox_mode="workspace-write"',
"-c",
"sandbox_workspace_write.network_access=true",
"--dangerously-bypass-approvals-and-sandbox",
"--model",
"gpt-5",
"-",
@@ -182,10 +171,7 @@ describe("buildCodexExecArgs", () => {
expect(result.args).toEqual([
"exec",
"--json",
"-c",
'sandbox_mode="workspace-write"',
"-c",
"sandbox_workspace_write.network_access=true",
"--dangerously-bypass-approvals-and-sandbox",
"--model",
"gpt-5.4-mini",
"-",
@@ -203,11 +189,8 @@ describe("buildCodexExecArgs", () => {
expect(result.args).toEqual([
"exec",
"--json",
"-c",
'sandbox_mode="workspace-write"',
"-c",
"sandbox_workspace_write.network_access=true",
"--skip-git-repo-check",
"--dangerously-bypass-approvals-and-sandbox",
"--model",
"gpt-5.5",
"-",
@@ -227,10 +210,7 @@ describe("buildCodexExecArgs", () => {
expect(result.args).toEqual([
"exec",
"--json",
"-c",
'sandbox_mode="workspace-write"',
"-c",
"sandbox_workspace_write.network_access=true",
"--dangerously-bypass-approvals-and-sandbox",
"--model",
"gpt-5.5",
"--skip-git-repo-check",
@@ -1,6 +1,7 @@
import { asBoolean, asString, asStringArray } from "@paperclipai/adapter-utils/server-utils";
import {
CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS,
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
isCodexLocalFastModeSupported,
normalizeCodexModel,
} from "../index.js";
@@ -48,20 +49,21 @@ export function buildCodexExecArgs(
const search = asBoolean(record.search, false);
const fastModeRequested = asBoolean(record.fastMode, false);
const fastModeApplied = fastModeRequested && isCodexLocalFastModeSupported(model);
const bypass = asBoolean(
record.dangerouslyBypassApprovalsAndSandbox,
asBoolean(record.dangerouslyBypassSandbox, false),
);
const extraArgs = readExtraArgs(record);
const args = ["exec", "--json"];
// `codex exec` otherwise defaults to read-only/never, which cannot perform
// Paperclip work. Keep the sandbox, but make unattended workspace work and
// API calls possible. Explicit operator modes/profiles retain their meaning.
// Explicit CLI modes/profiles remain deliberate overrides. An omitted
// setting uses the same full-auto default as agent creation and onboarding.
const explicitSandbox = extraArgs.some((arg) =>
/^(--sandbox(?:=|$)|-s|--profile(?:=|$)|-p|--full-auto$|--yolo$|--dangerously-bypass-approvals-and-sandbox$)/.test(arg)
|| /^(?:(?:--config=|-c=?)\s*)?(?:sandbox_mode|profile)\s*=/.test(arg),
);
const explicitPermissionRestriction = extraArgs.some((arg) =>
/^(?:(?:--config=|-c=?)\s*)?(?:approval_policy\s*=|sandbox_workspace_write\.network_access\s*=\s*false)/.test(arg),
);
const bypass = asBoolean(
record.dangerouslyBypassApprovalsAndSandbox,
asBoolean(record.dangerouslyBypassSandbox, !explicitSandbox && !explicitPermissionRestriction && options.networkAccess !== false && DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX),
);
const args = ["exec", "--json"];
if (!bypass && !explicitSandbox) {
args.push("-c", 'sandbox_mode="workspace-write"');
args.push("-c", `sandbox_workspace_write.network_access=${options.networkAccess !== false}`);
@@ -394,10 +394,7 @@ describe("codex remote execution", () => {
expect(call?.[2]).toEqual([
"exec",
"--json",
"-c",
'sandbox_mode="workspace-write"',
"-c",
"sandbox_workspace_write.network_access=true",
"--dangerously-bypass-approvals-and-sandbox",
"-",
]);
});
@@ -470,10 +467,7 @@ describe("codex remote execution", () => {
expect(call?.[2]).toEqual([
"exec",
"--json",
"-c",
'sandbox_mode="workspace-write"',
"-c",
"sandbox_workspace_write.network_access=true",
"--dangerously-bypass-approvals-and-sandbox",
"resume",
"session-123",
"-",
@@ -554,10 +548,7 @@ describe("codex remote execution", () => {
expect(call?.[2]).toEqual([
"exec",
"--json",
"-c",
'sandbox_mode="workspace-write"',
"-c",
"sandbox_workspace_write.network_access=true",
"--dangerously-bypass-approvals-and-sandbox",
"resume",
"session-123",
"-",
@@ -192,7 +192,7 @@ export async function testEnvironment(
});
} else {
const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim();
const approvalMode = asString(config.approvalMode, asBoolean(config.yolo, false) ? "yolo" : "default");
const approvalMode = asString(config.approvalMode, asBoolean(config.yolo, true) ? "yolo" : "default");
const sandbox = asBoolean(config.sandbox, false);
const helloProbeTimeoutSec = Math.max(1, asNumber(config.helloProbeTimeoutSec, 60));
const extraArgs = (() => {
@@ -80,7 +80,7 @@ Core fields:
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt
- model (string, required): OpenCode model id in provider/model format (for example anthropic/claude-sonnet-4-5)
- variant (string, optional): provider-specific reasoning/profile variant passed as --variant (for example minimal|low|medium|high|xhigh|max)
- dangerouslySkipPermissions (boolean, optional): inject a runtime OpenCode config that allows \`external_directory\` access without interactive prompts; defaults to true for unattended Paperclip runs
- dangerouslySkipPermissions (boolean, optional): inject a runtime OpenCode config with \`permission=allow\` for all tools and connections; defaults to true for unattended Paperclip runs
- promptTemplate (string, optional): run prompt template
- command (string, optional): defaults to "opencode"
- extraArgs (string[], optional): additional CLI args
@@ -100,6 +100,6 @@ Notes:
writing an opencode.json config file into the project working directory. Model \
selection is passed via the --model CLI flag instead.
- When \`dangerouslySkipPermissions\` is enabled, Paperclip injects a temporary \
runtime config with \`permission.external_directory=allow\` so headless runs do \
runtime config with \`permission=allow\` so headless runs do \
not stall on approval prompts.
`;
@@ -31,10 +31,12 @@ async function makeConfigHome(initialConfig?: Record<string, unknown>) {
}
describe("prepareOpenCodeRuntimeConfig", () => {
it("injects an external_directory allow rule by default", async () => {
it("allows all tools and connected tools by default", async () => {
const configHome = await makeConfigHome({
permission: {
read: "allow",
bash: "ask",
"mcp__example__write": "deny",
},
theme: "system",
});
@@ -54,10 +56,7 @@ describe("prepareOpenCodeRuntimeConfig", () => {
) as Record<string, unknown>;
expect(runtimeConfig).toMatchObject({
theme: "system",
permission: {
read: "allow",
external_directory: "allow",
},
permission: "allow",
});
await prepared.cleanup();
@@ -92,7 +91,7 @@ describe("prepareOpenCodeRuntimeConfig", () => {
await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"),
) as Record<string, unknown>;
expect(runtimeConfig).toMatchObject({
permission: { read: "allow", external_directory: "allow" },
permission: "allow",
provider: providers,
});
expect(prepared.notes.some((n) => n.includes("bifrost"))).toBe(true);
@@ -149,11 +149,8 @@ export async function prepareOpenCodeRuntimeConfig(input: {
}
const existingConfig = await readJsonObject(runtimeConfigPath);
const existingPermission = isPlainObject(existingConfig.permission)
? existingConfig.permission
: {};
const notes = [
"Injected runtime OpenCode config with permission.external_directory=allow to avoid headless approval prompts.",
"Injected runtime OpenCode config with permission=allow for all tools and connections.",
];
// Merge gateway/custom provider definitions supplied via PAPERCLIP_OPENCODE_PROVIDERS
@@ -207,10 +204,7 @@ export async function prepareOpenCodeRuntimeConfig(input: {
const nextConfig: Record<string, unknown> = {
...existingConfig,
permission: {
...existingPermission,
external_directory: "allow",
},
permission: "allow",
};
if (Object.keys(nextProvider).length > 0) {
nextConfig.provider = nextProvider;
@@ -133,7 +133,7 @@ export async function testEnvironment(
checks.push({
code: "opencode_headless_permissions_enabled",
level: "info",
message: "Headless OpenCode external-directory permissions are auto-approved for unattended runs.",
message: "Headless OpenCode permissions are auto-approved for all tools and connections.",
});
}
let restoreWorkspace: (() => Promise<void>) | null = null;