From aa72110b15a9d3a07e4797a3547367a007d024cf Mon Sep 17 00:00:00 2001 From: Yasyf Mohamedali Date: Wed, 2 Sep 2026 14:31:43 -0700 Subject: [PATCH] Stop Claude runs inheriting the host's user settings via --setting-sources Claude-Session: https://claude.ai/code/session_014gKfZvjihGgFrEVCr3NJJb Claude-Session-Id: 2eb2d69e-c564-4401-84fb-d22d217b5ff7 --- CHANGELOG.md | 12 +++ .../plan/claude-append-system-prompt.json | 1 + .../plan/claude-disable-slash-commands.json | 1 + .../vectors/plan/claude-disallowed-tools.json | 1 + .../plan/claude-explicit-max-budget.json | 1 + .../plan/claude-full-explicit-agent.json | 1 + .../vectors/plan/claude-isolated-false.json | 2 + .../vectors/plan/claude-max-turns.json | 1 + .../vectors/plan/claude-mcp-config.json | 1 + .../vectors/plan/claude-output-format.json | 1 + .../vectors/plan/claude-permission-mode.json | 1 + .../plan/claude-setting-sources-empty.json | 70 +++++++++++++++++ .../plan/claude-setting-sources-host.json | 74 ++++++++++++++++++ .../plan/claude-setting-sources-isolated.json | 75 +++++++++++++++++++ conformance/vectors/plan/claude-settings.json | 1 + .../vectors/plan/claude-strict-mcp.json | 3 + .../vectors/plan/claude-system-prompt.json | 1 + .../vectors/plan/claude-tools-empty.json | 1 + .../vectors/plan/claude-tools-list.json | 1 + conformance/vectors/plan/claude-verbose.json | 1 + go/backend_test.go | 19 +++++ go/spec.go | 12 ++- rust/conformance-gen/src/cases.rs | 32 ++++++++ rust/spawnllm-core/src/plan/claude.rs | 13 +++- rust/spawnllm-core/src/wire.rs | 1 + rust/spawnllm/src/spec.rs | 3 + spawnllm/backends/claude_sdk.py | 9 ++- spawnllm/spec.py | 8 +- spawnllm/types.py | 5 +- tests/test_claude_sdk.py | 26 ++++++- 30 files changed, 364 insertions(+), 14 deletions(-) create mode 100644 conformance/vectors/plan/claude-setting-sources-empty.json create mode 100644 conformance/vectors/plan/claude-setting-sources-host.json create mode 100644 conformance/vectors/plan/claude-setting-sources-isolated.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ff716b..e633652 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the same 64 KiB pipe boundary. ### Changed +- **Claude runs no longer inherit the host's user settings, and that is a default + change.** `claude -p` loads `user`, `project`, and `local` settings unless told + otherwise, which handed every spawned session the caller's hooks. On a machine + with a real hook stack those hooks dominate the call. A one-word prompt to + `claude-haiku-4-5` measured 63s with the host's hooks against 6s without, and + the session exited printing `SessionEnd hook … failed`. Non-isolated runs now + plan `--setting-sources project`; isolated runs keep the `--setting-sources ""` + they already passed. The new `ClaudeConfig.setting_sources` (Go + `SettingSources`) names the sources instead, on isolated runs too. Pass + `("user", "project", "local")` to restore what non-isolated runs did before, or + `()` to load none. `--settings` is no substitute, because settings merge: a + `{"hooks":{}}` payload leaves the inherited hooks in place. - **The Apple backend no longer needs the `apple` extra, `apple-fm-sdk`, or Xcode.** A prebuilt Swift sidecar ships inside the macOS platform wheel (`macosx_26_0_arm64`), so installing spawnllm on an Apple-Intelligence-capable diff --git a/conformance/vectors/plan/claude-append-system-prompt.json b/conformance/vectors/plan/claude-append-system-prompt.json index f4064a7..d7331d1 100644 --- a/conformance/vectors/plan/claude-append-system-prompt.json +++ b/conformance/vectors/plan/claude-append-system-prompt.json @@ -22,6 +22,7 @@ "mcp_config": null, "output_format": null, "permission_mode": null, + "setting_sources": null, "settings": null, "strict_mcp": false, "system_prompt": null, diff --git a/conformance/vectors/plan/claude-disable-slash-commands.json b/conformance/vectors/plan/claude-disable-slash-commands.json index 749343b..5cba3c5 100644 --- a/conformance/vectors/plan/claude-disable-slash-commands.json +++ b/conformance/vectors/plan/claude-disable-slash-commands.json @@ -22,6 +22,7 @@ "mcp_config": null, "output_format": null, "permission_mode": null, + "setting_sources": null, "settings": null, "strict_mcp": false, "system_prompt": null, diff --git a/conformance/vectors/plan/claude-disallowed-tools.json b/conformance/vectors/plan/claude-disallowed-tools.json index 32004a2..8d9ee16 100644 --- a/conformance/vectors/plan/claude-disallowed-tools.json +++ b/conformance/vectors/plan/claude-disallowed-tools.json @@ -25,6 +25,7 @@ "mcp_config": null, "output_format": null, "permission_mode": null, + "setting_sources": null, "settings": null, "strict_mcp": false, "system_prompt": null, diff --git a/conformance/vectors/plan/claude-explicit-max-budget.json b/conformance/vectors/plan/claude-explicit-max-budget.json index d4082b3..f2dc087 100644 --- a/conformance/vectors/plan/claude-explicit-max-budget.json +++ b/conformance/vectors/plan/claude-explicit-max-budget.json @@ -22,6 +22,7 @@ "mcp_config": null, "output_format": null, "permission_mode": "acceptEdits", + "setting_sources": null, "settings": null, "strict_mcp": false, "system_prompt": null, diff --git a/conformance/vectors/plan/claude-full-explicit-agent.json b/conformance/vectors/plan/claude-full-explicit-agent.json index 664072d..7191cb8 100644 --- a/conformance/vectors/plan/claude-full-explicit-agent.json +++ b/conformance/vectors/plan/claude-full-explicit-agent.json @@ -34,6 +34,7 @@ "mcp_config": "{\"mcpServers\":{}}", "output_format": null, "permission_mode": "bypassPermissions", + "setting_sources": null, "settings": "{\"model\":\"opus\"}", "strict_mcp": true, "system_prompt": "terse", diff --git a/conformance/vectors/plan/claude-isolated-false.json b/conformance/vectors/plan/claude-isolated-false.json index f3f8f48..31569b2 100644 --- a/conformance/vectors/plan/claude-isolated-false.json +++ b/conformance/vectors/plan/claude-isolated-false.json @@ -30,6 +30,8 @@ "--no-session-persistence", "--model", "haiku", + "--setting-sources", + "project", "--system-prompt", "" ], diff --git a/conformance/vectors/plan/claude-max-turns.json b/conformance/vectors/plan/claude-max-turns.json index 928c18e..23d5761 100644 --- a/conformance/vectors/plan/claude-max-turns.json +++ b/conformance/vectors/plan/claude-max-turns.json @@ -22,6 +22,7 @@ "mcp_config": null, "output_format": null, "permission_mode": null, + "setting_sources": null, "settings": null, "strict_mcp": false, "system_prompt": null, diff --git a/conformance/vectors/plan/claude-mcp-config.json b/conformance/vectors/plan/claude-mcp-config.json index 666c358..95ed4e1 100644 --- a/conformance/vectors/plan/claude-mcp-config.json +++ b/conformance/vectors/plan/claude-mcp-config.json @@ -22,6 +22,7 @@ "mcp_config": "{\"mcpServers\":{}}", "output_format": null, "permission_mode": null, + "setting_sources": null, "settings": null, "strict_mcp": false, "system_prompt": null, diff --git a/conformance/vectors/plan/claude-output-format.json b/conformance/vectors/plan/claude-output-format.json index fd13608..6e95496 100644 --- a/conformance/vectors/plan/claude-output-format.json +++ b/conformance/vectors/plan/claude-output-format.json @@ -22,6 +22,7 @@ "mcp_config": null, "output_format": "stream-json", "permission_mode": null, + "setting_sources": null, "settings": null, "strict_mcp": false, "system_prompt": null, diff --git a/conformance/vectors/plan/claude-permission-mode.json b/conformance/vectors/plan/claude-permission-mode.json index 0914e89..bedb68b 100644 --- a/conformance/vectors/plan/claude-permission-mode.json +++ b/conformance/vectors/plan/claude-permission-mode.json @@ -22,6 +22,7 @@ "mcp_config": null, "output_format": null, "permission_mode": "bypassPermissions", + "setting_sources": null, "settings": null, "strict_mcp": false, "system_prompt": null, diff --git a/conformance/vectors/plan/claude-setting-sources-empty.json b/conformance/vectors/plan/claude-setting-sources-empty.json new file mode 100644 index 0000000..582b7c4 --- /dev/null +++ b/conformance/vectors/plan/claude-setting-sources-empty.json @@ -0,0 +1,70 @@ +{ + "name": "claude-setting-sources-empty", + "op": "plan", + "input": { + "provider": "claude", + "spec": { + "prompt": "hi", + "model": "haiku", + "agent": false, + "isolated": false, + "timeout": 180, + "max_attempts": 5, + "api_auth": false, + "schema": null, + "apple": null, + "claude": { + "append_system_prompt": null, + "disable_slash_commands": false, + "disallowed_tools": [], + "max_budget_usd": null, + "max_turns": null, + "mcp_config": null, + "output_format": null, + "permission_mode": null, + "setting_sources": [], + "settings": null, + "strict_mcp": false, + "system_prompt": null, + "tools": null, + "verbose": false + }, + "codex": null, + "gemini": null, + "openai_endpoint": null + }, + "host": { + "platform": "darwin" + } + }, + "expected": { + "kind": "exec", + "argv": [ + "claude", + "-p", + "--no-session-persistence", + "--model", + "haiku", + "--setting-sources", + "", + "--system-prompt", + "" + ], + "stdin": "hi", + "files": [ + { + "id": "stdout", + "suffix": ".json", + "content": null + } + ], + "stdout_to_file": true, + "read_result_from": "stdout", + "env": {}, + "env_unset": [ + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN" + ], + "needs_claude_isolation": false + } +} diff --git a/conformance/vectors/plan/claude-setting-sources-host.json b/conformance/vectors/plan/claude-setting-sources-host.json new file mode 100644 index 0000000..9e4a43d --- /dev/null +++ b/conformance/vectors/plan/claude-setting-sources-host.json @@ -0,0 +1,74 @@ +{ + "name": "claude-setting-sources-host", + "op": "plan", + "input": { + "provider": "claude", + "spec": { + "prompt": "hi", + "model": "haiku", + "agent": false, + "isolated": false, + "timeout": 180, + "max_attempts": 5, + "api_auth": false, + "schema": null, + "apple": null, + "claude": { + "append_system_prompt": null, + "disable_slash_commands": false, + "disallowed_tools": [], + "max_budget_usd": null, + "max_turns": null, + "mcp_config": null, + "output_format": null, + "permission_mode": null, + "setting_sources": [ + "user", + "project", + "local" + ], + "settings": null, + "strict_mcp": false, + "system_prompt": null, + "tools": null, + "verbose": false + }, + "codex": null, + "gemini": null, + "openai_endpoint": null + }, + "host": { + "platform": "darwin" + } + }, + "expected": { + "kind": "exec", + "argv": [ + "claude", + "-p", + "--no-session-persistence", + "--model", + "haiku", + "--setting-sources", + "user,project,local", + "--system-prompt", + "" + ], + "stdin": "hi", + "files": [ + { + "id": "stdout", + "suffix": ".json", + "content": null + } + ], + "stdout_to_file": true, + "read_result_from": "stdout", + "env": {}, + "env_unset": [ + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN" + ], + "needs_claude_isolation": false + } +} diff --git a/conformance/vectors/plan/claude-setting-sources-isolated.json b/conformance/vectors/plan/claude-setting-sources-isolated.json new file mode 100644 index 0000000..f512aad --- /dev/null +++ b/conformance/vectors/plan/claude-setting-sources-isolated.json @@ -0,0 +1,75 @@ +{ + "name": "claude-setting-sources-isolated", + "op": "plan", + "input": { + "provider": "claude", + "spec": { + "prompt": "hi", + "model": "haiku", + "agent": false, + "isolated": true, + "timeout": 180, + "max_attempts": 5, + "api_auth": false, + "schema": null, + "apple": null, + "claude": { + "append_system_prompt": null, + "disable_slash_commands": false, + "disallowed_tools": [], + "max_budget_usd": null, + "max_turns": null, + "mcp_config": null, + "output_format": null, + "permission_mode": null, + "setting_sources": [ + "project" + ], + "settings": null, + "strict_mcp": false, + "system_prompt": null, + "tools": null, + "verbose": false + }, + "codex": null, + "gemini": null, + "openai_endpoint": null + }, + "host": { + "platform": "darwin" + } + }, + "expected": { + "kind": "exec", + "argv": [ + "claude", + "-p", + "--no-session-persistence", + "--model", + "haiku", + "--setting-sources", + "project", + "--strict-mcp-config", + "--system-prompt", + "" + ], + "stdin": "hi", + "files": [ + { + "id": "stdout", + "suffix": ".json", + "content": null + } + ], + "stdout_to_file": true, + "read_result_from": "stdout", + "env": { + "CLAUDE_CONFIG_DIR": "${isolated_config_dir}" + }, + "env_unset": [ + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN" + ], + "needs_claude_isolation": true + } +} diff --git a/conformance/vectors/plan/claude-settings.json b/conformance/vectors/plan/claude-settings.json index 343f8bc..cc346ab 100644 --- a/conformance/vectors/plan/claude-settings.json +++ b/conformance/vectors/plan/claude-settings.json @@ -22,6 +22,7 @@ "mcp_config": null, "output_format": null, "permission_mode": null, + "setting_sources": null, "settings": "{\"model\":\"opus\"}", "strict_mcp": false, "system_prompt": null, diff --git a/conformance/vectors/plan/claude-strict-mcp.json b/conformance/vectors/plan/claude-strict-mcp.json index e942153..6755f5e 100644 --- a/conformance/vectors/plan/claude-strict-mcp.json +++ b/conformance/vectors/plan/claude-strict-mcp.json @@ -22,6 +22,7 @@ "mcp_config": null, "output_format": null, "permission_mode": null, + "setting_sources": null, "settings": null, "strict_mcp": true, "system_prompt": null, @@ -44,6 +45,8 @@ "--no-session-persistence", "--model", "haiku", + "--setting-sources", + "project", "--strict-mcp-config" ], "stdin": "hi", diff --git a/conformance/vectors/plan/claude-system-prompt.json b/conformance/vectors/plan/claude-system-prompt.json index cf775c3..6741c4c 100644 --- a/conformance/vectors/plan/claude-system-prompt.json +++ b/conformance/vectors/plan/claude-system-prompt.json @@ -22,6 +22,7 @@ "mcp_config": null, "output_format": null, "permission_mode": null, + "setting_sources": null, "settings": null, "strict_mcp": false, "system_prompt": "You are terse.", diff --git a/conformance/vectors/plan/claude-tools-empty.json b/conformance/vectors/plan/claude-tools-empty.json index 2455a26..e9c61ff 100644 --- a/conformance/vectors/plan/claude-tools-empty.json +++ b/conformance/vectors/plan/claude-tools-empty.json @@ -22,6 +22,7 @@ "mcp_config": null, "output_format": null, "permission_mode": null, + "setting_sources": null, "settings": null, "strict_mcp": false, "system_prompt": null, diff --git a/conformance/vectors/plan/claude-tools-list.json b/conformance/vectors/plan/claude-tools-list.json index 54cedd2..721af28 100644 --- a/conformance/vectors/plan/claude-tools-list.json +++ b/conformance/vectors/plan/claude-tools-list.json @@ -22,6 +22,7 @@ "mcp_config": null, "output_format": null, "permission_mode": null, + "setting_sources": null, "settings": null, "strict_mcp": false, "system_prompt": null, diff --git a/conformance/vectors/plan/claude-verbose.json b/conformance/vectors/plan/claude-verbose.json index af2f491..064c00c 100644 --- a/conformance/vectors/plan/claude-verbose.json +++ b/conformance/vectors/plan/claude-verbose.json @@ -22,6 +22,7 @@ "mcp_config": null, "output_format": null, "permission_mode": null, + "setting_sources": null, "settings": null, "strict_mcp": false, "system_prompt": null, diff --git a/go/backend_test.go b/go/backend_test.go index 431ed35..6b259a9 100644 --- a/go/backend_test.go +++ b/go/backend_test.go @@ -269,6 +269,25 @@ func TestClaudeToolsNilVsEmpty(t *testing.T) { } } +func TestClaudeSettingSourcesNilVsList(t *testing.T) { + unset := RunSpec{Providers: ProviderConfigs{Claude: &ClaudeConfig{}}}.core() + raw, err := json.Marshal(unset.Claude) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(raw, []byte(`"setting_sources":null`)) { + t.Fatalf("nil SettingSources should serialize to null: %s", raw) + } + host := ClaudeConfig{SettingSources: []string{"user", "project", "local"}} + raw, err = json.Marshal(RunSpec{Providers: ProviderConfigs{Claude: &host}}.core().Claude) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(raw, []byte(`"setting_sources":["user","project","local"]`)) { + t.Fatalf("SettingSources should serialize verbatim: %s", raw) + } +} + func TestClaudeConfigOptionalsSerializeNull(t *testing.T) { raw, err := json.Marshal(RunSpec{Providers: ProviderConfigs{Claude: &ClaudeConfig{}}}.core().Claude) if err != nil { diff --git a/go/spec.go b/go/spec.go index 3330a62..28655ce 100644 --- a/go/spec.go +++ b/go/spec.go @@ -88,9 +88,13 @@ type AppleConfig struct { // fields are unset; Tools distinguishes nil (CLI default) from an empty slice // (disable every built-in tool). type ClaudeConfig struct { - PermissionMode string - MCPConfig string - StrictMCP bool + PermissionMode string + MCPConfig string + StrictMCP bool + // SettingSources is --setting-sources. Nil drops the host's user settings + // ("project", or nothing when UseHostConfig is false), so a run never inherits + // the caller's hooks; {"user", "project", "local"} restores the CLI default. + SettingSources []string AppendSystemPrompt string SystemPrompt string Settings string @@ -203,6 +207,7 @@ type coreClaude struct { MCPConfig *string `json:"mcp_config"` OutputFormat *string `json:"output_format"` PermissionMode *string `json:"permission_mode"` + SettingSources []string `json:"setting_sources"` Settings *string `json:"settings"` StrictMCP bool `json:"strict_mcp"` SystemPrompt *string `json:"system_prompt"` @@ -312,6 +317,7 @@ func coreClaudeOf(c *ClaudeConfig) *coreClaude { MCPConfig: optString(c.MCPConfig), OutputFormat: optString(c.OutputFormat), PermissionMode: optString(c.PermissionMode), + SettingSources: c.SettingSources, Settings: optString(c.Settings), StrictMCP: c.StrictMCP, SystemPrompt: optString(c.SystemPrompt), diff --git a/rust/conformance-gen/src/cases.rs b/rust/conformance-gen/src/cases.rs index 19748cf..871c33c 100644 --- a/rust/conformance-gen/src/cases.rs +++ b/rust/conformance-gen/src/cases.rs @@ -49,6 +49,7 @@ fn claude() -> ClaudeConfig { mcp_config: None, output_format: None, permission_mode: None, + setting_sources: None, settings: None, strict_mcp: false, system_prompt: None, @@ -227,6 +228,37 @@ fn plan_cases() -> Vec { ..spec("hi", "haiku") }, ), + plan_case( + "claude-setting-sources-host", + "claude", + RunSpec { + isolated: false, + claude: Some(ClaudeConfig { + setting_sources: Some(strs(&["user", "project", "local"])), + ..claude() + }), + ..spec("hi", "haiku") + }, + ), + plan_case( + "claude-setting-sources-empty", + "claude", + RunSpec { + isolated: false, + claude: Some(ClaudeConfig { + setting_sources: Some(vec![]), + ..claude() + }), + ..spec("hi", "haiku") + }, + ), + claude_cfg_case( + "claude-setting-sources-isolated", + ClaudeConfig { + setting_sources: Some(strs(&["project"])), + ..claude() + }, + ), claude_cfg_case( "claude-max-turns", ClaudeConfig { diff --git a/rust/spawnllm-core/src/plan/claude.rs b/rust/spawnllm-core/src/plan/claude.rs index f3473fa..5c6c441 100644 --- a/rust/spawnllm-core/src/plan/claude.rs +++ b/rust/spawnllm-core/src/plan/claude.rs @@ -17,12 +17,11 @@ pub(super) fn plan(spec: &RunSpec) -> InvocationPlan { ]; let mut env = BTreeMap::new(); + let config = spec.claude.as_ref(); + argv.extend(["--setting-sources".into(), setting_sources(spec, config)]); if spec.isolated { - argv.extend(["--setting-sources".into(), String::new()]); env.insert("CLAUDE_CONFIG_DIR".into(), "${isolated_config_dir}".into()); } - - let config = spec.claude.as_ref(); if spec.isolated || config.is_some_and(|config| config.strict_mcp) { argv.push("--strict-mcp-config".into()); } @@ -80,6 +79,14 @@ pub(super) fn plan(spec: &RunSpec) -> InvocationPlan { }) } +fn setting_sources(spec: &RunSpec, config: Option<&ClaudeConfig>) -> String { + match config.and_then(|config| config.setting_sources.as_deref()) { + Some(sources) => sources.join(","), + None if spec.isolated => String::new(), + None => "project".into(), + } +} + fn append_config(argv: &mut Vec, spec: &RunSpec, config: &ClaudeConfig) { let explicit = config.permission_mode.is_some() || config.mcp_config.is_some() diff --git a/rust/spawnllm-core/src/wire.rs b/rust/spawnllm-core/src/wire.rs index afdee62..2c3e4b8 100644 --- a/rust/spawnllm-core/src/wire.rs +++ b/rust/spawnllm-core/src/wire.rs @@ -73,6 +73,7 @@ pub struct ClaudeConfig { pub mcp_config: Option, pub output_format: Option, pub permission_mode: Option, + pub setting_sources: Option>, pub settings: Option, pub strict_mcp: bool, pub system_prompt: Option, diff --git a/rust/spawnllm/src/spec.rs b/rust/spawnllm/src/spec.rs index 0da9648..9ecbc69 100644 --- a/rust/spawnllm/src/spec.rs +++ b/rust/spawnllm/src/spec.rs @@ -28,6 +28,9 @@ pub struct ClaudeConfig { pub mcp_config: Option, pub output_format: Option, pub permission_mode: Option, + /// `--setting-sources`; unset drops the host's user settings (`project`, or nothing + /// when isolated), and `["user", "project", "local"]` restores the CLI default. + pub setting_sources: Option>, pub settings: Option, pub strict_mcp: bool, pub system_prompt: Option, diff --git a/spawnllm/backends/claude_sdk.py b/spawnllm/backends/claude_sdk.py index 383dd29..0282fa3 100644 --- a/spawnllm/backends/claude_sdk.py +++ b/spawnllm/backends/claude_sdk.py @@ -31,7 +31,7 @@ from spawnllm.backends.base import BackendStatus from spawnllm.spec import RunSpec - from spawnllm.types import ProviderName, TModel + from spawnllm.types import ProviderName, TModel, TSettingSource def sdk_cli_path() -> str | None: @@ -53,7 +53,9 @@ class ClaudeSdkBackend(LlmBackend): Here `isolated=True` makes settings and MCP configuration hermetic through `setting_sources=[]` and `strict_mcp_config`, but unlike `ClaudeCliBackend` it does not seed a fresh `CLAUDE_CONFIG_DIR`, so credentials come from the - ambient config home or `CLAUDE_CODE_OAUTH_TOKEN`. + ambient config home or `CLAUDE_CODE_OAUTH_TOKEN`. `isolated=False` loads + `["project"]`, matching the CLI plan, until `ClaudeConfig.setting_sources` + names its own. `api_auth=False` blanks Claude's API-key environment variables because the SDK can only overlay its subprocess environment, not truly unset inherited @@ -90,11 +92,12 @@ def build_options(self, spec: RunSpec) -> ClaudeAgentOptions: cfg = spec.config_for(ClaudeConfig) or ClaudeConfig() api_key_vars = _core.dispatch("capabilities")["api_key_vars"]["claude"] schema = self.wire_schema(spec) + host_free: list[TSettingSource] = [] if spec.isolated else ["project"] options = ClaudeAgentOptions( model=spec.model, cwd=spec.cwd, env=({} if spec.api_auth else {key: "" for key in api_key_vars}) | (spec.env or {}), - setting_sources=[] if spec.isolated else ["user", "project", "local"], + setting_sources=host_free if cfg.setting_sources is None else list(cfg.setting_sources), strict_mcp_config=spec.isolated or cfg.strict_mcp, max_turns=cfg.max_turns, tools=list(cfg.tools) if cfg.tools is not None else None, diff --git a/spawnllm/spec.py b/spawnllm/spec.py index 6099c48..f801969 100644 --- a/spawnllm/spec.py +++ b/spawnllm/spec.py @@ -8,7 +8,7 @@ if TYPE_CHECKING: from pydantic import BaseModel - from spawnllm.types import ProviderName + from spawnllm.types import ProviderName, TSettingSource @dataclass(frozen=True, slots=True) @@ -67,16 +67,20 @@ class ClaudeConfig: (`max_turns`, `max_budget_usd`, `tools`, `disable_slash_commands`, `output_format`, `verbose`). `tools` selects the built-in toolset: `None` keeps the CLI default, `()` disables every built-in tool, and names - restrict the session to those tools. + restrict the session to those tools. `setting_sources` selects + `--setting-sources`: `None` drops the host's user settings (`("project",)`, + or nothing when `isolated`), so a run never inherits the caller's hooks. Example: >>> ClaudeConfig(permission_mode="bypassPermissions", strict_mcp=True) >>> ClaudeConfig(tools=()) # bare session: no built-in tools + >>> ClaudeConfig(setting_sources=("user", "project", "local")) # CLI default """ permission_mode: str | None = None mcp_config: str | None = None strict_mcp: bool = False + setting_sources: tuple[TSettingSource, ...] | None = None append_system_prompt: str | None = None system_prompt: str | None = None settings: str | None = None diff --git a/spawnllm/types.py b/spawnllm/types.py index a730dbd..82d3b55 100644 --- a/spawnllm/types.py +++ b/spawnllm/types.py @@ -4,11 +4,14 @@ from typing import Literal -__all__ = ["ProviderName", "TModel", "TSpecialty"] +__all__ = ["ProviderName", "TModel", "TSettingSource", "TSpecialty"] TSpecialty = Literal["debugging", "review", "general"] """Task specialty; `LlmBackends.for_specialty` maps each to its registered backend.""" +TSettingSource = Literal["user", "project", "local"] +"""A settings layer the Claude CLI loads; `ClaudeConfig.setting_sources` selects them.""" + TModel = Literal["small", "medium", "large"] """Abstract model tier; each backend maps it to a provider-specific model name.""" diff --git a/tests/test_claude_sdk.py b/tests/test_claude_sdk.py index 74cf1b4..0c47d01 100644 --- a/tests/test_claude_sdk.py +++ b/tests/test_claude_sdk.py @@ -59,12 +59,34 @@ def test_lockdown_default(self) -> None: assert options.extra_args == {"no-session-persistence": None} assert options.output_format is None - def test_isolated_false_loads_every_setting_source(self) -> None: + def test_isolated_false_loads_project_settings_only(self) -> None: options = ClaudeSdkBackend().build_options(spec(isolated=False)) - assert options.setting_sources == ["user", "project", "local"] + assert options.setting_sources == ["project"] assert options.strict_mcp_config is False + @pytest.mark.parametrize( + ("configured", "isolated", "expected"), + [ + pytest.param(None, True, [], id="isolated-default"), + pytest.param(None, False, ["project"], id="host-default"), + pytest.param((), False, [], id="none"), + pytest.param(("user", "project", "local"), False, ["user", "project", "local"], id="host-restore"), + pytest.param(("project",), True, ["project"], id="explicit-over-isolation"), + ], + ) + def test_setting_sources_selection( + self, + configured: tuple[str, ...] | None, + isolated: bool, + expected: list[str], + ) -> None: + options = ClaudeSdkBackend().build_options( + spec(config=ClaudeConfig(setting_sources=configured), isolated=isolated) + ) + + assert options.setting_sources == expected + def test_agent_branch_uses_auto_permissions_and_claude_code_prompt(self) -> None: options = ClaudeSdkBackend().build_options(spec(agent=True))