diff --git a/CHANGELOG.md b/CHANGELOG.md index 82fe80f67..22dce9efb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Bug Fixes +* **ccr:** don't crash `parse_tool_call` on a CCR tool call whose arguments aren't an object. For the OpenAI/`openai_responses` shape the arguments are `json.loads`-decoded and only `JSONDecodeError` was caught, so a model that emitted `arguments='[]'`/`'"abc"'`/`'123'` (decoding to a list/str/number) — or a non-dict Anthropic `input` — reached `input_data.get("hash")` and raised `AttributeError`; a null `arguments` raised an uncaught `TypeError` from `json.loads(None)`. Both are now handled: the decode also catches `TypeError`, and a non-dict `input_data` returns `None` (not a valid CCR call) instead of crashing CCR response processing. * **cache/semantic:** key entries by the full-context hash, not the trailing query text. `SemanticCache.put` stored each response under `sha256(query)[:16]` where `query` is only the last user message, and the exact-match branch of `get` returned the slot without checking the stored entry's `messages_hash`. Two requests that share a trailing message ("continue", "yes", "run the tests") but differ in earlier context therefore collided on one slot — the second overwrote the first, and the first's hash then resolved to the second's cached response (wrong data served). Entries are now keyed by `messages_hash` when present, and `get` verifies `entry.messages_hash` before returning. * **proxy/openai:** stop PRE_SEND from reintroducing `tools: []` after the direct #728 fix. The OpenAI request handler now mirrors the existing `tools or _original_tools is not None` body-write guard during PRE_SEND write-back, so providers that reject empty tool arrays no longer see a tools field when the client omitted it, while explicit client `tools: []` remains preserved ([#1983](https://github.com/headroomlabs-ai/headroom/issues/1983)). * **proxy/openai:** keep the exact Responses function name `terminal` resident during OpenAI tool-search deferral so cache-mode optimization stops forwarding `terminal.terminal` and triggering the reserved-namespace 400 on Codex Responses ([#1946](https://github.com/headroomlabs-ai/headroom/issues/1946)). diff --git a/headroom/ccr/tool_injection.py b/headroom/ccr/tool_injection.py index d8e744535..118cec849 100644 --- a/headroom/ccr/tool_injection.py +++ b/headroom/ccr/tool_injection.py @@ -465,7 +465,8 @@ def parse_tool_call( args_str = function.get("arguments", "{}") try: input_data = json.loads(args_str) - except json.JSONDecodeError: + except (json.JSONDecodeError, TypeError): + # TypeError covers a null/None `arguments` value (json.loads(None)). input_data = {} elif provider == "google": # Google/Gemini format: {"functionCall": {"name": "...", "args": {...}}} @@ -480,7 +481,8 @@ def parse_tool_call( args_str = tool_call.get("arguments", "{}") try: input_data = json.loads(args_str) - except json.JSONDecodeError: + except (json.JSONDecodeError, TypeError): + # TypeError covers a null/None `arguments` value (json.loads(None)). input_data = {} else: # Generic fallback @@ -490,6 +492,12 @@ def parse_tool_call( if name != CCR_TOOL_NAME: return None + # A CCR-named tool call whose decoded arguments/input are not an object + # (a JSON array/string/number, or a non-dict Anthropic `input`) is simply + # not a valid CCR call — return None instead of crashing on `.get`. + if not isinstance(input_data, dict): + return None + hash_key = input_data.get("hash") if hash_key is None: return None diff --git a/tests/test_ccr_tool_injection.py b/tests/test_ccr_tool_injection.py index 294b725e5..7a781f7db 100644 --- a/tests/test_ccr_tool_injection.py +++ b/tests/test_ccr_tool_injection.py @@ -313,6 +313,23 @@ class TestParseToolCall: assert hash_key is None + def test_parse_openai_non_object_arguments_returns_none(self): + """OpenAI arguments that decode to a non-object (array/string/number) + must return None, not crash on `.get`.""" + for args in ("[]", '"abc"', "123"): + tool_call = {"function": {"name": CCR_TOOL_NAME, "arguments": args}} + assert parse_tool_call(tool_call, "openai") is None + + def test_parse_openai_null_arguments_returns_none(self): + """A null `arguments` value (json.loads(None) -> TypeError) is handled.""" + tool_call = {"function": {"name": CCR_TOOL_NAME, "arguments": None}} + assert parse_tool_call(tool_call, "openai") is None + + def test_parse_anthropic_non_dict_input_returns_none(self): + """A non-dict Anthropic `input` must return None, not crash.""" + tool_call = {"name": CCR_TOOL_NAME, "input": ["not", "a", "dict"]} + assert parse_tool_call(tool_call, "anthropic") is None + class TestHashSecurityValidation: """Test hash validation security measures.