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
| 1 | """Regression tests for anthropics/claude-code#96276: the reviewer must not |
| 2 | put files the session's Read deny/ask rules cover (or well-known secret |
| 3 | files) into a review prompt, and its SDK sub-agents must carry the same |
| 4 | rules as disallowed_tools. |
| 5 | |
| 6 | Run: python3 -m unittest discover -s plugins/security-guidance/tests -v |
| 7 | """ |
| 8 | |
| 9 | import json |
| 10 | import os |
| 11 | import shutil |
| 12 | import subprocess |
| 13 | import sys |
| 14 | import tempfile |
| 15 | import types |
| 16 | import unittest |
| 17 | |
| 18 | HOOKS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "hooks") |
| 19 | sys.path.insert(0, HOOKS_DIR) |
| 20 | |
| 21 | _tmp_state = tempfile.mkdtemp(prefix="sg-test-state-") |
| 22 | os.environ["SECURITY_WARNINGS_STATE_DIR"] = _tmp_state |
| 23 | os.environ["SECURITY_GUIDANCE_DEBUG_LOG"] = os.path.join(_tmp_state, "log.txt") |
| 24 | |
| 25 | import gitutil # noqa: E402 |
| 26 | import llm # noqa: E402 |
| 27 | import secretpaths # noqa: E402 |
| 28 | |
| 29 | SHELL_TOOLS = ["Bash"] + (["PowerShell"] if sys.platform == "win32" else []) |
| 30 | |
| 31 | |
| 32 | def tearDownModule(): |
| 33 | shutil.rmtree(_tmp_state, ignore_errors=True) |
| 34 | |
| 35 | |
| 36 | def _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 | |
| 49 | class _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 | |
| 96 | class 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 | |
| 192 | class 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 | |
| 226 | class 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 | |
| 262 | class _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 | |
| 295 | class 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 | |
| 316 | if __name__ == "__main__": |
| 317 | unittest.main() |