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 | """Paths the security reviewer must not see. |
| 2 | |
| 3 | Two sources, one answer: |
| 4 | |
| 5 | 1. 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. |
| 10 | 2. 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 |
| 15 | the 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 | |
| 18 | Not covered: rules that only exist in MDM/registry policy, server-managed |
| 19 | settings, or the parent's ``--settings``/``--disallowedTools`` flags — a hook |
| 20 | subprocess cannot see those. The README says so. |
| 21 | """ |
| 22 | |
| 23 | import fnmatch |
| 24 | import json |
| 25 | import os |
| 26 | import re |
| 27 | import sys |
| 28 | from typing import List, Optional, Pattern, Tuple |
| 29 | |
| 30 | from _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. |
| 36 | SECRET_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 | |
| 75 | def 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 | |
| 79 | def 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 | |
| 87 | def 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 | |
| 100 | def 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 | |
| 127 | def 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 | |
| 135 | def _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 | |
| 155 | def _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 | |
| 163 | def _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 | |
| 204 | def _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 | |
| 239 | def _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 | |
| 249 | def _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 | |
| 257 | def _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 | |
| 265 | def _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 | |
| 276 | def _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 | |
| 313 | def _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("") |