fix(claude-local): start approve-all sandbox ACP sessions in bypassPermissions (#19430)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The `claude_local` adapter runs Claude through the ACP engine
(claude-agent-acp) inside remote sandboxes
> - Paperclip seeds the sandbox Claude settings with
`permissions.defaultMode: "default"`, so Claude asks acpx for permission
before every MCP call and every Bash call that is not read-only
> - In the default `approve-all` mode, acpx approves every request. The
permission round trip gives no access control, but it is the step where
tool calls stall
> - Intermittently, the Claude CLI never sends that permission request.
The call stays `pending` until the 30-minute stalled-tool watchdog ends
the turn (`acpx_tool_stalled`)
> - This pull request starts approve-all sandbox sessions in
`bypassPermissions`, so Claude does not use the permission round trip at
all
> - The benefit is that sandbox runs no longer lose about 35 minutes per
stall, and `approve-reads` and `deny-all` agents keep their current
permission behavior

## Linked Issues or Issue Description

Refs #16688
Refs #16769

**What happened?**
Sandbox `claude_local` ACP runs ended with `acpx_tool_stalled`: "A
pending tool call (kind other) received no update for 30 minutes, so the
run ended the turn instead of waiting for the execution timeout." The
diagnostics from #16688 and #16769 show the shape. The stalled call got
its `pending` update and its input updates. Then its permission request
never reached acpx. Finished calls of the same kind in the same run did
send a request. The stall occurred on MCP calls and on Bash calls, in
the main agent and in background subagents. Calls that need no
permission (ToolSearch, read-only Bash) never stalled.

**Expected behavior**
In `approve-all` mode, a tool call must not wait on a permission round
trip that acpx always approves.

**Steps to reproduce**
1. Run a `claude_local` agent with the ACP engine on a sandbox target,
with the default `approve-all` permission mode.
2. Make many MCP or network Bash calls during the turn.
3. Sometimes a call stays `pending` with no permission request. The
watchdog then ends the turn after 30 minutes.

**Agent adapter(s) involved**
Claude Code (`claude_local`, ACP engine, sandbox target).

## What Changed

- `claude-config.ts`: `prepareClaudeConfigSeed` accepts
`permissionDefaultMode` (`"default"` or `"bypassPermissions"`). The
sanitized seed writes that value into `permissions.defaultMode`. In
bypass mode the seed writes `settings.json` also when the host has no
`settings.json`. Hooks, MCP servers and host permission rules are still
removed.
- `claude-config.ts`: `overrideRemoteClaudeLocalPermissionMode` sets
`bypassPermissions` in the **sandbox copy** of the workspace
`.claude/settings.local.json`. The engine writes that file with
`defaultMode: "default"`, and the local tier overrides the user seed.
Before workspace copy-back, the override is reset in the sandbox, so
copy-back never treats it as an agent edit. After copy-back, if the host
file still has `bypassPermissions`, only `defaultMode` is put back. Host
and agent edits to other settings are kept.
- `acp.ts`: the sandbox managed-home seam reads the run's effective ACPX
permission mode. If the mode is `approve-all`, the seam seeds
`bypassPermissions`, applies the workspace override above, and sets
`IS_SANDBOX=1` through the existing `claudeSandboxPermissionEnv` helper.
This lets the bypass apply when the sandbox user is root, the same rule
as the CLI lane. The mode resolution is the same as the engine's: an
absent value or `"default"` becomes `approve-all`. An unrecognized value
keeps `"default"`.
- These keep `defaultMode: "default"` and do not change:
`approve-reads`, `deny-all`, the CLI lane, the Test probes, managed AI
connections, and an operator `CLAUDE_CONFIG_DIR`.
- Tests: two seed tests in `claude-config.test.ts`. In `acp.test.ts`: an
end-to-end executor test for five permission modes (absent,
`approve-all`, `default`, `approve-reads`, `deny-all`) that checks the
mode Claude actually gets during the turn and that the host settings
keep `default`, and a test that host and agent edits to the workspace
settings survive copy-back without `bypassPermissions`.

## Verification

- `pnpm exec vitest run packages/adapters/claude-local`: 393 passed, 1
skipped (before the copy-back commits); full CI is green on the current
head.
- Regression proof: with the new tests and the old `acp.ts` and
`claude-config.ts`, the seed tests and the absent, `approve-all` and
`default` cases fail (`expected 'default' to be 'bypassPermissions'`).
The `approve-reads` and `deny-all` cases pass on both versions, because
their behavior does not change.
- `pnpm exec tsc --noEmit` in `packages/adapters/claude-local`: clean.

## Risks

- Behavior change for sandbox `claude_local` ACP runs in `approve-all`
mode (the default): Claude does not ask permission for tools. Paperclip
already approves every request in this mode, so the access outcome does
not change. Claude's bypass-immune safety checks still go through
`canUseTool`.
- The seed directory is content-addressed, so the bypass seed and the
default seed are different snapshots and do not overwrite each other.
- Agents that set `approve-reads` or `deny-all` keep the permission
round trip. The stall can still occur for them.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code
execution in an agent harness.

## 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
- [ ] 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

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Devin FoleyandClaude Opus 5.5 authored and GitHub committed 2026-11-06 06:44:43 -08:00
1 parent d87f6fa4c2
commit 65c5d8d600
4 files changed
+356 -15

No files matched your search

@@ -919,6 +919,163 @@ describe("claude_local ACP lane", () => {
expect((await fs.stat(toolShellPrefix)).mode & 0o777).toBe(0o700);
});
it.each([
{ permissionMode: undefined, hostSettings: true, defaultMode: "bypassPermissions", isSandbox: "1" },
{ permissionMode: "approve-all", hostSettings: false, defaultMode: "bypassPermissions", isSandbox: "1" },
{ permissionMode: "default", hostSettings: true, defaultMode: "bypassPermissions", isSandbox: "1" },
{ permissionMode: "approve-reads", hostSettings: true, defaultMode: "default", isSandbox: undefined },
{ permissionMode: "deny-all", hostSettings: true, defaultMode: "default", isSandbox: undefined },
])(
"starts sandbox Claude in $defaultMode mode when the ACPX permission mode is $permissionMode",
async ({ permissionMode, hostSettings, defaultMode, isSandbox }) => {
const root = await makeTempRoot("paperclip-claude-acp-permission-mode-");
const localCwd = path.join(root, "worktree");
const remoteCwd = path.join(root, "remote-workspace");
const sharedClaudeConfig = path.join(root, "shared-claude-config");
await fs.mkdir(localCwd, { recursive: true });
await fs.mkdir(remoteCwd, { recursive: true });
await fs.mkdir(sharedClaudeConfig, { recursive: true });
if (hostSettings) {
await fs.writeFile(
path.join(sharedClaudeConfig, "settings.json"),
JSON.stringify({ theme: "dark", permissions: { defaultMode: "acceptEdits", allow: ["Bash(*)"] } }),
"utf8",
);
}
process.env.PAPERCLIP_HOME = path.join(root, "paperclip-home");
process.env.PAPERCLIP_INSTANCE_ID = "test";
process.env.CLAUDE_CONFIG_DIR = sharedClaudeConfig;
const meta: AdapterInvocationMeta[] = [];
let effectiveDefaultMode: unknown;
const runtime = new FakeRuntime({});
const startTurn = runtime.startTurn.bind(runtime);
runtime.startTurn = (turnInput) => {
const turn = startTurn(turnInput);
return {
...turn,
result: (async () => {
const readSettings = async (file: string) =>
JSON.parse(await fs.readFile(file, "utf8").catch(() => "{}"));
const local = await readSettings(path.join(turnInput.handle.cwd ?? remoteCwd, ".claude", "settings.local.json"));
const user = await readSettings(path.join(String(meta[0]?.env?.CLAUDE_CONFIG_DIR ?? ""), "settings.json"));
effectiveDefaultMode = local.permissions?.defaultMode ?? user.permissions?.defaultMode;
return await turn.result;
})(),
};
};
const execute = createClaudeAcpExecutor({
createRuntime: (options: FakeRuntimeOptions) => {
Object.assign(runtime.options, options);
return runtime as never;
},
});
const result = await execute(
buildContext(localCwd, {
config: {
engine: "acp",
cwd: localCwd,
agentCommand: "node ./fake-acp.js",
stateDir: path.join(root, "state"),
promptTemplate: "Do the assigned work.",
...(permissionMode ? { permissionMode } : {}),
},
context: {
issueId: "issue-1",
paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" },
},
executionTarget: {
kind: "remote",
transport: "sandbox",
providerKey: "fake-plugin",
remoteCwd,
runner: createLocalSandboxRunner(),
} as never,
authToken: "real-run-jwt",
onMeta: async (payload: AdapterInvocationMeta) => {
meta.push(payload);
},
}),
);
expect(result.exitCode).toBe(0);
const remoteConfigDir = String(meta[0]?.env?.CLAUDE_CONFIG_DIR ?? "");
const remoteSettings = JSON.parse(await fs.readFile(path.join(remoteConfigDir, "settings.json"), "utf8"));
expect(remoteSettings.permissions).toEqual({ defaultMode });
expect(remoteSettings.theme).toBe(hostSettings ? "dark" : undefined);
expect(meta[0]?.env?.IS_SANDBOX).toBe(isSandbox);
expect(effectiveDefaultMode).toBe(defaultMode);
const hostLocalSettings = JSON.parse(await fs.readFile(path.join(localCwd, ".claude", "settings.local.json"), "utf8"));
expect(hostLocalSettings.permissions.defaultMode).toBe("default");
},
);
it.each([
{ name: "a host edit during the run", editHost: true },
{ name: "an agent edit in the sandbox", editHost: false },
])("keeps $name to the workspace Claude settings and never copies bypassPermissions home", async ({ editHost }) => {
const root = await makeTempRoot("paperclip-claude-acp-local-settings-");
const localCwd = path.join(root, "worktree");
const remoteCwd = path.join(root, "remote-workspace");
await fs.mkdir(localCwd, { recursive: true });
await fs.mkdir(remoteCwd, { recursive: true });
process.env.PAPERCLIP_HOME = path.join(root, "paperclip-home");
process.env.PAPERCLIP_INSTANCE_ID = "test";
process.env.CLAUDE_CONFIG_DIR = path.join(root, "shared-claude-config");
const hostSettingsPath = path.join(localCwd, ".claude", "settings.local.json");
const runtime = new FakeRuntime({});
const startTurn = runtime.startTurn.bind(runtime);
runtime.startTurn = (turnInput) => {
const turn = startTurn(turnInput);
return {
...turn,
result: (async () => {
const target = editHost
? hostSettingsPath
: path.join(turnInput.handle.cwd ?? remoteCwd, ".claude", "settings.local.json");
const current = JSON.parse(await fs.readFile(target, "utf8"));
await fs.writeFile(target, JSON.stringify({ ...current, theme: "edited" }), "utf8");
return await turn.result;
})(),
};
};
const execute = createClaudeAcpExecutor({
createRuntime: (options: FakeRuntimeOptions) => {
Object.assign(runtime.options, options);
return runtime as never;
},
});
const result = await execute(
buildContext(localCwd, {
config: {
engine: "acp",
cwd: localCwd,
agentCommand: "node ./fake-acp.js",
stateDir: path.join(root, "state"),
promptTemplate: "Do the assigned work.",
},
context: {
issueId: "issue-1",
paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" },
},
executionTarget: {
kind: "remote",
transport: "sandbox",
providerKey: "fake-plugin",
remoteCwd,
runner: createLocalSandboxRunner(),
} as never,
authToken: "real-run-jwt",
}),
);
expect(result.exitCode).toBe(0);
const hostSettings = JSON.parse(await fs.readFile(hostSettingsPath, "utf8"));
expect(hostSettings.theme).toBe("edited");
expect(hostSettings.permissions.defaultMode).toBe("default");
});
it("keeps an operator CLAUDE_CODE_SHELL_PREFIX and installs no tool shell prefix", async () => {
const root = await makeTempRoot("paperclip-claude-acp-operator-prefix-");
const localCwd = path.join(root, "worktree");
@@ -42,6 +42,7 @@ import {
} from "@paperclipai/adapter-utils/server-utils";
import {
materializeRemoteClaudeConfig,
overrideRemoteClaudeLocalPermissionMode,
prepareClaudeConfigSeed,
prepareSandboxClaudeProbeRuntime,
resolveRemoteClaudeToolShellPrefixPath,
@@ -122,6 +123,11 @@ function firstNonEmptyString(...values: unknown[]): string | undefined {
return undefined;
}
function resolveAcpPermissionMode(config: Record<string, unknown>): string {
const requested = firstNonEmptyString(config.permissionMode);
return requested === undefined || requested === "default" ? DEFAULT_ACP_ENGINE_PERMISSION_MODE : requested;
}
export function buildClaudeAcpConfig(
config: Record<string, unknown>,
inheritedEnv: Record<string, unknown> = {},
@@ -271,9 +277,12 @@ async function prepareClaudeRemoteManagedHome(
// Content-addressed sanitized seed (managed cache under the instance root, not
// a temp dir — reused across runs, so no teardown cleanup).
const bypassPermissions = resolveAcpPermissionMode(input.config) === "approve-all";
const claudeConfigSeedDir = input.config.managedAiConnection
? explicitClaudeConfigDir
: await prepareClaudeConfigSeed(process.env, onLog, input.companyId);
: await prepareClaudeConfigSeed(process.env, onLog, input.companyId, {
permissionDefaultMode: bypassPermissions ? "bypassPermissions" : "default",
});
// Ship the per-run skill bundle, staged only when the run selected at
// least one skill. The bundle directory holds a plain copy of each
// selected skill's files (`materializePaperclipSkillCopy` never copies a
@@ -316,10 +325,38 @@ async function prepareClaudeRemoteManagedHome(
});
// Repoint CLAUDE_CONFIG_DIR onto the in-sandbox config dir.
env.CLAUDE_CONFIG_DIR = remoteClaudeConfigDir;
const localPermissionOverride = bypassPermissions && !input.config.managedAiConnection
? await overrideRemoteClaudeLocalPermissionMode({
runId,
target: executionTarget,
workspaceLocalDir: input.workspaceLocalDir,
workspaceRemoteDir: stagedRuntime.workspaceRemoteDir ?? input.workspaceLocalDir,
defaultMode: "bypassPermissions",
options: {
cwd: stagedRuntime.workspaceRemoteDir ?? input.workspaceLocalDir,
env,
timeoutSec: Math.max(input.timeoutSec, 15),
graceSec: 20,
onLog,
},
})
: null;
if (bypassPermissions && !input.config.managedAiConnection) {
Object.assign(env, claudeSandboxPermissionEnv({ dangerouslySkipPermissions: true, targetIsSandbox: true }));
}
if (toolShellPrefixInstalled && remoteToolShellPrefixPath) {
env.CLAUDE_CODE_SHELL_PREFIX = remoteToolShellPrefixPath;
}
return { stagedRuntime, teardown: registerWorkspaceSyncBack(stagedRuntime) };
const syncBack = registerWorkspaceSyncBack(stagedRuntime);
const teardown: AcpxRemoteManagedHomeResult["teardown"] = localPermissionOverride && syncBack
? async () => {
await localPermissionOverride.beforeRestore();
const outcome = await syncBack();
await localPermissionOverride.afterRestore();
return outcome;
}
: syncBack;
return { stagedRuntime, teardown };
}
export function classifyClaudeTerminalSessionFailure(
@@ -101,6 +101,50 @@ describe("prepareClaudeConfigSeed", () => {
.resolves.toBe(JSON.stringify({ theme: "dark", permissions: { defaultMode: "default" } }));
});
it("seeds bypassPermissions only when it is requested", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-config-bypass-"));
cleanupDirs.push(root);
const sourceDir = path.join(root, "claude-source");
await fs.mkdir(sourceDir, { recursive: true });
await fs.writeFile(path.join(sourceDir, "settings.json"), JSON.stringify({
theme: "light",
permissions: { defaultMode: "acceptEdits", allow: ["Bash(*)"] },
hooks: { PreToolUse: [{ matcher: "*" }] },
}), "utf8");
const onLog = vi.fn(async () => {});
const env = createEnv(root, sourceDir);
const defaultSeed = await prepareClaudeConfigSeed(env, onLog, "company-1");
const bypassSeed = await prepareClaudeConfigSeed(env, onLog, "company-1", {
permissionDefaultMode: "bypassPermissions",
});
expect(bypassSeed).not.toBe(defaultSeed);
await expect(fs.readFile(path.join(defaultSeed, "settings.json"), "utf8"))
.resolves.toBe(JSON.stringify({ theme: "light", permissions: { defaultMode: "default" } }));
await expect(fs.readFile(path.join(bypassSeed, "settings.json"), "utf8"))
.resolves.toBe(JSON.stringify({ theme: "light", permissions: { defaultMode: "bypassPermissions" } }));
});
it("writes a bypassPermissions settings file when the host has none", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-config-bypass-empty-"));
cleanupDirs.push(root);
const sourceDir = path.join(root, "claude-source");
await fs.mkdir(sourceDir, { recursive: true });
const onLog = vi.fn(async () => {});
const env = createEnv(root, sourceDir);
const defaultSeed = await prepareClaudeConfigSeed(env, onLog, "company-1");
const bypassSeed = await prepareClaudeConfigSeed(env, onLog, "company-1", {
permissionDefaultMode: "bypassPermissions",
});
await expect(fs.access(path.join(defaultSeed, "settings.json")))
.rejects.toMatchObject({ code: "ENOENT" });
await expect(fs.readFile(path.join(bypassSeed, "settings.json"), "utf8"))
.resolves.toBe(JSON.stringify({ permissions: { defaultMode: "bypassPermissions" } }));
});
it("strips local-only settings from remote Claude config seeds", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-config-boundary-"));
cleanupDirs.push(root);
@@ -41,20 +41,25 @@ function isAlreadyExistsError(error: unknown): boolean {
return code === "EEXIST" || code === "ENOTEMPTY";
}
function sanitizeRemoteClaudeSettings(raw: string): string {
export type RemoteClaudePermissionDefaultMode = "default" | "bypassPermissions";
function sanitizeRemoteClaudeSettings(
raw: string | null,
defaultMode: RemoteClaudePermissionDefaultMode,
): string {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
parsed = raw === null ? null : JSON.parse(raw);
} catch {
return JSON.stringify({ permissions: { defaultMode: "default" } });
return JSON.stringify({ permissions: { defaultMode } });
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return JSON.stringify({ permissions: { defaultMode: "default" } });
return JSON.stringify({ permissions: { defaultMode } });
}
const settings = { ...(parsed as Record<string, unknown>) };
settings.permissions = { defaultMode: "default" };
settings.permissions = { defaultMode };
delete settings.hooks;
delete settings.mcpServers;
delete settings.permissionMode;
@@ -62,16 +67,22 @@ function sanitizeRemoteClaudeSettings(raw: string): string {
return JSON.stringify(settings);
}
async function collectSeedFiles(sourceDir: string): Promise<SeedFile[]> {
async function collectSeedFiles(
sourceDir: string,
defaultMode: RemoteClaudePermissionDefaultMode,
): Promise<SeedFile[]> {
const files: SeedFile[] = [];
for (const name of SEEDED_SHARED_FILES) {
const sourcePath = path.join(sourceDir, name);
if (!(await pathExists(sourcePath))) continue;
const rawContents = await fs.readFile(sourcePath);
const contents = name === "settings.json"
? Buffer.from(sanitizeRemoteClaudeSettings(rawContents.toString("utf8")), "utf8")
: rawContents;
files.push({ name, sourcePath, contents });
const exists = await pathExists(sourcePath);
if (name === "settings.json") {
if (!exists && defaultMode === "default") continue;
const raw = exists ? (await fs.readFile(sourcePath)).toString("utf8") : null;
files.push({ name, sourcePath, contents: Buffer.from(sanitizeRemoteClaudeSettings(raw, defaultMode), "utf8") });
continue;
}
if (!exists) continue;
files.push({ name, sourcePath, contents: await fs.readFile(sourcePath) });
}
return files;
}
@@ -187,6 +198,7 @@ export async function prepareClaudeConfigSeed(
env: NodeJS.ProcessEnv,
onLog: AdapterExecutionContext["onLog"],
companyId?: string,
options: { permissionDefaultMode?: RemoteClaudePermissionDefaultMode } = {},
): Promise<string> {
const sourceDir = resolveSharedClaudeConfigDir(env);
const targetRootDir = resolveManagedClaudeConfigSeedDir(env, companyId);
@@ -195,7 +207,7 @@ export async function prepareClaudeConfigSeed(
return targetRootDir;
}
const copiedFiles = await collectSeedFiles(sourceDir);
const copiedFiles = await collectSeedFiles(sourceDir, options.permissionDefaultMode ?? "default");
const snapshotKey = await buildSeedSnapshotKey(copiedFiles);
const targetDir = await materializeSeedSnapshot({
rootDir: targetRootDir,
@@ -295,6 +307,97 @@ export async function materializeRemoteClaudeConfig(input: {
};
}
type ClaudeLocalSettings = { settings: Record<string, unknown>; permissions: Record<string, unknown> };
function parseClaudeLocalSettings(raw: string | null): ClaudeLocalSettings | null {
if (raw === null) return null;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
const settings = parsed as Record<string, unknown>;
const permissions =
settings.permissions && typeof settings.permissions === "object" && !Array.isArray(settings.permissions)
? (settings.permissions as Record<string, unknown>)
: {};
return { settings, permissions };
}
function withClaudeLocalDefaultMode(local: ClaudeLocalSettings, defaultMode: unknown): string {
const permissions = { ...local.permissions };
if (defaultMode === undefined) delete permissions.defaultMode;
else permissions.defaultMode = defaultMode;
return `${JSON.stringify({ ...local.settings, permissions }, null, 2)}\n`;
}
export interface RemoteClaudeLocalPermissionOverride {
beforeRestore: () => Promise<void>;
afterRestore: () => Promise<void>;
}
export async function overrideRemoteClaudeLocalPermissionMode(input: {
runId: string;
target: AdapterExecutionTarget | null | undefined;
workspaceLocalDir: string;
workspaceRemoteDir: string;
defaultMode: RemoteClaudePermissionDefaultMode;
options: AdapterExecutionTargetShellOptions;
}): Promise<RemoteClaudeLocalPermissionOverride | null> {
const localPath = path.join(input.workspaceLocalDir, ".claude", "settings.local.json");
const original = await fs.readFile(localPath, "utf8").catch(() => null);
const originalLocal = parseClaudeLocalSettings(original);
if (original === null || !originalLocal) return null;
const originalMode = originalLocal.permissions.defaultMode;
if (originalMode === input.defaultMode) return null;
const overridden = withClaudeLocalDefaultMode(originalLocal, input.defaultMode);
const remotePath = shellQuote(path.posix.join(input.workspaceRemoteDir, ".claude", "settings.local.json"));
const quietOptions: AdapterExecutionTargetShellOptions = { ...input.options, onLog: async () => {} };
const writeRemote = (contents: string) =>
runAdapterExecutionTargetShellCommand(
input.runId,
input.target,
`if [ -f ${remotePath} ]; then printf '%s' ${shellQuote(contents)} > ${remotePath}; fi`,
quietOptions,
);
const applied = await writeRemote(overridden);
if (applied.exitCode !== 0 || applied.timedOut) {
await input.options.onLog?.("stderr", "[paperclip] Could not apply the Claude permission mode to the sandbox workspace settings.\n");
}
return {
beforeRestore: async () => {
try {
const read = await runAdapterExecutionTargetShellCommand(
input.runId,
input.target,
`cat ${remotePath}`,
quietOptions,
);
if (read.exitCode !== 0 || read.timedOut) return;
if (read.stdout === overridden) {
await writeRemote(original);
return;
}
const current = parseClaudeLocalSettings(read.stdout);
if (current?.permissions.defaultMode === input.defaultMode) {
await writeRemote(withClaudeLocalDefaultMode(current, originalMode));
}
} catch {
await input.options.onLog?.("stderr", "[paperclip] Could not reset the Claude permission mode in the sandbox workspace settings.\n");
}
},
afterRestore: async () => {
const current = parseClaudeLocalSettings(await fs.readFile(localPath, "utf8").catch(() => null));
if (current?.permissions.defaultMode !== input.defaultMode) return;
await fs.writeFile(localPath, withClaudeLocalDefaultMode(current, originalMode)).catch(async () => {
await input.options.onLog?.("stderr", "[paperclip] Could not restore the host Claude workspace settings after the sandbox run.\n");
});
},
};
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}