diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index 243b7928bba..51823ae1d55 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -818,7 +818,10 @@ rules and hooks apply. Native Codex tools can read and modify the execution home (read-only for planning); credentials in that home are not isolated from tools. Native Codex separately denies Runner-owned protocol state; ACPX Claude retains its existing provider trust boundary. Execution is unattended: native Codex uses `never`, and -ACPX rejects requests for approval. The initial ACPX mode requires the Runner +ACPX rejects requests for approval. When Claude starts in its home directory, +its native user settings retain user trust even if the SDK also reports that +same file as project settings. Other project settings keep the native trust filter. +The initial ACPX mode requires the Runner setting `acpxPermissionMode: "approve-all"` (its default); other Runner modes are unqualified here and fail before launch. Local permission rules still apply. Preconfigured ACPX read-only/planning tasks also fail before provider launch diff --git a/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs b/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs index 3cd71aa3198..c70486fbdac 100644 --- a/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs +++ b/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs @@ -1,8 +1,11 @@ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; -import { readFile } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; -import { dirname, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { pathToFileURL } from "node:url"; import test from "node:test"; const runnerPackage = JSON.parse( @@ -217,3 +220,39 @@ test("the Claude patch removes ambient project and local configuration", () => { ); } }); + +test("Claude preserves user permission modes when its working directory is home", async (t) => { + const packagePath = createRequire(import.meta.url).resolve("@agentclientprotocol/claude-agent-acp/package.json"); + const settingsModule = pathToFileURL(resolve(dirname(packagePath), "dist/settings.js")).href; + const root = await mkdtemp(join(tmpdir(), "claude-settings-origin-")); + t.after(() => rm(root, { recursive: true, force: true })); + const home = join(root, "home"); + const project = join(root, "project"); + await mkdir(join(home, ".claude"), { recursive: true }); + await mkdir(join(project, ".claude"), { recursive: true }); + const userFile = join(home, ".claude/settings.json"); + const env = { ...process.env, HOME: home }; + delete env.CLAUDE_CONFIG_DIR; + delete env.PAPERCLIP_ACPX_ISOLATED_CONTEXT; + const inspect = (cwd) => JSON.parse(execFileSync(process.execPath, ["--input-type=module", "-e", ` + import { SettingsManager } from ${JSON.stringify(settingsModule)}; + const manager = new SettingsManager(${JSON.stringify(cwd)}); + try { + await manager.initialize(); + console.log(JSON.stringify(manager.getSettings())); + } finally { manager.dispose(); } + `], { env, encoding: "utf8", timeout: 10_000 })); + for (const defaultMode of ["bypassPermissions", "acceptEdits", "auto", "plan", "default"]) { + const content = JSON.stringify({ permissions: { defaultMode, deny: ["Read(./private)"] } }); + await writeFile(userFile, content); + assert.deepEqual(inspect(home).permissions, { defaultMode, deny: ["Read(./private)"] }); + assert.equal(await readFile(userFile, "utf8"), content, "discovery does not rewrite the profile"); + } + await writeFile(userFile, JSON.stringify({ permissions: { defaultMode: "bypassPermissions" } })); + for (const defaultMode of ["bypassPermissions", "acceptEdits", "auto"]) { + await writeFile(join(project, ".claude/settings.json"), JSON.stringify({ permissions: { defaultMode } })); + assert.equal(inspect(project).permissions?.defaultMode, undefined, "real project escalation stays filtered"); + } + await writeFile(join(home, ".claude/settings.local.json"), JSON.stringify({ permissions: { defaultMode: "plan" } })); + assert.equal(inspect(home).permissions.defaultMode, "plan", "local restrictions still win"); +}); diff --git a/patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch b/patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch index 6774dc6553c..7a16bb7c0f2 100644 --- a/patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch +++ b/patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch @@ -64,3 +64,19 @@ index 0214a509eb5e7b777a69e4e34637941c275d27b1..403be02df66e25eee29e7d5bef146270 // Forward MCP elicitation requests onto ACP elicitation. Only attached // when the client advertised support, so non-supporting clients keep the // SDK's default (auto-decline) behavior. (AskUserQuestion is handled in +diff --git a/dist/settings.js b/dist/settings.js +--- a/dist/settings.js ++++ b/dist/settings.js +@@ -88,6 +88,12 @@ + async loadAllSettings() { + try { + const resolved = await resolveSettings({ cwd: this.cwd }); ++ const userPath = resolved.sources.find((entry) => entry.source === "user")?.path; ++ for (const entry of resolved.sources) { ++ if (userPath && entry.source === "project" && entry.path === userPath) { ++ entry.source = "user"; ++ } ++ } + this.effective = filterEscalatingDefaultMode(resolved); + } + catch (error) {