@@ -552,6 +552,9 @@ def test_claude_code_check_available_ready():
552552 assert msg == "ok"
553553
554554
555+ _FAKE_CLAUDE_BIN = "/usr/local/bin/claude"
556+
557+
555558def test_claude_code_classify_command_line ():
556559 captured = {}
557560
@@ -560,11 +563,15 @@ def fake_run(cmd, **kwargs):
560563 captured ["kwargs" ] = kwargs
561564 return _mock_completed (0 , stdout = _claude_envelope ('{"ok": true}' ))
562565
563- with patch ("mempalace.llm_client.subprocess.run" , side_effect = fake_run ):
564- p = ClaudeCodeProvider (model = "claude-haiku-4-5" , timeout = 99 )
565- p .classify ("system text" , "user text" , json_mode = True )
566+ with patch ("mempalace.llm_client.shutil.which" , return_value = _FAKE_CLAUDE_BIN ):
567+ with patch ("mempalace.llm_client.subprocess.run" , side_effect = fake_run ):
568+ p = ClaudeCodeProvider (model = "claude-haiku-4-5" , timeout = 99 )
569+ p .classify ("system text" , "user text" , json_mode = True )
566570
567- assert captured ["cmd" ][0 ] == "claude"
571+ # cmd[0] is the resolved absolute path from shutil.which, not a bare
572+ # "claude" literal -- avoids a TOCTOU between check_available and
573+ # classify if PATH changes between calls.
574+ assert captured ["cmd" ][0 ] == _FAKE_CLAUDE_BIN
568575 assert "-p" in captured ["cmd" ]
569576 # `--bare` is intentionally NOT passed: it would force ANTHROPIC_API_KEY
570577 # auth and disable OAuth / keychain, defeating the subscription path.
@@ -578,15 +585,22 @@ def fake_run(cmd, **kwargs):
578585 # users via `ps` / /proc/*/cmdline and may carry sensitive context.
579586 assert "--system-prompt" not in captured ["cmd" ]
580587 assert "system text" not in captured ["cmd" ]
581- # System + user are framed in stdin instead. json_mode appends a
582- # JSON-only directive to the system block.
588+ # System + user are framed in stdin with XML-like tags so a malicious
589+ # drawer cannot spoof the boundary with literal "SYSTEM:" / "USER:"
590+ # markers. json_mode appends a JSON-only directive inside the <system>
591+ # block.
583592 stdin_input = captured ["kwargs" ]["input" ]
584- assert stdin_input .startswith ("SYSTEM: \n system text" )
593+ assert stdin_input .startswith ("<system> \n system text" )
585594 assert "JSON only" in stdin_input
586- assert "\n \n USER:\n user text" in stdin_input
595+ assert "</system>\n <user>\n user text\n </user>" in stdin_input
596+ assert "SYSTEM:" not in stdin_input
597+ assert "USER:" not in stdin_input
587598 assert captured ["kwargs" ]["timeout" ] == 99
588599 # cwd must be a temp dir so claude does not pick up a project-level CLAUDE.md
589600 assert captured ["kwargs" ]["cwd" ] == tempfile .gettempdir ()
601+ # Explicit UTF-8 decoding so Windows cp1252 locale does not mojibake the
602+ # JSON envelope.
603+ assert captured ["kwargs" ]["encoding" ] == "utf-8"
590604
591605
592606def test_claude_code_classify_json_mode_off_keeps_system_clean ():
@@ -597,24 +611,64 @@ def fake_run(cmd, **kwargs):
597611 captured ["kwargs" ] = kwargs
598612 return _mock_completed (0 , stdout = _claude_envelope ("plain text reply" ))
599613
600- with patch ("mempalace.llm_client.subprocess.run" , side_effect = fake_run ):
601- p = ClaudeCodeProvider (model = "claude-haiku-4-5" )
602- resp = p .classify ("system text" , "user" , json_mode = False )
614+ with patch ("mempalace.llm_client.shutil.which" , return_value = _FAKE_CLAUDE_BIN ):
615+ with patch ("mempalace.llm_client.subprocess.run" , side_effect = fake_run ):
616+ p = ClaudeCodeProvider (model = "claude-haiku-4-5" )
617+ resp = p .classify ("system text" , "user" , json_mode = False )
603618
604619 # No JSON-only directive appended when json_mode=False; raw system
605- # text appears verbatim in the stdin SYSTEM block (not in argv).
620+ # text appears verbatim inside the <system> block (not in argv).
606621 assert "--system-prompt" not in captured ["cmd" ]
607- assert captured ["kwargs" ]["input" ] == "SYSTEM:\n system text\n \n USER:\n user"
622+ assert captured ["kwargs" ]["input" ] == (
623+ "<system>\n system text\n </system>\n <user>\n user\n </user>"
624+ )
608625 assert resp .text == "plain text reply"
609626
610627
628+ def test_claude_code_strips_anthropic_env_vars (monkeypatch ):
629+ captured = {}
630+
631+ def fake_run (cmd , ** kwargs ):
632+ captured ["env" ] = kwargs .get ("env" )
633+ return _mock_completed (0 , stdout = _claude_envelope ("ok" ))
634+
635+ monkeypatch .setenv ("ANTHROPIC_API_KEY" , "sk-test-key" )
636+ monkeypatch .setenv ("ANTHROPIC_AUTH_TOKEN" , "tok-test" )
637+ monkeypatch .setenv ("anthropic_other" , "lower" ) # case-insensitive prefix scrub
638+ monkeypatch .setenv ("UNRELATED_VAR" , "kept" )
639+
640+ with patch ("mempalace.llm_client.shutil.which" , return_value = _FAKE_CLAUDE_BIN ):
641+ with patch ("mempalace.llm_client.subprocess.run" , side_effect = fake_run ):
642+ p = ClaudeCodeProvider (model = "claude-haiku-4-5" )
643+ p .classify ("s" , "u" )
644+
645+ env = captured ["env" ]
646+ assert env is not None
647+ # ANTHROPIC_* in any case is stripped so claude -p can't fall back to
648+ # API-key auth and bill the API account instead of the subscription.
649+ assert "ANTHROPIC_API_KEY" not in env
650+ assert "ANTHROPIC_AUTH_TOKEN" not in env
651+ assert "anthropic_other" not in env
652+ # Unrelated env vars must still pass through.
653+ assert env .get ("UNRELATED_VAR" ) == "kept"
654+
655+
656+ def test_claude_code_is_external_service_true ():
657+ # claude-code routes user content to Anthropic's hosted models via the
658+ # local CLI binary, so the privacy gate (#1224) must treat it as
659+ # external regardless of the URL-based base-class default.
660+ p = ClaudeCodeProvider (model = "claude-haiku-4-5" )
661+ assert p .is_external_service is True
662+
663+
611664def test_claude_code_classify_parses_envelope ():
612- with patch (
613- "mempalace.llm_client.subprocess.run" ,
614- return_value = _mock_completed (0 , stdout = _claude_envelope ("classified" )),
615- ):
616- p = ClaudeCodeProvider (model = "claude-haiku-4-5" )
617- resp = p .classify ("s" , "u" )
665+ with patch ("mempalace.llm_client.shutil.which" , return_value = _FAKE_CLAUDE_BIN ):
666+ with patch (
667+ "mempalace.llm_client.subprocess.run" ,
668+ return_value = _mock_completed (0 , stdout = _claude_envelope ("classified" )),
669+ ):
670+ p = ClaudeCodeProvider (model = "claude-haiku-4-5" )
671+ resp = p .classify ("s" , "u" )
618672
619673 assert resp .text == "classified"
620674 assert resp .provider == "claude-code"
@@ -623,53 +677,68 @@ def test_claude_code_classify_parses_envelope():
623677
624678
625679def test_claude_code_classify_timeout_raises_llm_error ():
626- with patch (
627- "mempalace.llm_client.subprocess.run" ,
628- side_effect = subprocess .TimeoutExpired (cmd = ["claude" ], timeout = 1 ),
629- ):
630- p = ClaudeCodeProvider (model = "claude-haiku-4-5" , timeout = 1 )
631- with pytest .raises (LLMError , match = "timed out after 1s" ):
632- p .classify ("s" , "u" )
680+ with patch ("mempalace.llm_client.shutil.which" , return_value = _FAKE_CLAUDE_BIN ):
681+ with patch (
682+ "mempalace.llm_client.subprocess.run" ,
683+ side_effect = subprocess .TimeoutExpired (cmd = ["claude" ], timeout = 1 ),
684+ ):
685+ p = ClaudeCodeProvider (model = "claude-haiku-4-5" , timeout = 1 )
686+ with pytest .raises (LLMError , match = "timed out after 1s" ):
687+ p .classify ("s" , "u" )
633688
634689
635690def test_claude_code_classify_spawn_failure_raises_llm_error ():
636- with patch (
637- "mempalace.llm_client.subprocess.run" ,
638- side_effect = FileNotFoundError ("no such file: claude" ),
639- ):
691+ with patch ("mempalace.llm_client.shutil.which" , return_value = _FAKE_CLAUDE_BIN ):
692+ with patch (
693+ "mempalace.llm_client.subprocess.run" ,
694+ side_effect = FileNotFoundError ("no such file: claude" ),
695+ ):
696+ p = ClaudeCodeProvider (model = "claude-haiku-4-5" )
697+ with pytest .raises (LLMError , match = "failed to spawn" ):
698+ p .classify ("s" , "u" )
699+
700+
701+ def test_claude_code_classify_binary_missing_raises_llm_error ():
702+ # If the binary disappears between provider construction and classify,
703+ # surface a clear LLMError rather than letting subprocess raise an
704+ # opaque FileNotFoundError later.
705+ with patch ("mempalace.llm_client.shutil.which" , return_value = None ):
640706 p = ClaudeCodeProvider (model = "claude-haiku-4-5" )
641- with pytest .raises (LLMError , match = "failed to spawn " ):
707+ with pytest .raises (LLMError , match = "not found in PATH " ):
642708 p .classify ("s" , "u" )
643709
644710
645711def test_claude_code_classify_nonzero_raises_llm_error ():
646- with patch (
647- "mempalace.llm_client.subprocess.run" ,
648- return_value = _mock_completed (1 , stdout = "" , stderr = "boom: bad model" ),
649- ):
650- p = ClaudeCodeProvider (model = "claude-haiku-4-5" )
651- with pytest .raises (LLMError , match = r"`claude -p` exited 1: boom" ):
652- p .classify ("s" , "u" )
712+ with patch ("mempalace.llm_client.shutil.which" , return_value = _FAKE_CLAUDE_BIN ):
713+ with patch (
714+ "mempalace.llm_client.subprocess.run" ,
715+ return_value = _mock_completed (1 , stdout = "" , stderr = "boom: bad model" ),
716+ ):
717+ p = ClaudeCodeProvider (model = "claude-haiku-4-5" )
718+ with pytest .raises (LLMError , match = r"`claude -p` exited 1: boom" ):
719+ p .classify ("s" , "u" )
653720
654721
655722def test_claude_code_classify_malformed_json_raises_llm_error ():
656- with patch (
657- "mempalace.llm_client.subprocess.run" ,
658- return_value = _mock_completed (0 , stdout = "not valid json" ),
659- ):
660- p = ClaudeCodeProvider (model = "claude-haiku-4-5" )
661- with pytest .raises (LLMError , match = "non-JSON envelope" ):
662- p .classify ("s" , "u" )
723+ with patch ("mempalace.llm_client.shutil.which" , return_value = _FAKE_CLAUDE_BIN ):
724+ with patch (
725+ "mempalace.llm_client.subprocess.run" ,
726+ return_value = _mock_completed (0 , stdout = "not valid json" ),
727+ ):
728+ p = ClaudeCodeProvider (model = "claude-haiku-4-5" )
729+ with pytest .raises (LLMError , match = "non-JSON envelope" ):
730+ p .classify ("s" , "u" )
663731
664732
665733def test_claude_code_classify_empty_result_raises_llm_error ():
666- with patch (
667- "mempalace.llm_client.subprocess.run" ,
668- return_value = _mock_completed (0 , stdout = _claude_envelope ("" )),
669- ):
670- p = ClaudeCodeProvider (model = "claude-haiku-4-5" )
671- with pytest .raises (LLMError , match = "empty result" ):
672- p .classify ("s" , "u" )
734+ with patch ("mempalace.llm_client.shutil.which" , return_value = _FAKE_CLAUDE_BIN ):
735+ with patch (
736+ "mempalace.llm_client.subprocess.run" ,
737+ return_value = _mock_completed (0 , stdout = _claude_envelope ("" )),
738+ ):
739+ p = ClaudeCodeProvider (model = "claude-haiku-4-5" )
740+ with pytest .raises (LLMError , match = "empty result" ):
741+ p .classify ("s" , "u" )
673742
674743
675744@pytest .mark .skipif (
0 commit comments