Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions .claude/hooks/guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,9 +360,13 @@ def _is_protected_home_top(name: str) -> bool:
or (IS_WINDOWS and name == 'appdata'))


def _windows_drive_verdict(parts: list[str]) -> str | None:
"""Why a /<drive>/... path is too broad for rm -r on Windows, else None."""
if not (IS_WINDOWS and parts and len(parts[0]) == 1):
def _windows_drive_verdict(parts: list[str], base: str) -> str | None:
"""Why a /<drive>/... or //server/share path is too broad for rm -r on Windows."""
if not (IS_WINDOWS and parts):
return None
if base.startswith('//'): # UNC \\server\share: only the share root is too broad
return 'network share' if len(parts) <= 2 else None
if len(parts[0]) != 1:
return None
if len(parts) == 1:
return 'drive root'
Expand Down Expand Up @@ -395,7 +399,7 @@ def classify_rm_target(t: str, cwd: str | None = None) -> tuple[int, str | None]
return ALLOW, None
parts = [x for x in base.split('/') if x]
if (base == '/' or base == HOME or (parts and parts[0] in SYSTEM_TOP)
or _windows_drive_verdict(parts)):
or _windows_drive_verdict(parts, base)):
return DENY, f'rm -r * in {base}: wildcard wipe of a sensitive directory'
return ALLOW, None

Expand Down Expand Up @@ -428,8 +432,8 @@ def classify_rm_target(t: str, cwd: str | None = None) -> tuple[int, str | None]
parts = [x for x in base.split('/') if x]
if not parts:
return DENY, f'rm -r {t}: filesystem root'
if IS_WINDOWS and len(parts[0]) == 1: # /<drive>/...
why = _windows_drive_verdict(parts)
if IS_WINDOWS and (len(parts[0]) == 1 or base.startswith('//')): # /<drive>/... or UNC
why = _windows_drive_verdict(parts, base)
return (DENY, f'rm -r {t}: {why}') if why else (ALLOW, None)
top = parts[0]
if top == 'tmp' or base.startswith('/var/tmp') or base.startswith('/dev/shm'):
Expand Down
3 changes: 3 additions & 0 deletions .claude/hooks/test_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,9 @@
(D, 'Bash', {'command': 'rm -rf "/c/Program Files"'}),
(D, 'Bash', {'command': 'rm -rf /c'}),
(D, 'Bash', {'command': 'rm -rf C:/foo'}),
(D, 'Bash', {'command': 'rm -rf //fileserver/share'}), # UNC \\server\share
(D, 'Bash', {'command': 'rm -rf //fileserver'}),
(A, 'Bash', {'command': 'rm -rf //fileserver/share/build'}),
(D, 'Bash', {'command': 'rm -rf ../../..'}),
(D, 'Bash', {'command': 'find ~ -mindepth 1 -delete'}),
(A, 'Bash', {'command': 'rm -rf ./build'}),
Expand Down
16 changes: 10 additions & 6 deletions .codex/hooks/guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,9 +360,13 @@ def _is_protected_home_top(name: str) -> bool:
or (IS_WINDOWS and name == 'appdata'))


def _windows_drive_verdict(parts: list[str]) -> str | None:
"""Why a /<drive>/... path is too broad for rm -r on Windows, else None."""
if not (IS_WINDOWS and parts and len(parts[0]) == 1):
def _windows_drive_verdict(parts: list[str], base: str) -> str | None:
"""Why a /<drive>/... or //server/share path is too broad for rm -r on Windows."""
if not (IS_WINDOWS and parts):
return None
if base.startswith('//'): # UNC \\server\share: only the share root is too broad
return 'network share' if len(parts) <= 2 else None
if len(parts[0]) != 1:
return None
if len(parts) == 1:
return 'drive root'
Expand Down Expand Up @@ -395,7 +399,7 @@ def classify_rm_target(t: str, cwd: str | None = None) -> tuple[int, str | None]
return ALLOW, None
parts = [x for x in base.split('/') if x]
if (base == '/' or base == HOME or (parts and parts[0] in SYSTEM_TOP)
or _windows_drive_verdict(parts)):
or _windows_drive_verdict(parts, base)):
return DENY, f'rm -r * in {base}: wildcard wipe of a sensitive directory'
return ALLOW, None

