fix(runner): preserve Claude user settings trust in HOME (#17826)

This commit is contained in:
Nicky Leach authored and GitHub committed 2026-10-26 18:02:15 -07:00
1 parent baec3abd67
commit e61ee4b005
3 files changed
+61 -3

No files matched your search

+4 -1
View File
@@ -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
@@ -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");
});
@@ -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) {