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_secretpaths.py
317 lines · 14.2 KBRaw
1"""Regression tests for anthropics/claude-code#96276: the reviewer must not
2put files the session's Read deny/ask rules cover (or well-known secret
3files) into a review prompt, and its SDK sub-agents must carry the same
4rules as disallowed_tools.
5
6Run: python3 -m unittest discover -s plugins/security-guidance/tests -v
7"""
8
9import json
10import os
11import shutil
12import subprocess
13import sys
14import tempfile
15import types
16import unittest
17
18HOOKS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "hooks")
19sys.path.insert(0, HOOKS_DIR)
20
21_tmp_state = tempfile.mkdtemp(prefix="sg-test-state-")
22os.environ["SECURITY_WARNINGS_STATE_DIR"] = _tmp_state
23os.environ["SECURITY_GUIDANCE_DEBUG_LOG"] = os.path.join(_tmp_state, "log.txt")
24
25import gitutil # noqa: E402
26import llm # noqa: E402
27import secretpaths # noqa: E402
28
29SHELL_TOOLS = ["Bash"] + (["PowerShell"] if sys.platform == "win32" else [])
30
31
32def tearDownModule():
33 shutil.rmtree(_tmp_state, ignore_errors=True)
34
35
36def _diff(*paths):
37 """A minimal unified diff touching each path with one added line."""
38 out = []
39 for p in paths:
40 out.append(
41 f"diff --git a/{p} b/{p}\n"
42 f"--- a/{p}\n+++ b/{p}\n"
43 "@@ -0,0 +1,1 @@\n"
44 f"+value_for_{p.replace('/', '_')} = \"DUMMY-SECRET\"\n"
45 )
46 return "".join(out)
47
48
49class _Env:
50 """Isolated project, config and managed dirs; restores env and module state."""
51
52 def __init__(self, project_settings=None, user_settings=None, local_settings=None,
53 managed_settings=None):
54 self.project_settings = project_settings
55 self.user_settings = user_settings
56 self.local_settings = local_settings
57 self.managed_settings = managed_settings
58
59 def __enter__(self):
60 self._saved = dict(os.environ)
61 self._saved_managed_dir = secretpaths._managed_settings_dir
62 self.root = os.path.realpath(tempfile.mkdtemp(prefix="sg-test-"))
63 self.project = os.path.join(self.root, "project")
64 self.config = os.path.join(self.root, "config-dir")
65 self.managed = os.path.join(self.root, "managed")
66 os.makedirs(os.path.join(self.project, ".claude"))
67 os.makedirs(self.config)
68 os.makedirs(os.path.join(self.managed, "managed-settings.d"))
69 secretpaths._managed_settings_dir = lambda: self.managed
70 if self.managed_settings is not None:
71 with open(os.path.join(self.managed, "managed-settings.d", "10-sec.json"), "w") as f:
72 json.dump(self.managed_settings, f)
73 if self.project_settings is not None:
74 with open(os.path.join(self.project, ".claude", "settings.json"), "w") as f:
75 json.dump(self.project_settings, f)
76 if self.local_settings is not None:
77 with open(os.path.join(self.project, ".claude", "settings.local.json"), "w") as f:
78 json.dump(self.local_settings, f)
79 if self.user_settings is not None:
80 with open(os.path.join(self.config, "settings.json"), "w") as f:
81 json.dump(self.user_settings, f)
82 os.environ["CLAUDE_CONFIG_DIR"] = self.config
83 os.environ["CLAUDE_PROJECT_DIR"] = self.project
84 os.environ.pop("SG_SKIP_SECRET_FILES", None)
85 secretpaths.load_for_session(self.project)
86 return self
87
88 def __exit__(self, *exc):
89 os.environ.clear()
90 os.environ.update(self._saved)
91 secretpaths._managed_settings_dir = self._saved_managed_dir
92 secretpaths.load_for_session(None)
93 shutil.rmtree(self.root, ignore_errors=True)
94
95
96class DiffFilterTests(unittest.TestCase):
97 DIFF = _diff(
98 "app.py",
99 "config/prod.json", # denied by Read(./config/**)
100 "docs/config/notes.json", # also denied: Claude Code reads `x/**` as `x`, any depth
101 "docs/settings/ui.json", # Read(./settings/*.json) is anchored: NOT denied
102 "private/report.json", # ask rule Read(/private/**) → excluded
103 "secrets.yaml", # well-known secret store name
104 "deploy/credentials", # well-known secret store name (extensionless)
105 "keys/server.pem", # already non-reviewable by extension
106 "src/secrets.py", # code that handles secrets: reviewable
107 )
108
109 def _reviewed(self):
110 return [p for p, _ in gitutil.parse_diff_into_files(self.DIFF)]
111
112 def test_denied_and_secret_files_are_dropped_from_review_diff(self):
113 settings = {"permissions": {"deny": ["Read(./config/**)", "Read(./settings/*.json)",
114 "Bash(curl:*)"],
115 "ask": ["Read(/private/**)"]}}
116 with _Env(project_settings=settings):
117 self.assertEqual(
118 self._reviewed(),
119 ["app.py", "docs/settings/ui.json", "src/secrets.py"],
120 )
121
122 def test_opt_out_restores_secret_named_files_but_not_denied_ones(self):
123 settings = {"permissions": {"deny": ["Read(./config/**)"]}}
124 with _Env(project_settings=settings):
125 os.environ["SG_SKIP_SECRET_FILES"] = "0"
126 self.assertEqual(
127 self._reviewed(),
128 ["app.py", "docs/settings/ui.json", "private/report.json",
129 "secrets.yaml", "deploy/credentials", "src/secrets.py"],
130 )
131
132 def test_no_settings_still_skips_well_known_secret_files(self):
133 with _Env():
134 reviewed = self._reviewed()
135 self.assertIn("config/prod.json", reviewed)
136 self.assertNotIn("secrets.yaml", reviewed)
137 self.assertNotIn("deploy/credentials", reviewed)
138
139 def test_bare_read_deny_excludes_everything(self):
140 with _Env(local_settings={"permissions": {"deny": ["Read"]}}):
141 self.assertEqual(self._reviewed(), [])
142
143 def test_user_settings_slash_rule_anchors_at_config_dir_not_project(self):
144 # `/x` in user settings means <config dir>/x, so it must not hide the
145 # project's reports/ — but `~/` and `//` rules reach anywhere.
146 user = {"permissions": {"deny": ["Read(/reports/**)", "Read(//**/vault/*.json)"]}}
147 with _Env(user_settings=user):
148 reviewed = [p for p, _ in gitutil.parse_diff_into_files(
149 _diff("reports/q3.json", "app/vault/keys.json", "app.py"))]
150 self.assertEqual(reviewed, ["reports/q3.json", "app.py"])
151
152 def test_managed_dropin_rules_and_utf8_bom_are_honored(self):
153 with _Env(managed_settings={"permissions": {"deny": ["Read(/private/**)"]}}) as env:
154 # PowerShell 5 writes settings.json with a BOM; Claude Code strips it.
155 with open(os.path.join(env.project, ".claude", "settings.json"), "w", encoding="utf-8-sig") as f:
156 json.dump({"permissions": {"deny": ["Read(./config/**)"]}}, f)
157 secretpaths.load_for_session(env.project)
158 reviewed = self._reviewed()
159 self.assertNotIn("private/report.json", reviewed)
160 self.assertNotIn("config/prod.json", reviewed)
161 self.assertIn("app.py", reviewed)
162
163 def test_empty_patterns_match_nothing_instead_of_everything(self):
164 with _Env(project_settings={"permissions": {"deny": ["Read(./)", "Read(/)", "Read(~/)"]}}):
165 self.assertIn("app.py", self._reviewed())
166
167 def test_worktree_session_reads_local_settings_at_canonical_root(self):
168 with _Env() as env:
169 git = ["git", "-c", "user.email=t@example.com", "-c", "user.name=t"]
170 run = lambda *a, cwd=env.project: subprocess.run([*git, *a], cwd=cwd, check=True,
171 capture_output=True)
172 run("init", "-q")
173 run("commit", "-q", "--allow-empty", "-m", "init")
174 worktree = os.path.join(env.root, "wt")
175 run("worktree", "add", "-q", worktree)
176 # Claude Code keeps settings.local.json at the main repo root.
177 with open(os.path.join(env.project, ".claude", "settings.local.json"), "w") as f:
178 json.dump({"permissions": {"deny": ["Read(./config/**)"]}}, f)
179 os.environ["CLAUDE_PROJECT_DIR"] = worktree
180 secretpaths.load_for_session(worktree)
181 self.assertNotIn("config/prod.json", self._reviewed())
182 self.assertIn("app.py", self._reviewed())
183
184 def test_malformed_settings_file_is_ignored(self):
185 with _Env() as env:
186 with open(os.path.join(env.project, ".claude", "settings.json"), "w") as f:
187 f.write("{not json")
188 secretpaths.load_for_session(env.project)
189 self.assertIn("app.py", self._reviewed())
190
191
192class PatternSemanticsTests(unittest.TestCase):
193 CASES = [
194 # (pattern, root-relative path, matches)
195 ("secrets.yaml", "secrets.yaml", True),
196 ("secrets.yaml", "a/b/secrets.yaml", True), # slashless → any depth
197 ("secrets.yaml", "SECRETS.YAML", True), # case-insensitive, like Claude Code
198 ("config/*.json", "config/a.json", True),
199 ("config/*.json", "x/config/a.json", False), # contains slash → anchored
200 ("config/*.json", "config/sub/a.json", False), # * does not cross /
201 ("config/**", "config/sub/a.json", True),
202 ("config/**", "docs/config/a.json", True), # Claude Code strips /** → slashless
203 ("/config/**", "docs/config/a.json", False),
204 ("config", "config/sub/a.json", True), # dir name covers contents
205 ("/private", "private/x", True),
206 ("/private", "docs/private/x", False),
207 ("**/vault/*.json", "a/b/vault/k.json", True),
208 ("**/vault/*.json", "vault/k.json", True),
209 ("*.pem", "keys/a.PEM", True),
210 ("id_?sa", "home/id_rsa", True),
211 ("key[0-9]", "key7", True),
212 ("key[!0-9]", "key7", False),
213 (r"a\*b", "a*b", True),
214 (r"a\*b", "axb", False),
215 ("docs/", "docs/readme.md", True),
216 ("!keep.json", "keep.json", False), # negation never excludes
217 ]
218
219 def test_gitignore_style_matching(self):
220 for pattern, path, expected in self.CASES:
221 with self.subTest(pattern=pattern, path=path):
222 rx = secretpaths._compile(pattern)
223 self.assertEqual(bool(rx and rx.match(path)), expected)
224
225
226class SubagentDisallowedToolsTests(unittest.TestCase):
227 def test_rules_are_forwarded_with_absolute_anchors(self):
228 settings = {"permissions": {
229 "deny": ["Read(./config/**)", "Read(/private/**)", "Read(~/.ssh/**)",
230 "Read(//etc/shadow)", "Grep", "Bash(curl:*)", "Edit(**)"],
231 "ask": ["Read(*.tfstate)"],
232 }}
233 with _Env(project_settings=settings) as env:
234 tools = secretpaths.subagent_disallowed_tools(env.project)
235 proj = env.project.strip("/")
236 for expected in (*SHELL_TOOLS, "Read(./config/**)", f"Read(//{proj}/private/**)",
237 "Read(~/.ssh/**)", "Read(//etc/shadow)", "Grep",
238 "Read(*.tfstate)", "Read(.env)", "Read(id_rsa*)",
239 "Read(secrets.yaml)", "Read(credentials.json)"):
240 self.assertIn(expected, tools)
241 # Only read-path rules are forwarded; other tools' rules are not ours.
242 self.assertFalse(any(t.startswith(("Bash(", "Edit")) for t in tools))
243 # Sub-agent rooted at an alternate checkout: the verbatim relative
244 # rule covers that root, the session cwd gets an absolute copy, and
245 # the project-anchored rule is mirrored into the checkout.
246 alt = os.path.join(env.root, "alt-checkout")
247 elsewhere = secretpaths.subagent_disallowed_tools(alt)
248 for expected in (f"Read(//{proj}/config/**)", f"Read(//{alt.strip('/')}/private/**)"):
249 self.assertIn(expected, elsewhere)
250 self.assertNotIn(expected, tools)
251
252 def test_opt_out_drops_only_the_builtin_globs(self):
253 with _Env(project_settings={"permissions": {"deny": ["Read(./config/**)"]}}) as env:
254 os.environ["SG_SKIP_SECRET_FILES"] = "0"
255 self.assertEqual(secretpaths.subagent_disallowed_tools(env.project),
256 SHELL_TOOLS + ["Read(./config/**)"])
257
258 def test_diff_only_call_gets_no_file_or_shell_tools(self):
259 self.assertEqual(secretpaths.no_file_tools(), SHELL_TOOLS + ["Read", "Grep", "Glob"])
260
261
262class _FakeSDK(types.ModuleType):
263 """Stand-in for claude_agent_sdk that records the options each query got."""
264
265 def __init__(self):
266 super().__init__("claude_agent_sdk")
267 self.options_seen = []
268 sdk = self
269
270 class ClaudeAgentOptions:
271 def __init__(self, **kwargs):
272 self.__dict__.update(kwargs)
273 sdk.options_seen.append(kwargs)
274
275 class AssistantMessage:
276 pass
277
278 class ResultMessage:
279 subtype = "success"
280 structured_output = {"findings": []}
281 usage = None
282 total_cost_usd = None
283
284 async def query(prompt, options):
285 async for _ in prompt:
286 pass
287 yield ResultMessage()
288
289 self.ClaudeAgentOptions = ClaudeAgentOptions
290 self.AssistantMessage = AssistantMessage
291 self.ResultMessage = ResultMessage
292 self.query = query
293
294
295class AgenticReviewWiringTests(unittest.TestCase):
296 def test_agentic_review_passes_deny_rules_as_disallowed_tools(self):
297 fake = _FakeSDK()
298 saved = sys.modules.get("claude_agent_sdk")
299 sys.modules["claude_agent_sdk"] = fake
300 try:
301 with _Env(project_settings={"permissions": {"deny": ["Read(./config/**)"]}}) as env:
302 os.environ["SG_AGENTIC_CLI_PATH"] = os.path.join(env.root, "no-such-claude")
303 llm.agentic_review(env.project, [("app.py", _diff("app.py"))], ["app.py"])
304 finally:
305 if saved is None:
306 del sys.modules["claude_agent_sdk"]
307 else:
308 sys.modules["claude_agent_sdk"] = saved
309 self.assertTrue(fake.options_seen, "agentic_review never built ClaudeAgentOptions")
310 for opts in fake.options_seen:
311 self.assertEqual(opts.get("setting_sources"), []) # still no recursive plugins/hooks
312 for expected in ("Bash", "Read(./config/**)", "Read(.env)"):
313 self.assertIn(expected, opts.get("disallowed_tools", []))
314
315
316if __name__ == "__main__":
317 unittest.main()