Expand Down Expand Up @@ -428,8 +432,8 @@ def classify_rm_target(t: str, cwd: str | None = None) -> tuple[int, str | None]
parts = [x for x in base.split('/') if x]
if not parts:
return DENY, f'rm -r {t}: filesystem root'
if IS_WINDOWS and len(parts[0]) == 1: # /<drive>/...
why = _windows_drive_verdict(parts)
if IS_WINDOWS and (len(parts[0]) == 1 or base.startswith('//')): # /<drive>/... or UNC
why = _windows_drive_verdict(parts, base)
return (DENY, f'rm -r {t}: {why}') if why else (ALLOW, None)
top = parts[0]
if top == 'tmp' or base.startswith('/var/tmp') or base.startswith('/dev/shm'):
Expand Down
28 changes: 28 additions & 0 deletions .github/workflows/guard.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: guard

on:
push:
branches: [master]
pull_request:

jobs:
# The guard's path rules are platform-specific, so the corpus has to run on
# every platform the hook is installed on — a crash there fails open silently.
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: '3.12'
- run: python .claude/hooks/test_guard.py

sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: diff .claude/hooks/guard.py .codex/hooks/guard.py
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.

### Added

- **.github/workflows/guard.yml** — the guard corpus runs on ubuntu, windows and macos on every push and PR, plus a job asserting `.claude/hooks/guard.py` and `.codex/hooks/guard.py` are byte-identical. A platform-specific crash in the guard fails open silently, so it has to be caught by CI rather than by whoever happens to run that platform.
- **guard.py** — `pkill -f` / `pgrep -f` self-match rule: a full-match pattern (`-f`, `--full`, `-ef`, `-9f`, also after `sudo`/`timeout`, inside `bash -c`, `$(…)`, backticks, pipes and `ssh host '…'`) that matches the command line it is part of is denied, because the Bash tool's own `bash -c "<command>"` shell is in that pattern's way and dies with it (exit 144); `pgrep` is denied only when its output feeds a `kill`, and `[p]attern` or anchored patterns stay allowed.
- **guard.py** — `GUARD_PROTECTED_UNITS` (comma/whitespace separated unit names, with or without `.service`): `systemctl stop|restart|disable|kill|mask|try-restart|reload-or-restart` on a listed unit is denied, while `status`, `start`, `show`, `cat`, `list-units` and `daemon-reload` stay allowed.
- **.claude/skills/repo-context** — compact repository snapshot collected with `!` injections before the model runs; replaces `/prime`.
Expand Down Expand Up @@ -57,6 +58,7 @@ All notable changes to this project will be documented in this file.

### Fixed

- **guard.py** — `rm -r` of a UNC share root on Windows (`//server/share`, `\\server\share`) was allowed: the drive-letter check did not match a `//` path, so it fell through to the POSIX ladder and no rule covered it. It is now denied as `network share`, the Windows counterpart of the existing `/mnt`, `/media` rule; paths inside a share (`//server/share/build`) stay allowed.
- **statusline.py** — rate-limit segment disappeared entirely when the 5-hour window was absent (Claude Code drops a window once it resets, e.g. while idle); a missing window now renders as `-` (`-/76% (-/48h)`).
- **guard.py** — path rules on Windows. `HOME` (`C:\Users\x`) was passed to `re.sub` as a replacement template, so every path check raised `bad escape \U` and the hook failed open: `rm -rf /`, reading `~/.ssh` keys and unhooking guard.py from settings were all allowed. Past that crash, `os.path.normpath` produced backslash paths the POSIX rules never matched, so every `rm -r` (even `./build`) was denied as a top-level directory, while `C:\…` tool paths skipped the `~/.ssh` directory and settings checks. Paths are now normalized to one lowercase form before any check (`C:\x`, `C:/x` and Git Bash `/c/x` all become `/c/x`), `$USERPROFILE` expands like `$HOME`, and on Windows `rm -r` of a drive root, `Windows`, `Program Files`, `ProgramData` or another profile under `Users` is denied and `~/AppData` is protected. POSIX behavior is unchanged. `test_guard.py` pins a `/home/def` POSIX host so the corpus gives the same verdicts on any machine, and adds 28 simulated Windows cases.

Expand Down
Loading