feat: Use SDK settings resolution for defaults (#677)

This commit is contained in:
Ben Brandt
2026-05-16 23:08:50 +02:00
committed by GitHub
parent 5ff7d50f0e
commit eb1259cf88
6 changed files with 132 additions and 182 deletions

View File

@@ -1,7 +1,6 @@
#!/usr/bin/env node
// Load managed settings and apply environment variables
import { loadManagedSettings, applyEnvironmentSettings } from "./utils.js";
import { resolveSettings } from "@anthropic-ai/claude-agent-sdk";
import { claudeCliPath, runAcp } from "./acp-agent.js";
if (process.argv.includes("--cli")) {
@@ -35,9 +34,13 @@ if (process.argv.includes("--cli")) {
process.exit(1);
});
} else {
const managedSettings = loadManagedSettings();
if (managedSettings) {
applyEnvironmentSettings(managedSettings);
// Apply env vars from the managed-policy tier before any SDK call so the
// SDK subprocess inherits them. Going through resolveSettings (vs. a raw
// read of managed-settings.json) also picks up MDM sources on macOS and
// HKLM/HKCU on Windows.
const policy = await resolveSettings({ settingSources: [] });
for (const [key, value] of Object.entries(policy.effective.env ?? {})) {
process.env[key] = value;
}
// stdout is used to send messages to the client

View File

@@ -10,25 +10,14 @@ export {
type NewSessionMeta,
type SDKMessageFilter,
} from "./acp-agent.js";
export {
loadManagedSettings,
applyEnvironmentSettings,
nodeToWebReadable,
nodeToWebWritable,
Pushable,
unreachable,
} from "./utils.js";
export { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
export {
toolInfoFromToolUse,
toDisplayPath,
planEntries,
toolUpdateFromToolResult,
} from "./tools.js";
export {
SettingsManager,
type ClaudeCodeSettings,
type SettingsManagerOptions,
} from "./settings.js";
export { SettingsManager, type SettingsManagerOptions } from "./settings.js";
// Export types
export type { ClaudePlanEntry } from "./tools.js";

View File

@@ -1,5 +1,12 @@
import * as fs from "node:fs";
import * as path from "node:path";
// resolveSettings and filterEscalatingDefaultMode are marked @alpha in the
// SDK; API may shift in a future release.
import {
filterEscalatingDefaultMode,
resolveSettings,
type Settings,
} from "@anthropic-ai/claude-agent-sdk";
import { CLAUDE_CONFIG_DIR } from "./acp-agent.js";
/**
@@ -14,52 +21,10 @@ import { CLAUDE_CONFIG_DIR } from "./acp-agent.js";
* Docs: https://code.claude.com/docs/en/iam#tool-specific-permission-rules
*/
export interface PermissionSettings {
defaultMode?: string;
}
export interface ClaudeCodeSettings {
permissions?: PermissionSettings;
env?: Record<string, string>;
model?: string;
effortLevel?: string;
availableModels?: string[];
}
/**
* Reads and parses a JSON settings file, returning an empty object if not found or invalid.
* Silently ignores missing files (ENOENT) but logs warnings for other errors
* (malformed JSON, permission errors, etc.) to aid debugging.
*/
async function loadSettingsFile(
filePath: string | null,
logger?: { error: (...args: any[]) => void },
): Promise<ClaudeCodeSettings> {
if (!filePath) {
return {};
}
try {
const content = await fs.promises.readFile(filePath, "utf-8");
return JSON.parse(content) as ClaudeCodeSettings;
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
return {};
}
logger?.error(`Failed to load settings from ${filePath}:`, error);
return {};
}
}
/**
* Gets the enterprise settings path based on the current platform
*/
export function getManagedSettingsPath(): string {
function getManagedSettingsPath(): string {
switch (process.platform) {
case "darwin":
return "/Library/Application Support/ClaudeCode/managed-settings.json";
case "linux":
return "/etc/claude-code/managed-settings.json";
case "win32":
return "C:\\Program Files\\ClaudeCode\\managed-settings.json";
default:
@@ -73,23 +38,17 @@ export interface SettingsManagerOptions {
}
/**
* Manages Claude Code settings from multiple sources with proper precedence.
* Manages Claude Code settings using the SDK's `resolveSettings` merge engine
* so the values we see match what `query()` would observe.
*
* Settings are loaded from (in order of increasing precedence):
* 1. User settings (~/.claude/settings.json)
* 2. Project settings (<cwd>/.claude/settings.json)
* 3. Local project settings (<cwd>/.claude/settings.local.json)
* 4. Enterprise managed settings (platform-specific path)
*
* The manager watches all settings files for changes and automatically reloads.
* Watches the user/project/local/managed settings files for changes and
* re-resolves through the SDK on update. Escalating `permissions.defaultMode`
* values from repo-committed sources are filtered out via
* `filterEscalatingDefaultMode`, matching the CLI's trust policy.
*/
export class SettingsManager {
private cwd: string;
private userSettings: ClaudeCodeSettings = {};
private projectSettings: ClaudeCodeSettings = {};
private localSettings: ClaudeCodeSettings = {};
private enterpriseSettings: ClaudeCodeSettings = {};
private mergedSettings: ClaudeCodeSettings = {};
private effective: Settings = {};
private watchers: fs.FSWatcher[] = [];
private onChange?: () => void;
private logger: { log: (...args: any[]) => void; error: (...args: any[]) => void };
@@ -127,107 +86,37 @@ export class SettingsManager {
}
/**
* Returns the path to the user settings file
* Paths the SDK reads when resolving settings for this cwd. Watching the
* containing directories means we pick up file creation as well as edits.
*/
private getUserSettingsPath(): string {
return path.join(CLAUDE_CONFIG_DIR, "settings.json");
private getWatchedPaths(): string[] {
return [
path.join(CLAUDE_CONFIG_DIR, "settings.json"),
path.join(this.cwd, ".claude", "settings.json"),
path.join(this.cwd, ".claude", "settings.local.json"),
getManagedSettingsPath(),
];
}
/**
* Returns the path to the project settings file
*/
private getProjectSettingsPath(): string {
return path.join(this.cwd, ".claude", "settings.json");
}
/**
* Returns the path to the local project settings file
*/
private getLocalSettingsPath(): string {
return path.join(this.cwd, ".claude", "settings.local.json");
}
/**
* Loads settings from all sources
* Resolves the effective settings via the SDK and applies the CLI's trust
* filter for escalating `permissions.defaultMode` values.
*/
private async loadAllSettings(): Promise<void> {
const [userSettings, projectSettings, localSettings, enterpriseSettings] = await Promise.all([
loadSettingsFile(this.getUserSettingsPath(), this.logger),
loadSettingsFile(this.getProjectSettingsPath(), this.logger),
loadSettingsFile(this.getLocalSettingsPath(), this.logger),
loadSettingsFile(getManagedSettingsPath(), this.logger),
]);
this.userSettings = userSettings;
this.projectSettings = projectSettings;
this.localSettings = localSettings;
this.enterpriseSettings = enterpriseSettings;
this.mergeSettings();
}
/**
* Merges all settings sources with proper precedence.
* For permissions, rules from all sources are combined.
* Deny rules always take precedence during permission checks.
*/
private mergeSettings(): void {
const allSettings = [
this.userSettings,
this.projectSettings,
this.localSettings,
this.enterpriseSettings,
];
const merged: ClaudeCodeSettings = {};
for (const settings of allSettings) {
if (settings.env) {
merged.env = { ...merged.env, ...settings.env };
}
if (settings.model) {
merged.model = settings.model;
}
if (settings.effortLevel !== undefined) {
merged.effortLevel = settings.effortLevel;
}
if (settings.availableModels !== undefined) {
// Per Claude Code docs: "When `availableModels` is set at multiple
// levels, such as user settings and project settings, arrays are
// merged and deduplicated."
// https://code.claude.com/docs/en/model-config#merge-behavior
const combined = [...(merged.availableModels ?? []), ...settings.availableModels];
merged.availableModels = Array.from(new Set(combined));
}
if (settings.permissions?.defaultMode !== undefined) {
merged.permissions = {
...merged.permissions,
defaultMode: settings.permissions.defaultMode,
};
}
try {
const resolved = await resolveSettings({ cwd: this.cwd });
this.effective = filterEscalatingDefaultMode(resolved);
} catch (error) {
this.logger.error("Failed to resolve settings:", error);
this.effective = {};
}
this.mergedSettings = merged;
}
/**
* Sets up file watchers for all settings files
*/
private setupWatchers(): void {
const paths = [
this.getUserSettingsPath(),
this.getProjectSettingsPath(),
this.getLocalSettingsPath(),
getManagedSettingsPath(),
];
for (const filePath of paths) {
if (!filePath) continue;
for (const filePath of this.getWatchedPaths()) {
try {
const dir = path.dirname(filePath);
const filename = path.basename(filePath);
@@ -278,8 +167,8 @@ export class SettingsManager {
/**
* Returns the current merged settings
*/
getSettings(): ClaudeCodeSettings {
return this.mergedSettings;
getSettings(): Settings {
return this.effective;
}
/**

View File

@@ -126,6 +126,31 @@ describe("ClaudeAcpAgent settings", () => {
expect(response.modes.currentModeId).toBe("acceptEdits");
});
it("drops escalating defaultMode when it comes from project-tier settings", async () => {
// bypassPermissions in .claude/settings.json (a repo-committed tier) is
// filtered by the SDK's trust policy and clamps to 'default'.
const projectDir = path.join(tempDir, "project");
await fs.promises.mkdir(path.join(projectDir, ".claude"), { recursive: true });
await fs.promises.writeFile(
path.join(projectDir, ".claude", "settings.json"),
JSON.stringify({ permissions: { defaultMode: "bypassPermissions" } }),
);
const { getCapturedOptions } = mockQuery();
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
cwd: projectDir,
mcpServers: [],
_meta: { disableBuiltInTools: true },
});
expect(getCapturedOptions().permissionMode).toBe("default");
expect(response.modes.currentModeId).toBe("default");
});
it("defaults to 'default' when no permissions.defaultMode is set", async () => {
const projectDir = path.join(tempDir, "project");
await fs.promises.mkdir(projectDir, { recursive: true });

View File

@@ -141,4 +141,66 @@ describe("SettingsManager", () => {
expect(settings.permissions?.defaultMode).toBe("plan");
});
});
describe("escalating defaultMode trust filter", () => {
// The SDK's filterEscalatingDefaultMode drops escalating values
// (bypassPermissions / auto / acceptEdits) that came from a repo-committed
// tier (.claude/settings.json), preventing a checked-in file from
// silently escalating permissions. Local (.claude/settings.local.json)
// and user-tier sources are not committed by convention, so escalating
// values from those tiers are preserved.
it.each(["bypassPermissions", "auto", "acceptEdits"] as const)(
"drops %s when set in project-tier settings",
async (mode) => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({ permissions: { defaultMode: mode } }),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
expect(settingsManager.getSettings().permissions?.defaultMode).toBeUndefined();
},
);
it.each(["plan", "dontAsk"] as const)(
"preserves non-escalating %s from project-tier settings",
async (mode) => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({ permissions: { defaultMode: mode } }),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
expect(settingsManager.getSettings().permissions?.defaultMode).toBe(mode);
},
);
it("preserves escalating defaultMode when it comes from local-tier settings", async () => {
// settings.local.json is git-ignored by convention, so the trust
// filter does not apply.
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.local.json"),
JSON.stringify({ permissions: { defaultMode: "acceptEdits" } }),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
expect(settingsManager.getSettings().permissions?.defaultMode).toBe("acceptEdits");
});
});
});

View File

@@ -2,9 +2,7 @@
import { Readable, Writable } from "node:stream";
import { WritableStream, ReadableStream } from "node:stream/web";
import { readFileSync } from "node:fs";
import { Logger } from "./acp-agent.js";
import { ClaudeCodeSettings, getManagedSettingsPath } from "./settings.js";
// Useful for bridging push-based and async-iterator-based code.
export class Pushable<T> implements AsyncIterable<T> {
@@ -89,19 +87,3 @@ export function unreachable(value: never, logger: Logger = console) {
export function sleep(time: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, time));
}
export function loadManagedSettings(): ClaudeCodeSettings | null {
try {
return JSON.parse(readFileSync(getManagedSettingsPath(), "utf8")) as ClaudeCodeSettings;
} catch {
return null;
}
}
export function applyEnvironmentSettings(settings: ClaudeCodeSettings): void {
if (settings.env) {
for (const [key, value] of Object.entries(settings.env)) {
process.env[key] = value;
}
}
}