diff --git a/src/index.ts b/src/index.ts index 73495ebf..ed674303 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 diff --git a/src/lib.ts b/src/lib.ts index fd93fbab..e9751743 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -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"; diff --git a/src/settings.ts b/src/settings.ts index 2bdc98a6..8fc0c1a4 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -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; - 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 { - 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 (/.claude/settings.json) - * 3. Local project settings (/.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 { - 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; } /** diff --git a/src/tests/acp-agent-settings.test.ts b/src/tests/acp-agent-settings.test.ts index 326e7e82..bef9d7d1 100644 --- a/src/tests/acp-agent-settings.test.ts +++ b/src/tests/acp-agent-settings.test.ts @@ -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 }); diff --git a/src/tests/settings.test.ts b/src/tests/settings.test.ts index 14c26c94..f2cb37cb 100644 --- a/src/tests/settings.test.ts +++ b/src/tests/settings.test.ts @@ -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"); + }); + }); }); diff --git a/src/utils.ts b/src/utils.ts index 814021b2..f444f7a5 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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 implements AsyncIterable { @@ -89,19 +87,3 @@ export function unreachable(value: never, logger: Logger = console) { export function sleep(time: number): Promise { 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; - } - } -}