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 / hooks / secretpaths.py
371 lines · 15.6 KBRaw
1"""Paths the security reviewer must not see.
2
3Two sources, one answer:
4
51. The session's own ``Read`` deny/ask permission rules, read from the same
6 settings files Claude Code loads (managed, user, project, project-local).
7 The main session enforces these, but the review prompt is assembled here
8 from ``git diff`` and the reviewer's SDK sub-agents run with
9 ``setting_sources=[]``, so neither knew about them.
102. A built-in list of well-known secret-file names (``.env``, private keys,
11 credential stores). On by default; ``SG_SKIP_SECRET_FILES=0`` opts out.
12
13``is_excluded()`` drops such files from every review diff (via
14``gitutil._is_reviewable_source``) and ``subagent_disallowed_tools()`` hands
15the same rules to the SDK sub-agents as ``disallowed_tools`` so they cannot
16``Read``/``Grep``/``Glob`` them, nor reach them through a shell.
17
18Not covered: rules that only exist in MDM/registry policy, server-managed
19settings, or the parent's ``--settings``/``--disallowedTools`` flags — a hook
20subprocess cannot see those. The README says so.
21"""
22
23import fnmatch
24import json
25import os
26import re
27import sys
28from typing import List, Optional, Pattern, Tuple
29
30from _base import debug_log
31
32# Basename globs (case-insensitive) for files whose purpose is to hold
33# credentials. Deliberately conservative: names that are secret stores by
34# convention, not names that merely sound sensitive. Users extend this with
35# ordinary `Read(...)` deny rules.
36SECRET_BASENAME_GLOBS = (
37 ".env", ".env.*", "*.env", ".envrc",
38 "*.pem", "*.key", "*.p12", "*.pfx", "*.jks", "*.keystore", "*.ppk", "*.kdbx",
39 "id_rsa*", "id_dsa*", "id_ecdsa*", "id_ed25519*",
40 ".netrc", "_netrc", ".pgpass", ".git-credentials", ".npmrc", ".pypirc",
41 ".htpasswd", "credentials", "kubeconfig",
42)
43# `secrets.yaml` / `credentials.json` style stores. Restricted to data
44# extensions so `secrets.py` / `credentials.ts` (code that HANDLES secrets —
45# exactly what the reviewer should read) stay reviewable.
46_SECRET_STEMS = ("secret", "secrets", "credential", "credentials")
47_DATA_EXTS = (
48 ".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".env",
49 ".properties", ".xml", ".txt",
50)
51
52# Shell tools reach file contents without consulting Read(...) rules — their
53# read-only commands (`cat`, `git show HEAD:path`) are auto-approved — and the
54# reviewer needs none of them.
55_SHELL_TOOLS = ["Bash"] + (["PowerShell"] if sys.platform == "win32" else [])
56_READ_TOOLS = ("Read", "Grep", "Glob")
57_RULE_RE = re.compile(r"^\s*([A-Za-z_][\w-]*)\s*(?:\((.*)\))?\s*$", re.DOTALL)
58
59# Session state: load_for_session() only records the cwd; the settings files
60# are read on first use so hook events that never review (per-edit pattern
61# checks) pay nothing.
62_cwd: Optional[str] = None
63_loaded = False
64# (kind, root, pattern, compiled). kind "cwd": root-less rule, relative to the
65# session cwd (also tried against the repo toplevel, where diff paths live);
66# "project": `/x`, anchored at `root`; "abs": `//x` or `~/x`, anchored at `root`.
67_rules: List[Tuple[str, str, str, Optional[Pattern[str]]]] = []
68_forward: List[str] = [] # rule strings for the sub-agent, `/x` made absolute
69_deny_all_reads = False
70_project_dir: Optional[str] = None
71_toplevel: Optional[str] = None
72_reported = set() # paths already named in the debug log
73
74
75def skip_secret_files_enabled() -> bool:
76 return os.environ.get("SG_SKIP_SECRET_FILES", "1").strip().lower() not in ("0", "off", "false", "no")
77
78
79def load_for_session(cwd: Optional[str]) -> None:
80 """Point the module at this hook invocation's cwd; rules load lazily."""
81 global _cwd, _loaded, _rules, _forward, _deny_all_reads, _project_dir, _toplevel
82 _cwd = _norm_abs(cwd) if cwd else None
83 _loaded, _rules, _forward, _deny_all_reads = False, [], [], False
84 _project_dir = _toplevel = None
85
86
87def is_excluded(file_path: str) -> bool:
88 """True if the reviewer must not see this file: a well-known secret file
89 (unless opted out) or one the session's Read deny/ask rules cover.
90 `file_path` is repo-toplevel-relative (as `git diff` prints it) or absolute.
91 Each withheld path is named once in the debug log, so a rule that hides
92 code from review is at least visible there."""
93 reason = _exclusion_reason(file_path)
94 if reason and file_path not in _reported:
95 _reported.add(file_path)
96 debug_log(f"secretpaths: withheld from review ({reason}): {file_path}")
97 return reason is not None
98
99
100def subagent_disallowed_tools(context_dir: str) -> List[str]:
101 """`disallowed_tools` for an SDK sub-agent rooted at `context_dir`: the
102 shell tools, the session's Read/Grep/Glob deny+ask rules, and the
103 secret-file globs.
104
105 The sub-agent anchors root-less and `/x` patterns at its own cwd, which
106 can differ from the session cwd and project dir (a git toplevel above
107 them, or an alternate checkout), so those rules are also sent as absolute
108 copies for every root they should cover."""
109 _ensure_loaded()
110 out = _SHELL_TOOLS + _forward
111 context = _norm_abs(context_dir)
112 session_cwd = _cwd or _project_dir
113 for kind, root, pattern, _rx in _rules:
114 if kind == "cwd": # verbatim copy already covers `context` itself
115 roots = {session_cwd, _mirrored(session_cwd, context)} - {context}
116 elif kind == "project": # _forward already covers the project dir
117 roots = {_mirrored(root, context)} - {root}
118 else:
119 continue
120 out.extend(f"Read(/{r}/{pattern})" for r in sorted(x for x in roots if x))
121 if skip_secret_files_enabled():
122 out.extend(f"Read({g})" for g in SECRET_BASENAME_GLOBS)
123 out.extend(f"Read({stem}{ext})" for stem in _SECRET_STEMS for ext in _DATA_EXTS)
124 return list(dict.fromkeys(out))
125
126
127def no_file_tools() -> List[str]:
128 """`disallowed_tools` for a sub-agent that must not touch the filesystem at all."""
129 return _SHELL_TOOLS + list(_READ_TOOLS)
130
131
132# ── internals ───────────────────────────────────────────────────────────────
133
134
135def _exclusion_reason(file_path: str) -> Optional[str]:
136 if skip_secret_files_enabled() and _is_secret_basename(os.path.basename(file_path)):
137 return "well-known secret file"
138 _ensure_loaded()
139 if _deny_all_reads:
140 return "Read denied"
141 if not _rules:
142 return None
143 abs_path = _norm_abs(file_path if os.path.isabs(file_path) else os.path.join(_toplevel or os.getcwd(), file_path))
144 for kind, root, _pattern, rx in _rules:
145 if rx is None:
146 continue
147 for base in ({_cwd or _norm_abs(os.getcwd()), _toplevel} if kind == "cwd" else {root}):
148 if not base:
149 continue
150 if not _outside(abs_path, base) and rx.match(os.path.relpath(abs_path, base).replace(os.sep, "/")):
151 return "Read deny/ask rule"
152 return None
153
154
155def _is_secret_basename(base: str) -> bool:
156 low = base.lower()
157 if any(fnmatch.fnmatchcase(low, g) for g in SECRET_BASENAME_GLOBS):
158 return True
159 stem, ext = os.path.splitext(low)
160 return stem in _SECRET_STEMS and ext in _DATA_EXTS
161
162
163def _ensure_loaded() -> None:
164 """Read Read/Grep/Glob deny+ask rules from every settings file the parent
165 session loads. A malformed file is debug-logged and skipped (Claude Code
166 does not enforce rules from a file it cannot parse either); any other
167 failure hides every file from the reviewer rather than risk showing it a
168 denied one."""
169 global _loaded, _deny_all_reads
170 if _loaded:
171 return
172 _loaded = True
173 try:
174 seen = set()
175 for path, anchor in _settings_files():
176 try:
177 # utf-8-sig: Claude Code strips a BOM before parsing, so must we.
178 with open(path, encoding="utf-8-sig") as f:
179 data = json.load(f)
180 except OSError:
181 continue
182 except ValueError as e:
183 debug_log(f"secretpaths: cannot parse {path}: {e}")
184 continue
185 perms = data.get("permissions") if isinstance(data, dict) else None
186 if not isinstance(perms, dict):
187 continue
188 # `ask` means "a human decides" — there is no human in the review
189 # sub-agent, so it counts as a deny there.
190 for key in ("deny", "ask"):
191 rules = perms.get(key)
192 for rule in rules if isinstance(rules, list) else []:
193 if isinstance(rule, str) and (rule, anchor) not in seen:
194 seen.add((rule, anchor))
195 _add_rule(rule, anchor)
196 except Exception as e:
197 debug_log(f"secretpaths: failed to load permission rules ({e}); excluding all files")
198 _deny_all_reads = True
199 if _rules or _deny_all_reads:
200 debug_log(f"secretpaths: {len(_rules)} Read deny/ask pattern(s) loaded"
201 + (" (+ deny-all Read)" if _deny_all_reads else ""))
202
203
204def _settings_files() -> List[Tuple[str, str]]:
205 """(settings file, anchor dir for its `/`-prefixed patterns) — the files
206 Claude Code itself loads: managed (+ drop-ins), user, the project dir's
207 settings.json / settings.local.json, and settings.local.json at the
208 canonical repo root (where Claude Code keeps it for linked worktrees).
209 Managed, project and local rules anchor at the project dir, user rules
210 at the config dir."""
211 global _project_dir, _toplevel
212 from gitutil import _git_dir, _git_toplevel # gitutil imports this module
213
214 config_dir = _norm_abs(os.environ.get("CLAUDE_CONFIG_DIR") or os.path.expanduser("~/.claude"))
215 _project_dir = _norm_abs(os.environ.get("CLAUDE_PROJECT_DIR") or _cwd or os.getcwd())
216 top = _git_toplevel(_cwd or _project_dir)
217 _toplevel = _norm_abs(top) if top else (_cwd or _project_dir)
218 canonical = None
219 if top:
220 common = _git_dir(top)
221 if common and os.path.basename(os.path.normpath(common)) == ".git":
222 canonical = _norm_abs(os.path.dirname(os.path.normpath(common)))
223 managed = _managed_settings_dir()
224 files = [(os.path.join(managed, "managed-settings.json"), _project_dir)]
225 try:
226 dropins = sorted(os.listdir(os.path.join(managed, "managed-settings.d")))
227 except OSError:
228 dropins = []
229 files.extend((os.path.join(managed, "managed-settings.d", n), _project_dir)
230 for n in dropins if n.endswith(".json"))
231 files.append((os.path.join(config_dir, "settings.json"), config_dir))
232 files.append((os.path.join(_project_dir, ".claude", "settings.json"), _project_dir))
233 files.append((os.path.join(_project_dir, ".claude", "settings.local.json"), _project_dir))
234 if canonical and canonical != _project_dir:
235 files.append((os.path.join(canonical, ".claude", "settings.local.json"), _project_dir))
236 return files
237
238
239def _mirrored(directory: Optional[str], context: str) -> Optional[str]:
240 """`directory`'s counterpart inside the checkout rooted at `context` (the
241 sub-agent's cwd): itself when the sub-agent runs inside this repo, else
242 the same toplevel-relative location under `context`."""
243 if not directory or not _toplevel or not _outside(context, _toplevel):
244 return directory
245 rel = os.path.relpath(directory, _toplevel)
246 return None if _outside(directory, _toplevel) else _norm_abs(os.path.join(context, rel))
247
248
249def _outside(path: str, root: str) -> bool:
250 try:
251 rel = os.path.relpath(path, root)
252 except ValueError: # different Windows drive
253 return True
254 return rel == ".." or rel.startswith(".." + os.sep)
255
256
257def _managed_settings_dir() -> str:
258 if sys.platform == "darwin":
259 return "/Library/Application Support/ClaudeCode"
260 if sys.platform == "win32":
261 return r"C:\Program Files\ClaudeCode"
262 return "/etc/claude-code"
263
264
265def _norm_abs(path: str) -> str:
266 """Absolute path; on Windows in the `/c/Users/...` form Claude Code
267 matches against, so rules and paths compare in one notation."""
268 p = os.path.abspath(path)
269 if os.name == "nt":
270 p = p.replace("\\", "/")
271 if re.match(r"^[A-Za-z]:", p):
272 p = "/" + p[0].lower() + p[2:]
273 return p
274
275
276def _add_rule(rule: str, anchor: str) -> None:
277 global _deny_all_reads
278 m = _RULE_RE.match(rule)
279 if not m or m.group(1) not in _READ_TOOLS:
280 return
281 tool, pattern = m.group(1), (m.group(2) or "").strip()
282 if not pattern:
283 # Bare `Read` denies every read; bare `Grep`/`Glob` only remove a tool.
284 if tool == "Read":
285 _deny_all_reads = True
286 _forward.append(tool)
287 return
288 if tool != "Read":
289 # Grep(...)/Glob(...) content rules are not path rules; forward as-is.
290 _forward.append(rule.strip())
291 return
292 # Same prefix conventions as Claude Code's patternWithRootFor.
293 if os.name == "nt" and re.match(r"^(~\\|\\(?![!#]))", pattern):
294 pattern = pattern.replace("\\", "/")
295 if os.name == "nt" and re.match(r"^[A-Za-z]:[/\\]", pattern):
296 pattern = "//" + pattern[0].lower() + pattern[2:].replace("\\", "/")
297 if pattern.startswith("//"):
298 kind, root, rel = "abs", "/", pattern[2:]
299 if os.name == "nt" and re.match(r"^[A-Za-z]/", rel):
300 root, rel = "/" + rel[0].lower(), rel[2:]
301 elif pattern.startswith("~/"):
302 kind, root, rel = "abs", _norm_abs(os.path.expanduser("~")), pattern[2:]
303 elif pattern.startswith("/"):
304 kind, root, rel = "project", anchor, pattern[1:]
305 rule = f"Read(/{anchor}/{rel})"
306 else:
307 kind, root, rel = "cwd", "", (pattern[2:] if pattern.startswith("./") else pattern)
308 # All three prefixed forms stay anchored at their root, as in Claude Code.
309 _rules.append((kind, root, rel, _compile(rel if kind == "cwd" else "/" + rel)))
310 _forward.append(rule.strip())
311
312
313def _compile(pattern: str) -> Optional[Pattern[str]]:
314 """gitignore-style pattern → regex over a root-relative POSIX path.
315 Approximates the `ignore` matcher Claude Code uses; where it differs it
316 errs toward matching (excluding) more: negations are skipped, directory
317 patterns also match a same-named file, and — as Claude Code itself does
318 for deny rules — a trailing `/**` is dropped, so `dir/**` means `dir` at
319 any depth."""
320 p = pattern.strip()
321 if p.startswith("!") or p.startswith("#"):
322 return None
323 if p.endswith("/**"):
324 p = p[:-3]
325 p = p.rstrip("/")
326 if p.startswith("\\"):
327 p = p[1:]
328 anchored = "/" in p
329 p = p.lstrip("/")
330 if not p:
331 return None # `./`, `/`, `~/`: matches nothing, as in Claude Code
332 if p == "**":
333 return re.compile("") # everything under the root
334 out, i = "", 0
335 while i < len(p):
336 c = p[i]
337 if p.startswith("**/", i):
338 out += "(?:.*/)?"
339 i += 3
340 elif p.startswith("**", i):
341 out += ".*"
342 i += 2
343 elif c == "*":
344 out += "[^/]*"
345 i += 1
346 elif c == "?":
347 out += "[^/]"
348 i += 1
349 elif c == "[":
350 j = p.find("]", i + 2)
351 if j == -1:
352 out += re.escape(c)
353 i += 1
354 else:
355 body = p[i + 1:j]
356 if body.startswith("!"):
357 body = "^" + body[1:]
358 out += "[" + body.replace("\\", "\\\\") + "]"
359 i = j + 1
360 elif c == "\\" and i + 1 < len(p):
361 out += re.escape(p[i + 1])
362 i += 2
363 else:
364 out += re.escape(c)
365 i += 1
366 prefix = "^" if anchored else "^(?:.*/)?"
367 try:
368 return re.compile(prefix + out + "(?:/.*)?$", re.IGNORECASE)
369 except re.error:
370 debug_log(f"secretpaths: unusable pattern {pattern!r}; treating as match-all")
371 return re.compile("")