From 45c99a0d06cbd5b04982b06b79de321149930ac5 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:49:18 -0500 Subject: [PATCH] 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 --- .../src/acpx-engine/execute.test.ts | 23 +++++ .../adapter-utils/src/acpx-engine/execute.ts | 2 +- packages/adapters/claude-local/src/index.ts | 2 +- .../adapters/claude-local/src/server/acp.ts | 5 +- .../src/server/execute.remote.test.ts | 7 +- .../claude-local/src/server/execute.ts | 5 +- .../src/server/permissions.test.ts | 90 ++++--------------- .../claude-local/src/server/permissions.ts | 62 ++++--------- .../adapters/claude-local/src/server/test.ts | 3 +- .../codex-local/src/server/codex-args.test.ts | 52 ++++------- .../codex-local/src/server/codex-args.ts | 20 +++-- .../src/server/execute.remote.test.ts | 15 +--- .../adapters/gemini-local/src/server/test.ts | 2 +- packages/adapters/opencode-local/src/index.ts | 4 +- .../src/server/runtime-config.test.ts | 11 ++- .../src/server/runtime-config.ts | 10 +-- .../opencode-local/src/server/test.ts | 2 +- .../claude-local-adapter-environment.test.ts | 14 ++- .../__tests__/claude-local-execute.test.ts | 7 +- .../gemini-local-adapter-environment.test.ts | 3 +- 20 files changed, 119 insertions(+), 220 deletions(-) diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts index 042e296f55..9fe21551b3 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -369,6 +369,29 @@ const ALLOWED_TURN_SPAN_ATTRIBUTE_KEYS = new Set([ ]); describe("shared ACPX engine runtime behavior", () => { + it.each(["claude", "codex", "gemini", "kimi", "custom"])("defaults the legacy %s engine to full auto on fresh and resumed runs", async (agent) => { + const root = await makeTempRoot(); + const config = { + agent, cwd: root, stateDir: path.join(root, "state"), + ...(agent === "custom" ? { agentCommand: "node ./fake-acp.js" } : {}), + }; + const first = await runExecutor(config); + const resumed = await runExecutor(config, { runtime: { sessionParams: first.result.sessionParams } }); + for (const run of [first, resumed]) { + expect(run.runtimeOptions[0]?.permissionMode).toBe("approve-all"); + expect(run.result.resultJson?.permissionMode).toBe("approve-all"); + } + }); + + it.each([ + ["default", "approve-all"], ["", "approve-all"], + ["approve-reads", "approve-reads"], ["deny-all", "deny-all"], + ])("resolves the legacy %j permission setting to %s", async (permissionMode, expected) => { + const root = await makeTempRoot(); + const run = await runExecutor({ agent: "custom", agentCommand: "node ./fake-acp.js", cwd: root, stateDir: path.join(root, "state"), permissionMode }); + expect(run.runtimeOptions[0]?.permissionMode).toBe(expected); + }); + it("persists ACP agent process identity before prompting on each run (host lane re-creates, no warm reuse)", async () => { const root = await makeTempRoot(); const startedAt = "2026-07-30T07:00:00.000Z"; diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index 12d5ff8bbd..509a8dfb16 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -1422,7 +1422,7 @@ function normalizeMode(config: Record): "persistent" | "oneshot function normalizePermissionMode(config: Record): "approve-all" | "approve-reads" | "deny-all" { const value = asString(config.permissionMode, DEFAULT_ACP_ENGINE_PERMISSION_MODE).trim(); if (value === "approve-reads" || value === "deny-all") return value; - if (value === "default") return "approve-reads"; + if (value === "default") return DEFAULT_ACP_ENGINE_PERMISSION_MODE; return "approve-all"; } diff --git a/packages/adapters/claude-local/src/index.ts b/packages/adapters/claude-local/src/index.ts index 873b9eba56..c18d78d1b5 100644 --- a/packages/adapters/claude-local/src/index.ts +++ b/packages/adapters/claude-local/src/index.ts @@ -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 diff --git a/packages/adapters/claude-local/src/server/acp.ts b/packages/adapters/claude-local/src/server/acp.ts index 6ea71807cf..e68a92f9ca 100644 --- a/packages/adapters/claude-local/src/server/acp.ts +++ b/packages/adapters/claude-local/src/server/acp.ts @@ -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( diff --git a/packages/adapters/claude-local/src/server/execute.remote.test.ts b/packages/adapters/claude-local/src/server/execute.remote.test.ts index 7f37595c90..c1e776d20c 100644 --- a/packages/adapters/claude-local/src/server/execute.remote.test.ts +++ b/packages/adapters/claude-local/src/server/execute.remote.test.ts @@ -193,11 +193,8 @@ describe("claude remote execution", () => { | [string, string, string[], { env: Record; 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`, diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 1d3863eb47..83209568c0 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -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 { - 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({}); }); }); diff --git a/packages/adapters/claude-local/src/server/permissions.ts b/packages/adapters/claude-local/src/server/permissions.ts index 10498d5cca..f8760de7c1 100644 --- a/packages/adapters/claude-local/src/server/permissions.ts +++ b/packages/adapters/claude-local/src/server/permissions.ts @@ -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 { + return input.dangerouslySkipPermissions && input.targetIsSandbox ? { IS_SANDBOX: "1" } : {}; } diff --git a/packages/adapters/claude-local/src/server/test.ts b/packages/adapters/claude-local/src/server/test.ts index a13577730f..8677ad1761 100644 --- a/packages/adapters/claude-local/src/server/test.ts +++ b/packages/adapters/claude-local/src/server/test.ts @@ -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; diff --git a/packages/adapters/codex-local/src/server/codex-args.test.ts b/packages/adapters/codex-local/src/server/codex-args.test.ts index 2d1e4fe91f..0cdfa266db 100644 --- a/packages/adapters/codex-local/src/server/codex-args.test.ts +++ b/packages/adapters/codex-local/src/server/codex-args.test.ts @@ -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", diff --git a/packages/adapters/codex-local/src/server/codex-args.ts b/packages/adapters/codex-local/src/server/codex-args.ts index ca5b3fde2d..6ebf8381b4 100644 --- a/packages/adapters/codex-local/src/server/codex-args.ts +++ b/packages/adapters/codex-local/src/server/codex-args.ts @@ -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}`); diff --git a/packages/adapters/codex-local/src/server/execute.remote.test.ts b/packages/adapters/codex-local/src/server/execute.remote.test.ts index 16345c8798..fe56c89283 100644 --- a/packages/adapters/codex-local/src/server/execute.remote.test.ts +++ b/packages/adapters/codex-local/src/server/execute.remote.test.ts @@ -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", "-", diff --git a/packages/adapters/gemini-local/src/server/test.ts b/packages/adapters/gemini-local/src/server/test.ts index 7e56862f8e..43572d7684 100644 --- a/packages/adapters/gemini-local/src/server/test.ts +++ b/packages/adapters/gemini-local/src/server/test.ts @@ -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 = (() => { diff --git a/packages/adapters/opencode-local/src/index.ts b/packages/adapters/opencode-local/src/index.ts index 615f425ead..ca121e9125 100644 --- a/packages/adapters/opencode-local/src/index.ts +++ b/packages/adapters/opencode-local/src/index.ts @@ -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. `; diff --git a/packages/adapters/opencode-local/src/server/runtime-config.test.ts b/packages/adapters/opencode-local/src/server/runtime-config.test.ts index 14791829a8..598330c52c 100644 --- a/packages/adapters/opencode-local/src/server/runtime-config.test.ts +++ b/packages/adapters/opencode-local/src/server/runtime-config.test.ts @@ -31,10 +31,12 @@ async function makeConfigHome(initialConfig?: Record) { } 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; 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; expect(runtimeConfig).toMatchObject({ - permission: { read: "allow", external_directory: "allow" }, + permission: "allow", provider: providers, }); expect(prepared.notes.some((n) => n.includes("bifrost"))).toBe(true); diff --git a/packages/adapters/opencode-local/src/server/runtime-config.ts b/packages/adapters/opencode-local/src/server/runtime-config.ts index b77aa7c8e9..bb51b4a8dc 100644 --- a/packages/adapters/opencode-local/src/server/runtime-config.ts +++ b/packages/adapters/opencode-local/src/server/runtime-config.ts @@ -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 = { ...existingConfig, - permission: { - ...existingPermission, - external_directory: "allow", - }, + permission: "allow", }; if (Object.keys(nextProvider).length > 0) { nextConfig.provider = nextProvider; diff --git a/packages/adapters/opencode-local/src/server/test.ts b/packages/adapters/opencode-local/src/server/test.ts index d73f8c9413..7f2480b9c9 100644 --- a/packages/adapters/opencode-local/src/server/test.ts +++ b/packages/adapters/opencode-local/src/server/test.ts @@ -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) | null = null; diff --git a/server/src/__tests__/claude-local-adapter-environment.test.ts b/server/src/__tests__/claude-local-adapter-environment.test.ts index a68b77cfa3..055e66d9ea 100644 --- a/server/src/__tests__/claude-local-adapter-environment.test.ts +++ b/server/src/__tests__/claude-local-adapter-environment.test.ts @@ -298,8 +298,8 @@ describe("claude_local environment diagnostics", () => { expect(result.checks.some((check) => check.code === "claude_cwd_invalid")).toBe(false); }); - it("uses --allowedTools instead of --dangerously-skip-permissions for sandbox hello probes", async () => { - const executeCalls: Array<{ command: string; args?: string[] }> = []; + it("uses full permission bypass for sandbox hello probes", async () => { + const executeCalls: Array<{ command: string; args?: string[]; env?: Record }> = []; const result = await testEnvironment({ companyId: "company-1", @@ -315,7 +315,7 @@ describe("claude_local environment diagnostics", () => { remoteCwd: "/workspace/paperclip", runner: { execute: async (input) => { - executeCalls.push({ command: input.command, args: input.args }); + executeCalls.push({ command: input.command, args: input.args, env: input.env }); if (input.command === "claude") { return { exitCode: 0, @@ -351,12 +351,10 @@ describe("claude_local environment diagnostics", () => { expect(result.checks.some((check) => check.code === "claude_hello_probe_passed")).toBe(true); const probeCall = executeCalls.find((call) => call.command === "claude"); - expect(probeCall?.args).not.toContain("--dangerously-skip-permissions"); + expect(probeCall?.args).toContain("--dangerously-skip-permissions"); expect(probeCall?.args).not.toContain("--permission-mode"); - // Sandbox probes pass `--allowedTools` so any tool invocation triggered - // by the probe prompt cannot stall waiting for an interactive permission - // approval that no human is present to answer. - expect(probeCall?.args).toContain("--allowedTools"); + expect(probeCall?.args).not.toContain("--allowedTools"); + expect(probeCall?.env?.IS_SANDBOX).toBe("1"); }); it("uses the managed Claude config seed for sandbox hello probes", async () => { diff --git a/server/src/__tests__/claude-local-execute.test.ts b/server/src/__tests__/claude-local-execute.test.ts index b2fd8f0c86..cd7450bd80 100644 --- a/server/src/__tests__/claude-local-execute.test.ts +++ b/server/src/__tests__/claude-local-execute.test.ts @@ -901,11 +901,8 @@ describe("claude execute", () => { }, }); const capture = JSON.parse(await fs.readFile(capturePath1, "utf8")) as CapturePayload; - expect(capture.argv).toContain("--allowedTools"); - expect(capture.argv).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(capture.argv).not.toContain("--dangerously-skip-permissions"); + expect(capture.argv).toContain("--dangerously-skip-permissions"); + expect(capture.argv).not.toContain("--allowedTools"); expect(capture.claudeConfigDir).toBe(path.join(remoteWorkspace, ".paperclip-runtime", "claude", "config")); expect(capture.claudeConfigEntries).toContain("settings.json"); expect(capture.paperclipApiUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); diff --git a/server/src/__tests__/gemini-local-adapter-environment.test.ts b/server/src/__tests__/gemini-local-adapter-environment.test.ts index e18b82763a..b3900964b9 100644 --- a/server/src/__tests__/gemini-local-adapter-environment.test.ts +++ b/server/src/__tests__/gemini-local-adapter-environment.test.ts @@ -68,7 +68,7 @@ describe("gemini_local environment diagnostics", () => { await fs.rm(path.dirname(cwd), { recursive: true, force: true }); }); - it("passes model and yolo flags to the hello probe", async () => { + it("passes model and default yolo flags to the hello probe", async () => { const root = path.join( os.tmpdir(), `paperclip-gemini-local-probe-${Date.now()}-${Math.random().toString(16).slice(2)}`, @@ -87,7 +87,6 @@ describe("gemini_local environment diagnostics", () => { command: "gemini", cwd, model: "gemini-2.5-pro", - yolo: true, env: { GEMINI_API_KEY: "test-key", PAPERCLIP_TEST_ARGS_PATH: argsCapturePath,