diff --git a/litellm/proxy/client/cli/commands/autoroute/commands.py b/litellm/proxy/client/cli/commands/autoroute/commands.py index e48daad1f38..26d45138a27 100644 --- a/litellm/proxy/client/cli/commands/autoroute/commands.py +++ b/litellm/proxy/client/cli/commands/autoroute/commands.py @@ -37,7 +37,7 @@ from .process import ( terminate, write_pid_record, ) -from .settings import merge_claude_settings_shunt_permissions, merge_claude_settings_static_token +from .settings import merge_claude_settings_static_token from .wizard import run_configure_wizard _GENERATED_CONFIG_ADAPTER: Final = TypeAdapter(dict[str, JsonValue]) @@ -156,16 +156,10 @@ def up(port: int) -> None: ClaudeBackupRecord(existed=original_existed, content=original_settings if original_existed else None), AUTOROUTE_BACKUP_PATH, ) - with_token: Final = merge_claude_settings_static_token(original_settings, base_url, master_key) - # Applied unconditionally: an unused allow rule matches no real command and has no - # runtime effect, so this stays correct whether or not the running config actually - # arms shunt (`configure` has no shunt option yet; this is ready for when it does). - merged: Final = merge_claude_settings_shunt_permissions(with_token) + merged: Final = merge_claude_settings_static_token(original_settings, base_url, master_key) CLAUDE_SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True) with secure_create(CLAUDE_SETTINGS_PATH) as f: - # merged nests MappingProxyType at every level (see merge_claude_settings_shunt_ - # permissions); default=dict is what the JSON encoder needs to serialize those. - json.dump(merged, f, indent=2, default=dict) + json.dump(merged, f, indent=2) except ClaudeSettingsError as e: terminate(process.pid) clear_pid_record() diff --git a/litellm/proxy/client/cli/commands/autoroute/settings.py b/litellm/proxy/client/cli/commands/autoroute/settings.py index e1a4565ea40..ce2a4b01517 100644 --- a/litellm/proxy/client/cli/commands/autoroute/settings.py +++ b/litellm/proxy/client/cli/commands/autoroute/settings.py @@ -1,5 +1,3 @@ -from collections.abc import Mapping -from types import MappingProxyType from typing import Final from pydantic import JsonValue @@ -7,8 +5,6 @@ from pydantic import JsonValue from .config import AUTOROUTER_MODEL_NAME ENV_KEY: Final = "env" -PERMISSIONS_KEY: Final = "permissions" -ALLOW_KEY: Final = "allow" API_KEY_HELPER_KEY: Final = "apiKeyHelper" ANTHROPIC_API_KEY_KEY: Final = "ANTHROPIC_API_KEY" ANTHROPIC_AUTH_TOKEN_KEY: Final = "ANTHROPIC_AUTH_TOKEN" @@ -26,25 +22,6 @@ ANTHROPIC_DEFAULT_MODEL_ENV_KEYS: Final = ( "ANTHROPIC_DEFAULT_OPUS_MODEL", ) -# Reduces prompts for the curl commands the shunt guardrail's Read/Bash rewrite generates -# (litellm/proxy/guardrails/auto_router_shunt.py); it is NOT a security boundary. Claude Code -# matches an allow rule's text before its first "*" verbatim, with no argument- or host-aware -# matching, so a rule narrow enough to name "curl" at all cannot also pin the destination the -# trailing "*" is free to name any URL. A real boundary needs a PreToolUse hook instead, which -# is the docs' own recommendation for exactly this case. -# -# One rule per command shape the rewrite emits, matched on the literal prefix each one starts -# with. The bounded read needs its own rule because it opens with the `wc -l` size check rather -# than with `curl`, so a curl-prefixed rule would never match the main large-file path. -SHUNT_BASH_ALLOW_RULES: Final = ( - "Bash(L=$(wc -l < *)", - "Bash(curl -sS -F question=*)", - "Bash(curl -sS -F spec=*)", -) - -_NO_MAPPING: Final[Mapping[str, JsonValue]] = MappingProxyType({}) -_NO_RULES: Final[tuple[JsonValue, ...]] = () - def merge_claude_settings_static_token( settings: dict[str, JsonValue], base_url: str, auth_token: str @@ -71,29 +48,4 @@ def merge_claude_settings_static_token( return merged -def merge_claude_settings_shunt_permissions(settings: Mapping[str, JsonValue]) -> Mapping[str, JsonValue]: - """Add the shunt allow rules to `permissions.allow`, preserving every other permissions key. - - A naive top-level `{**settings, "permissions": {...}}` would replace the whole `permissions` - object, silently dropping any `deny`/`ask` rules the caller already has -- exactly the trap - `merge_claude_settings_static_token` avoids for `env` by merging that key explicitly instead - of replacing it. This does the same for `permissions.allow`: read what's there, add only the - two shunt rules if they're not already present, and leave `deny`/`ask`/anything else alone. - - Returns a `MappingProxyType` nested at every level rather than a plain dict, so the caller's - `json.dump(..., default=dict)` is what converts it back to something the JSON encoder accepts - -- the one place a concrete mutable mapping is genuinely needed, kept out of this function. - """ - raw_permissions: Final = settings.get(PERMISSIONS_KEY, _NO_MAPPING) - base_permissions: Final = raw_permissions if isinstance(raw_permissions, Mapping) else _NO_MAPPING - raw_allow: Final = base_permissions.get(ALLOW_KEY, _NO_RULES) - base_allow: Final = raw_allow if isinstance(raw_allow, (list, tuple)) else _NO_RULES - new_allow: Final = (*base_allow, *(rule for rule in SHUNT_BASH_ALLOW_RULES if rule not in base_allow)) - permissions: Final = MappingProxyType({**base_permissions, ALLOW_KEY: new_allow}) - return MappingProxyType({**settings, PERMISSIONS_KEY: permissions}) - - -__all__ = [ # mutable-ok: __all__ must be a list per Python convention - "merge_claude_settings_shunt_permissions", - "merge_claude_settings_static_token", -] +__all__ = ["merge_claude_settings_static_token"] # mutable-ok: __all__ must be a list per Python convention diff --git a/tests/test_litellm/proxy/client/cli/autoroute/test_settings.py b/tests/test_litellm/proxy/client/cli/autoroute/test_settings.py index 36815b38250..1a456f781d5 100644 --- a/tests/test_litellm/proxy/client/cli/autoroute/test_settings.py +++ b/tests/test_litellm/proxy/client/cli/autoroute/test_settings.py @@ -1,9 +1,5 @@ -import json - from litellm.proxy.client.cli.commands.autoroute.settings import ( ANTHROPIC_DEFAULT_MODEL_ENV_KEYS, - SHUNT_BASH_ALLOW_RULES, - merge_claude_settings_shunt_permissions, merge_claude_settings_static_token, ) @@ -13,49 +9,36 @@ def test_preserves_unrelated_top_level_keys(): assert merged["theme"] == "dark" -def test_preserves_unrelated_env_keys(): +def test_sets_base_url_and_auth_token(): + merged = merge_claude_settings_static_token({}, "http://127.0.0.1:4000", "token-abc") + assert merged["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:4000" + assert merged["env"]["ANTHROPIC_AUTH_TOKEN"] == "token-abc" + + +def test_strips_trailing_slash_from_base_url(): + merged = merge_claude_settings_static_token({}, "http://127.0.0.1:4000/", "token-abc") + assert merged["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:4000" + + +def test_clears_existing_api_key_env_var(): + settings = {"env": {"ANTHROPIC_API_KEY": "sk-old"}} + merged = merge_claude_settings_static_token(settings, "http://127.0.0.1:4000", "token-abc") + assert "ANTHROPIC_API_KEY" not in merged["env"] + + +def test_clears_existing_api_key_helper(): + settings = {"apiKeyHelper": "some-script.sh"} + merged = merge_claude_settings_static_token(settings, "http://127.0.0.1:4000", "token-abc") + assert "apiKeyHelper" not in merged + + +def test_preserves_other_env_vars(): settings = {"env": {"SOME_OTHER_VAR": "value"}} merged = merge_claude_settings_static_token(settings, "http://127.0.0.1:4000", "token-abc") assert merged["env"]["SOME_OTHER_VAR"] == "value" -def test_sets_base_url_and_auth_token(): - merged = merge_claude_settings_static_token({}, "http://127.0.0.1:4000/", "token-abc") - assert merged["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:4000" - assert merged["env"]["ANTHROPIC_AUTH_TOKEN"] == "token-abc" - assert merged["env"]["ENABLE_TOOL_SEARCH"] == "true" - - -def test_preserves_existing_tool_search(): - settings = {"env": {"ENABLE_TOOL_SEARCH": "false"}} - merged = merge_claude_settings_static_token(settings, "http://127.0.0.1:4000", "token-abc") - assert merged["env"]["ENABLE_TOOL_SEARCH"] == "false" - - -def test_drops_stray_api_key(): - settings = {"env": {"ANTHROPIC_API_KEY": "leaked-key"}} - merged = merge_claude_settings_static_token(settings, "http://127.0.0.1:4000", "token-abc") - assert "ANTHROPIC_API_KEY" not in merged["env"] - - -def test_removes_existing_api_key_helper(): - settings = {"apiKeyHelper": "/usr/local/bin/lite auth print-token"} - merged = merge_claude_settings_static_token(settings, "http://127.0.0.1:4000", "token-abc") - assert "apiKeyHelper" not in merged - - -def test_does_not_mutate_input(): - settings = {"env": {"FOO": "bar"}, "apiKeyHelper": "old-helper"} - merge_claude_settings_static_token(settings, "http://127.0.0.1:4000", "token-abc") - assert settings == {"env": {"FOO": "bar"}, "apiKeyHelper": "old-helper"} - - -def test_forces_all_claude_code_default_model_tiers_to_the_autorouter(): - # A bare "*" model_name deployment looks like the obvious way to catch every request - # regardless of which model Claude Code thinks it's using, but Router's auto-router - # registry is keyed by the literal requested model string with no wildcard resolution - # (litellm/router.py:10711-10717) -- so the only reliable way to make every one of Claude - # Code's own tiers hit the auto-router is to override the env vars it reads per tier. +def test_sets_every_default_model_env_key_to_autorouter(): merged = merge_claude_settings_static_token({}, "http://127.0.0.1:4000", "token-abc") for key in ANTHROPIC_DEFAULT_MODEL_ENV_KEYS: assert merged["env"][key] == "autorouter" @@ -65,56 +48,3 @@ def test_overrides_a_preexisting_default_model_env_var(): settings = {"env": {"ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-opus-4-8"}} merged = merge_claude_settings_static_token(settings, "http://127.0.0.1:4000", "token-abc") assert merged["env"]["ANTHROPIC_DEFAULT_SONNET_MODEL"] == "autorouter" - - -def test_adds_shunt_allow_rules_to_empty_settings(): - merged = merge_claude_settings_shunt_permissions({}) - assert tuple(merged["permissions"]["allow"]) == SHUNT_BASH_ALLOW_RULES - - -def test_preserves_unrelated_top_level_keys_for_permissions_merge(): - merged = merge_claude_settings_shunt_permissions({"theme": "dark"}) - assert merged["theme"] == "dark" - - -def test_preserves_existing_allow_rules(): - settings = {"permissions": {"allow": ["Bash(npm run *)"]}} - merged = merge_claude_settings_shunt_permissions(settings) - assert "Bash(npm run *)" in merged["permissions"]["allow"] - for rule in SHUNT_BASH_ALLOW_RULES: - assert rule in merged["permissions"]["allow"] - - -def test_preserves_deny_and_ask_rules(): - # Regression: a naive {**settings, "permissions": {...}} replaces the whole permissions - # object, silently dropping deny/ask rules the caller already had. - settings = {"permissions": {"deny": ["Bash(git push *)"], "ask": ["Bash(rm *)"]}} - merged = merge_claude_settings_shunt_permissions(settings) - assert merged["permissions"]["deny"] == ["Bash(git push *)"] - assert merged["permissions"]["ask"] == ["Bash(rm *)"] - for rule in SHUNT_BASH_ALLOW_RULES: - assert rule in merged["permissions"]["allow"] - - -def test_does_not_duplicate_shunt_rules_already_present(): - settings = {"permissions": {"allow": list(SHUNT_BASH_ALLOW_RULES)}} - merged = merge_claude_settings_shunt_permissions(settings) - assert tuple(merged["permissions"]["allow"]) == SHUNT_BASH_ALLOW_RULES - - -def test_merged_settings_survive_the_json_round_trip_commands_py_writes(): - # Regression: the merge returns MappingProxyType nested at every level, which the JSON - # encoder rejects without `default=dict` -- the exact call commands.py makes. Without it - # `lite autoroute up` raises instead of writing settings.json. - settings = {"permissions": {"allow": ["Bash(npm run *)"], "deny": ["Bash(rm *)"]}, "theme": "dark"} - merged = merge_claude_settings_shunt_permissions(settings) - reloaded = json.loads(json.dumps(merged, default=dict)) - assert reloaded["theme"] == "dark" - assert reloaded["permissions"]["deny"] == ["Bash(rm *)"] - assert reloaded["permissions"]["allow"] == ["Bash(npm run *)", *SHUNT_BASH_ALLOW_RULES] - - -def test_does_not_mutate_input_for_permissions_merge(): - settings = {"permissions": {"allow": ["Bash(npm run *)"], "deny": ["Bash(rm *)"]}} - merge_claude_settings_shunt_permissions(settings) - assert settings == {"permissions": {"allow": ["Bash(npm run *)"], "deny": ["Bash(rm *)"]}}