@@ -365,12 +365,27 @@ def _force_push_protected(command: str) -> str | None:
365365 # THIS gate's dangerous/sensitive checks — writing one IS the disarm act, and
366366 # no agent flow legitimately writes them (owner-authored config only).
367367 (re .compile (r"/\.guard-(?:whitelist|patterns)$" ), "guard-config" , ".guard-* guard config" ),
368+ # Credential stores the home CLAUDE.md names but the list above missed
369+ # (audit S4). Each holds a live credential in plaintext.
370+ (re .compile (r"/\.git-credentials$" ), "credentials" , ".git-credentials" ),
371+ (re .compile (r"/\.netrc$" ), "credentials" , ".netrc" ),
372+ (re .compile (r"/\.config/gh/hosts\.yml$" ), "token" , "gh CLI hosts.yml (auth token)" ),
373+ (re .compile (r"/\.envrc$" ), "env" , ".envrc (direnv)" ),
368374]
369375
376+ # Suffix exceptions: safe ANYWHERE. These name a file that by convention holds
377+ # placeholders, not secrets, so their location does not change the risk.
370378_SENSITIVE_EXCEPTIONS : list [re .Pattern [str ]] = [
371379 re .compile (r"\.env\.example$" ),
372380 re .compile (r"\.env\.sample$" ),
373381 re .compile (r"\.env\.template$" ),
382+ ]
383+
384+ # Directory exceptions: safe ONLY inside the repo worktree (audit S4). These say
385+ # "this is test data", which is a claim about a project's own tree. Matched
386+ # anywhere, `/fixtures/` excused `/home/feedgen/fixtures/.env` — a real
387+ # credential file in the home directory — from every sensitive-file check.
388+ _SENSITIVE_DIR_EXCEPTIONS : list [re .Pattern [str ]] = [
374389 re .compile (r"/testdata/" ),
375390 re .compile (r"/fixtures/" ),
376391 re .compile (r"/__fixtures__/" ),
@@ -943,9 +958,43 @@ def _load_guard_patterns() -> list[tuple[re.Pattern[str], str, str]]:
943958 return extra
944959
945960
961+ def _repo_worktree_root () -> Path | None :
962+ """Nearest ancestor of cwd containing `.git`, or None outside a repo.
963+
964+ Walks the filesystem instead of shelling out to git: this runs on every
965+ sensitive-file check and the hook has a hard latency budget.
966+ """
967+ try :
968+ here = Path .cwd ().resolve ()
969+ except OSError :
970+ return None
971+ for candidate in (here , * here .parents ):
972+ if (candidate / ".git" ).exists ():
973+ return candidate
974+ return None
975+
976+
946977def _is_sensitive_exception (file_path : str ) -> bool :
947- """Check if file matches a sensitive-file exception pattern."""
948- return any (p .search (file_path ) for p in _SENSITIVE_EXCEPTIONS )
978+ """Check if `file_path` matches a sensitive-file exception.
979+
980+ Suffix exceptions (`.env.example` and friends) apply anywhere. Directory
981+ exceptions (`/fixtures/`, `/testdata/`, …) apply ONLY inside the repo
982+ worktree — a "this is test data" claim is only meaningful about a
983+ project's own tree, and honoring it anywhere excused real credential
984+ files elsewhere on the box (audit S4).
985+ """
986+ if any (p .search (file_path ) for p in _SENSITIVE_EXCEPTIONS ):
987+ return True
988+ if not any (p .search (file_path ) for p in _SENSITIVE_DIR_EXCEPTIONS ):
989+ return False
990+ root = _repo_worktree_root ()
991+ if root is None :
992+ return False
993+ try :
994+ resolved = Path (file_path ).expanduser ().resolve ()
995+ except (OSError , RuntimeError ):
996+ return False
997+ return resolved == root or root in resolved .parents
949998
950999
9511000def _block (message : str , tool_name : str = "" , reason : str = "" ) -> None :
@@ -1332,6 +1381,133 @@ def check_sensitive_file(file_path: str, *, deny: bool = True) -> None:
13321381 )
13331382
13341383
1384+ # ── sensitive files reached via Bash (audit S4) ──────────────────
1385+ #
1386+ # `check_sensitive_file` ran only in the Write/Edit/Read tool branches, so the
1387+ # identical acts spelled as a shell command were never examined at all:
1388+ # `cat ~/.ssh/id_ed25519`, `cp ~/.env /var/www/html/`, and `cat > ~/.env` all
1389+ # passed while the Write-tool equivalents were denied.
1390+ #
1391+ # Posture deliberately mirrors the tool branches rather than inventing a
1392+ # stricter one for Bash:
1393+ # * COPY/WRITE verbs and redirect targets DENY — they duplicate a secret to a
1394+ # new location or mutate a credential file, which is what Write/Edit deny.
1395+ # * READ verbs WARN only — same as the Read tool, and for the same reason
1396+ # documented there: agents routinely read a project's own .env while
1397+ # debugging, and blocking that breaks common legitimate work.
1398+
1399+ # Verbs that duplicate or mutate a file → deny on a sensitive path in any
1400+ # argument position (source OR destination: copying a secret out is the
1401+ # exfiltration shape the home CLAUDE.md forbids).
1402+ _SENSITIVE_COPY_VERBS = frozenset ({"cp" , "mv" , "scp" , "rsync" , "install" , "tee" , "dd" , "rclone" })
1403+
1404+ # Verbs that only display a file → warn.
1405+ _SENSITIVE_READ_VERBS = frozenset (
1406+ {
1407+ "cat" ,
1408+ "bat" ,
1409+ "less" ,
1410+ "more" ,
1411+ "head" ,
1412+ "tail" ,
1413+ "od" ,
1414+ "xxd" ,
1415+ "hexdump" ,
1416+ "strings" ,
1417+ "base64" ,
1418+ "nl" ,
1419+ "cut" ,
1420+ "sort" ,
1421+ "uniq" ,
1422+ "wc" ,
1423+ }
1424+ )
1425+
1426+ # An output redirect target (`> path`, `>> path`, `2> path`). Writing here
1427+ # creates or truncates the named file, so it is a write regardless of verb.
1428+ _REDIRECT_TARGET_RE = re .compile (r"(?<!<)>>?\s*['\"]?([^\s'\"|;&<>]+)" )
1429+
1430+
1431+ def _sensitive_bash_paths (segment : str ) -> tuple [list [str ], list [str ]]:
1432+ """Return (deny_paths, warn_paths) for one shell segment.
1433+
1434+ Redirect targets are always write paths. Otherwise the segment's command
1435+ token picks the posture; a segment led by neither kind of verb yields
1436+ nothing (an unrecognized command's arguments are not assumed to be files).
1437+ """
1438+ deny_paths = [m .group (1 ) for m in _REDIRECT_TARGET_RE .finditer (segment )]
1439+
1440+ verb = _command_token (segment )
1441+ if verb not in _SENSITIVE_COPY_VERBS and verb not in _SENSITIVE_READ_VERBS :
1442+ return deny_paths , []
1443+
1444+ try :
1445+ toks = shlex .split (_strip_leading_prefixes (segment ), posix = True )
1446+ except ValueError :
1447+ toks = _strip_leading_prefixes (segment ).split ()
1448+ args = [t for t in toks [1 :] if not t .startswith ("-" )]
1449+ if verb in _SENSITIVE_COPY_VERBS :
1450+ return deny_paths + args , []
1451+ return deny_paths , args
1452+
1453+
1454+ def _matches_sensitive (path : str ) -> tuple [str , str ] | None :
1455+ """Return (category, description) if `path` is sensitive, else None.
1456+
1457+ Normalizes `~` so `~/.env` matches the same anchored patterns an absolute
1458+ path does — the patterns are rooted at `/`, so an un-expanded `~` never
1459+ matched anything.
1460+ """
1461+ expanded = os .path .expanduser (path )
1462+ if not expanded .startswith ("/" ):
1463+ expanded = str (Path .cwd () / expanded )
1464+ if _is_sensitive_exception (expanded ):
1465+ return None
1466+ for pattern , category , description in _SENSITIVE_PATTERNS + _load_guard_patterns ():
1467+ if pattern .search (expanded ):
1468+ return category , description
1469+ return None
1470+
1471+
1472+ def check_sensitive_bash (command : str ) -> None :
1473+ """Deny/warn when a Bash command reads, copies, or overwrites a secret."""
1474+ if os .environ .get (_SENSITIVE_BYPASS_ENV ) == "1" :
1475+ return
1476+
1477+ for line in _non_heredoc_lines (command ):
1478+ for segment in _SEGMENT_SPLIT_RE .split (line ):
1479+ if not segment .strip ():
1480+ continue
1481+ deny_paths , warn_paths = _sensitive_bash_paths (segment )
1482+ for path in deny_paths :
1483+ hit = _matches_sensitive (path )
1484+ if hit :
1485+ category , description = hit
1486+ _block (
1487+ f"[sensitive-file-guard] BLOCKED: shell command writes or copies a sensitive file ({ category } )\n "
1488+ f"[sensitive-file-guard] Path: { path } \n "
1489+ f"[sensitive-file-guard] Pattern: { description } \n "
1490+ f"[sensitive-file-guard] Command: { command } " ,
1491+ reason = (
1492+ f"Shell command writes or copies a sensitive file ({ category } : { description } ). "
1493+ f"Path: { path } . Duplicating or overwriting a credential file needs explicit owner "
1494+ f"approval per CLAUDE.md."
1495+ ),
1496+ )
1497+ for path in warn_paths :
1498+ hit = _matches_sensitive (path )
1499+ if hit :
1500+ category , description = hit
1501+ print (
1502+ f"[sensitive-file-guard] ADVISORY: shell command reads a sensitive file ({ category } )\n "
1503+ f"[sensitive-file-guard] Path: { path } \n "
1504+ f"[sensitive-file-guard] Pattern: { description } \n "
1505+ f"[sensitive-file-guard] Reading credential files needs owner approval "
1506+ f"(OWNER-APPROVED-SECRET-READ) per CLAUDE.md. Not blocked — flagged for review." ,
1507+ file = sys .stderr ,
1508+ )
1509+
1510+
13351511# ═══════════════════════════════════════════════════════════════
13361512# 5b. GUARD SELF-PROTECTION (audit S1)
13371513# ═══════════════════════════════════════════════════════════════
@@ -2662,6 +2838,7 @@ def main() -> None:
26622838 check_dangerous_command (command )
26632839 check_public_dev_server (command )
26642840 check_sysadmin_security (command )
2841+ check_sensitive_bash (command )
26652842
26662843 elif tool == "Write" :
26672844 file_path = tool_input .get ("file_path" , "" )
0 commit comments