spacehub

surshar / claude-code

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

Built by Anthropic · github.com/anthropics

claude-code / plugins / security-guidance / tests / test_review_model.py
404 lines · 16.5 KBRaw
1"""The default review model follows the newest Opus instead of a pinned release."""
2import http.server
3import json
4import sys
5import threading
6import time
7import types
8
9import pytest
10
11from conftest import (
12 VULN_PY, edit_payload, metrics_of, run_hook, stop_payload, ups_payload,
13)
14
15import llm
16
17SCHEMA = {"type": "object", "properties": {"hasVulnerabilities": {"type": "boolean"}}}
18PINNED = "claude-opus-5-5"
19
20
21class _Api(http.server.BaseHTTPRequestHandler):
22 def do_GET(self):
23 self.server.gets.append((self.path, dict(self.headers)))
24 if self.server.models_status != 200:
25 return self._send(self.server.models_status, {"type": "error"})
26 self._send(200, self.server.models_body)
27
28 def do_POST(self):
29 n = int(self.headers.get("Content-Length") or 0)
30 body = json.loads(self.rfile.read(n))
31 self.server.posts.append(body)
32 self._send(200, {
33 "id": "msg_stub", "type": "message", "role": "assistant",
34 "model": body["model"], "stop_reason": "end_turn",
35 "content": [{"type": "text", "text": json.dumps(
36 {"hasVulnerabilities": False, "vulnerabilities": []})}],
37 "usage": {"input_tokens": 1, "output_tokens": 1},
38 })
39
40 def _send(self, status, payload):
41 body = json.dumps(payload).encode()
42 self.send_response(status)
43 self.send_header("Content-Type", "application/json")
44 self.send_header("Content-Length", str(len(body)))
45 self.end_headers()
46 self.wfile.write(body)
47
48 def log_message(self, *a):
49 pass
50
51
52def _models(*ids):
53 return {"data": [{"type": "model", "id": i} for i in ids], "has_more": False}
54
55
56@pytest.fixture
57def api(tmp_path, monkeypatch):
58 srv = http.server.HTTPServer(("127.0.0.1", 0), _Api)
59 srv.gets, srv.posts = [], []
60 srv.models_status = 200
61 srv.models_body = _models(
62 "claude-fable-5-1", "claude-opus-5-5", "claude-sonnet-5-5", "claude-opus-5",
63 "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-5-20251101",
64 )
65 threading.Thread(target=srv.serve_forever, daemon=True).start()
66 for var in ("SECURITY_REVIEW_MODEL", "SG_AGENTIC_MODEL", "SG_DUAL_OR",
67 "CLAUDE_CODE_EXECPATH", "SG_AGENTIC_CLI_PATH", *llm._PROVIDER_ENV_VARS):
68 monkeypatch.delenv(var, raising=False)
69 monkeypatch.setenv("ANTHROPIC_BASE_URL", f"http://127.0.0.1:{srv.server_port}")
70 monkeypatch.setenv("SECURITY_WARNINGS_STATE_DIR", str(tmp_path / "state"))
71 monkeypatch.setenv("NO_PROXY", "*")
72 monkeypatch.setenv("no_proxy", "*")
73 monkeypatch.setattr(llm, "ANTHROPIC_API_KEY", "test-key")
74 monkeypatch.setattr(llm, "ANTHROPIC_AUTH_TOKEN", "")
75 monkeypatch.setattr(llm, "HAS_API_CREDENTIALS", True)
76 monkeypatch.setattr(llm, "_auth_prefer_token", False)
77 monkeypatch.setattr(llm, "_review_model_memo", None, raising=False)
78 try:
79 yield srv
80 finally:
81 srv.shutdown()
82
83
84def _new_process(monkeypatch):
85 """Each hook fire is a fresh process: only the on-disk cache carries over."""
86 monkeypatch.setattr(llm, "_review_model_memo", None, raising=False)
87
88
89def _review():
90 return llm._call_claude_dual_or(
91 "review this", SCHEMA,
92 bool_key="hasVulnerabilities", list_key="vulnerabilities",
93 )
94
95
96class TestNewestOpus:
97 def test_highest_version_wins_whatever_the_order(self):
98 ids = ["claude-opus-4-7", "claude-opus-5-5", "claude-opus-5", "claude-opus-4-8"]
99 assert llm._newest_opus(ids) == "claude-opus-5-5"
100 assert llm._newest_opus(reversed(ids)) == "claude-opus-5-5"
101
102 def test_minor_version_compares_as_a_number(self):
103 assert llm._newest_opus(["claude-opus-5-9", "claude-opus-5-10"]) == "claude-opus-5-10"
104
105 def test_snapshot_date_is_not_a_version(self):
106 ids = ["claude-opus-4-20250514", "claude-opus-4-1-20250805", "claude-opus-4-5-20251101"]
107 assert llm._newest_opus(ids) == "claude-opus-4-5-20251101"
108
109 def test_other_families_and_malformed_ids_are_ignored(self):
110 ids = [
111 "claude-fable-5-1", "claude-sonnet-5-5", "claude-haiku-4-5",
112 "claude-opus-9-preview", "bedrock/claude-opus-9", "claude-opus-9 --flag",
113 "us.anthropic.claude-opus-9", None, 7, "claude-opus-4-7",
114 ]
115 assert llm._newest_opus(ids) == "claude-opus-4-7"
116
117 def test_no_opus_listed(self):
118 assert llm._newest_opus(["claude-sonnet-5-5", "gpt-x"]) is None
119
120
121class TestReviewRequestModel:
122 def test_review_uses_the_newest_opus_the_endpoint_lists(self, api):
123 assert _review() == {"hasVulnerabilities": False, "vulnerabilities": []}
124 assert [p["model"] for p in api.posts] == ["claude-opus-5-5"]
125
126 def test_a_release_newer_than_the_pinned_default_is_picked_up(self, api):
127 api.models_body = _models("claude-opus-6", "claude-opus-5-5")
128 _review()
129 assert [p["model"] for p in api.posts] == ["claude-opus-6"]
130
131 def test_gateway_listing_only_an_older_opus_gets_that_one(self, api):
132 api.models_body = _models("claude-opus-4-8", "claude-sonnet-5")
133 _review()
134 assert [p["model"] for p in api.posts] == ["claude-opus-4-8"]
135
136 def test_truncated_listing_does_not_pick_an_older_opus(self, api):
137 api.models_body = {**_models("org-model-2", "org-model-1", "claude-opus-4-7"),
138 "has_more": True}
139 _review()
140 assert [p["model"] for p in api.posts] == [PINNED]
141
142 def test_truncated_listing_still_picks_a_newer_opus(self, api):
143 api.models_body = {**_models("claude-opus-6", "org-model-1"), "has_more": True}
144 _review()
145 assert [p["model"] for p in api.posts] == ["claude-opus-6"]
146
147 def test_lookup_asks_for_one_modest_page(self, api):
148 _review()
149 assert api.gets[0][0] == "/v1/models?limit=100"
150
151 def test_lookup_authenticates_like_the_review_call(self, api):
152 _review()
153 path, headers = api.gets[0]
154 assert path.startswith("/v1/models")
155 lowered = {k.lower(): v for k, v in headers.items()}
156 assert lowered["x-api-key"] == "test-key"
157 assert lowered["anthropic-version"] == "2023-06-01"
158
159 def test_oauth_only_session_sends_the_bearer_token(self, api, monkeypatch):
160 monkeypatch.setattr(llm, "ANTHROPIC_API_KEY", "")
161 monkeypatch.setattr(llm, "ANTHROPIC_AUTH_TOKEN", "oauth-token")
162 _review()
163 lowered = {k.lower(): v for k, v in api.gets[0][1].items()}
164 assert lowered["authorization"] == "Bearer oauth-token"
165 assert "oauth-2025-04-20" in lowered["anthropic-beta"]
166 assert "x-api-key" not in lowered
167
168 def test_explicit_model_is_used_as_is_without_a_lookup(self, api, monkeypatch):
169 monkeypatch.setenv("SECURITY_REVIEW_MODEL", "claude-sonnet-5")
170 _review()
171 assert [p["model"] for p in api.posts] == ["claude-sonnet-5"]
172 assert api.gets == []
173
174
175class TestLookupFailure:
176 @pytest.mark.parametrize("status", [401, 403, 404, 500])
177 def test_falls_back_to_the_pinned_default(self, api, status):
178 api.models_status = status
179 _review()
180 assert [p["model"] for p in api.posts] == [PINNED]
181
182 @pytest.mark.parametrize("body", [
183 {"data": []}, {"data": "nope"}, {"object": "list"}, [], "text",
184 {"data": [{"id": "gpt-x"}, {"name": "claude-opus-9"}, "claude-opus-9"]},
185 ])
186 def test_unusable_listing_falls_back_to_the_pinned_default(self, api, body):
187 api.models_body = body
188 assert llm.default_review_model() == PINNED
189
190 def test_unreachable_endpoint_falls_back_to_the_pinned_default(self, api, monkeypatch):
191 monkeypatch.setenv("ANTHROPIC_BASE_URL", "http://127.0.0.1:1")
192 assert llm.default_review_model() == PINNED
193
194 def test_no_credentials_means_no_lookup(self, api, monkeypatch):
195 monkeypatch.setattr(llm, "HAS_API_CREDENTIALS", False)
196 assert llm.default_review_model() == PINNED
197 assert api.gets == []
198
199 def test_failure_is_not_retried_on_every_hook_fire(self, api, monkeypatch):
200 api.models_status = 500
201 assert llm.default_review_model() == PINNED
202 _new_process(monkeypatch)
203 assert llm.default_review_model() == PINNED
204 assert len(api.gets) == 1
205
206 def test_failure_is_retried_after_the_retry_window(self, api, monkeypatch):
207 api.models_status = 500
208 assert llm.default_review_model() == PINNED
209 api.models_status = 200
210 later = time.time() + llm._REVIEW_MODEL_RETRY_SECONDS + 1
211 monkeypatch.setattr(llm.time, "time", lambda: later)
212 _new_process(monkeypatch)
213 assert llm.default_review_model() == "claude-opus-5-5"
214
215
216class TestCache:
217 def test_one_lookup_serves_later_hook_fires(self, api, monkeypatch):
218 assert llm.default_review_model() == "claude-opus-5-5"
219 _new_process(monkeypatch)
220 api.models_body = _models("claude-opus-6")
221 assert llm.default_review_model() == "claude-opus-5-5"
222 assert len(api.gets) == 1
223
224 def test_one_lookup_per_process(self, api):
225 llm.default_review_model()
226 llm.default_review_model()
227 assert len(api.gets) == 1
228
229 def test_new_release_is_seen_once_the_cache_expires(self, api, monkeypatch):
230 assert llm.default_review_model() == "claude-opus-5-5"
231 api.models_body = _models("claude-opus-6", "claude-opus-5-5")
232 later = time.time() + llm._REVIEW_MODEL_TTL_SECONDS + 1
233 monkeypatch.setattr(llm.time, "time", lambda: later)
234 _new_process(monkeypatch)
235 assert llm.default_review_model() == "claude-opus-6"
236
237 def test_cache_from_another_endpoint_is_not_reused(self, api, monkeypatch):
238 assert llm.default_review_model() == "claude-opus-5-5"
239 other = http.server.HTTPServer(("127.0.0.1", 0), _Api)
240 other.gets, other.posts = [], []
241 other.models_status = 200
242 other.models_body = _models("claude-opus-4-8")
243 threading.Thread(target=other.serve_forever, daemon=True).start()
244 try:
245 monkeypatch.setenv("ANTHROPIC_BASE_URL", f"http://127.0.0.1:{other.server_port}")
246 _new_process(monkeypatch)
247 assert llm.default_review_model() == "claude-opus-4-8"
248 finally:
249 other.shutdown()
250
251 @pytest.mark.parametrize("model", [
252 "claude-opus-9 --dangerously-skip-permissions", "../../etc/passwd",
253 "claude-fable-5-1", 7, ["claude-opus-9"],
254 ])
255 def test_tampered_cache_entry_is_ignored(self, api, monkeypatch, tmp_path, model):
256 assert llm.default_review_model() == "claude-opus-5-5"
257 cache = tmp_path / "state" / llm._REVIEW_MODEL_CACHE_FILE
258 entry = json.loads(cache.read_text())
259 entry["model"] = model
260 cache.write_text(json.dumps(entry))
261 _new_process(monkeypatch)
262 assert llm.default_review_model() == "claude-opus-5-5"
263 assert len(api.gets) == 2
264
265 @pytest.mark.parametrize("content", ["", "not json", "[]", '{"at": "soon"}', '{"at": 1e999}'])
266 def test_corrupt_cache_file_is_ignored(self, api, monkeypatch, tmp_path, content):
267 (tmp_path / "state").mkdir()
268 (tmp_path / "state" / llm._REVIEW_MODEL_CACHE_FILE).write_text(content)
269 assert llm.default_review_model() == "claude-opus-5-5"
270
271 def test_future_dated_cache_entry_is_ignored(self, api, monkeypatch, tmp_path):
272 assert llm.default_review_model() == "claude-opus-5-5"
273 cache = tmp_path / "state" / llm._REVIEW_MODEL_CACHE_FILE
274 entry = json.loads(cache.read_text())
275 entry["at"] = time.time() + 10 * 365 * 24 * 3600
276 cache.write_text(json.dumps(entry))
277 api.models_body = _models("claude-opus-6")
278 _new_process(monkeypatch)
279 assert llm.default_review_model() == "claude-opus-6"
280
281 def test_unwritable_state_dir_still_resolves(self, api, monkeypatch, tmp_path):
282 blocker = tmp_path / "file"
283 blocker.write_text("x")
284 monkeypatch.setenv("SECURITY_WARNINGS_STATE_DIR", str(blocker / "state"))
285 assert llm.default_review_model() == "claude-opus-5-5"
286
287 def test_endpoint_url_is_not_written_to_disk(self, api, monkeypatch, tmp_path):
288 llm.default_review_model()
289 cache = (tmp_path / "state" / llm._REVIEW_MODEL_CACHE_FILE).read_text()
290 assert "127.0.0.1" not in cache
291
292
293@pytest.fixture
294def fake_sdk(monkeypatch):
295 """Stand-in for claude_agent_sdk that records the options of each spawn."""
296 spawned = []
297
298 class ClaudeAgentOptions:
299 def __init__(self, **kwargs):
300 self.kwargs = kwargs
301 spawned.append(kwargs)
302
303 class AssistantMessage:
304 pass
305
306 class ResultMessage:
307 subtype = "success"
308 usage = {}
309 total_cost_usd = 0.0
310 structured_output = {"findings": [], "hasVulnerabilities": False,
311 "vulnerabilities": []}
312
313 async def query(prompt, options):
314 yield ResultMessage()
315
316 sdk = types.ModuleType("claude_agent_sdk")
317 sdk.ClaudeAgentOptions = ClaudeAgentOptions
318 sdk.AssistantMessage = AssistantMessage
319 sdk.ResultMessage = ResultMessage
320 sdk.query = query
321 monkeypatch.setitem(sys.modules, "claude_agent_sdk", sdk)
322 return spawned
323
324
325class TestSdkPaths:
326 """Reviews that run through the Agent SDK name the CLI's own `opus` alias,
327 so the CLI that drives the model is the one that picks it."""
328
329 def test_agentic_review_defaults_to_the_cli_alias(self, api, fake_sdk, tmp_path):
330 llm.agentic_review(str(tmp_path), [("a.py", "+x = 1\n")], ["a.py"])
331 assert fake_sdk[0]["model"] == "opus"
332 assert fake_sdk[0]["fallback_model"] is None
333 assert api.gets == []
334
335 def test_agentic_review_override_falls_back_to_the_alias(self, api, fake_sdk,
336 tmp_path, monkeypatch):
337 monkeypatch.setenv("SG_AGENTIC_MODEL", "claude-sonnet-5")
338 llm.agentic_review(str(tmp_path), [("a.py", "+x = 1\n")], ["a.py"])
339 assert fake_sdk[0]["model"] == "claude-sonnet-5"
340 assert fake_sdk[0]["fallback_model"] == "opus"
341
342 @pytest.mark.parametrize("provider_var", [
343 "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY",
344 ])
345 def test_3p_review_defaults_to_the_cli_alias(self, api, fake_sdk, monkeypatch,
346 provider_var):
347 monkeypatch.setenv(provider_var, "1")
348 _review()
349 assert fake_sdk[0]["model"] == "opus"
350 assert fake_sdk[0]["fallback_model"] is None
351 assert api.gets == [] and api.posts == []
352
353 def test_3p_review_keeps_an_explicit_provider_id(self, api, fake_sdk, monkeypatch):
354 monkeypatch.setenv("CLAUDE_CODE_USE_BEDROCK", "1")
355 monkeypatch.setenv("SECURITY_REVIEW_MODEL", "us.anthropic.claude-opus-4-7")
356 _review()
357 assert fake_sdk[0]["model"] == "us.anthropic.claude-opus-4-7"
358
359
360class TestStopHookProcess:
361 """The hook as Claude Code runs it: one process per fire, config from env."""
362
363 def _env(self, api, hook_env):
364 env = dict(hook_env)
365 env["ANTHROPIC_BASE_URL"] = f"http://127.0.0.1:{api.server_port}"
366 env.pop("SECURITY_REVIEW_MODEL", None)
367 return env
368
369 def _edit_then_stop(self, repo, env, session_id):
370 run_hook(ups_payload(repo, session_id=session_id), env)
371 (repo / "app.py").write_text(VULN_PY + f"# {session_id}\n")
372 run_hook(edit_payload(repo, repo / "app.py", VULN_PY, session_id=session_id), env)
373 rc, so, se = run_hook(stop_payload(repo, session_id=session_id), env)
374 m = metrics_of(so)
375 assert m.get("skip_reason") is None and m["files_reviewed"] == 1, (m, se)
376
377 def test_stop_review_runs_on_the_newest_opus(self, api, hook_env, workspace):
378 _, repo = workspace
379 env = self._env(api, hook_env)
380 self._edit_then_stop(repo, env, "s1")
381 assert [p["model"] for p in api.posts] == ["claude-opus-5-5"]
382 self._edit_then_stop(repo, env, "s2")
383 assert [p["model"] for p in api.posts] == ["claude-opus-5-5"] * 2
384 assert len(api.gets) == 1
385
386 def test_stop_review_honors_the_override(self, api, hook_env, workspace):
387 _, repo = workspace
388 env = self._env(api, hook_env)
389 env["SECURITY_REVIEW_MODEL"] = "claude-sonnet-5"
390 self._edit_then_stop(repo, env, "s1")
391 assert [p["model"] for p in api.posts] == ["claude-sonnet-5"]
392 assert api.gets == []
393
394
395class TestPricing:
396 def test_current_opus_is_not_priced_as_the_unknown_model_default(self):
397 import _base
398 with _base._USAGE_LOCK:
399 before = _base._USAGE["cost"]
400 _base._record_usage({"input_tokens": 1_000_000, "output_tokens": 1_000_000},
401 "claude-opus-5-5")
402 with _base._USAGE_LOCK:
403 after = _base._USAGE["cost"]
404 assert after - before == pytest.approx(24.0)