diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-25 15:35:14 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-25 15:35:14 -0500 |
| commit | f2609d9f9ad33486bef43211d753ba53e1e24181 (patch) | |
| tree | 561f0514866570d47c446816daa131ce13064639 /hooks/rulesets-write-boundary.py | |
| parent | 3203bd803b6a05d10781634e7e18742879c1046a (diff) | |
| download | rulesets-main.tar.gz rulesets-main.zip | |
Centralize repository-state checks, bind teardown to a certified clean HEAD, and allow inbox-only refreshes. Guard installed symlinks from cross-project writes and add regression coverage.
Diffstat (limited to 'hooks/rulesets-write-boundary.py')
| -rwxr-xr-x | hooks/rulesets-write-boundary.py | 94 |
1 files changed, 94 insertions, 0 deletions
diff --git a/hooks/rulesets-write-boundary.py b/hooks/rulesets-write-boundary.py new file mode 100755 index 0000000..35088ea --- /dev/null +++ b/hooks/rulesets-write-boundary.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Block cross-project Edit/Write calls that resolve into rulesets. + +Global Claude rules, hooks, skills, commands, and bin tools are symlinks into +~/code/rulesets. Editing one from another project's session silently dirties +rulesets and can block every later startup. The sanctioned path is inbox-send; +the rulesets session owns the canonical edit and its wrap. +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path +from typing import Any, Iterable + +from _common import read_payload, respond_deny + + +PATCH_PATH = re.compile( + r"^\*\*\* (?:Add|Update|Delete) File: (.+)$|^\*\*\* Move to: (.+)$", + re.MULTILINE, +) + + +def inside(path: Path, parent: Path) -> bool: + try: + path.relative_to(parent) + return True + except ValueError: + return False + + +def candidate_paths(tool_input: Any) -> Iterable[str]: + if isinstance(tool_input, dict): + for key, value in tool_input.items(): + if key in {"file_path", "path"} and isinstance(value, str): + yield value + elif key in {"patch", "input"} and isinstance(value, str): + for match in PATCH_PATH.finditer(value): + yield match.group(1) or match.group(2) + elif isinstance(tool_input, str): + for match in PATCH_PATH.finditer(tool_input): + yield match.group(1) or match.group(2) + + +def resolve(raw: str, cwd: Path) -> Path: + expanded = Path(os.path.expandvars(os.path.expanduser(raw))) + if not expanded.is_absolute(): + expanded = cwd / expanded + return expanded.resolve(strict=False) + + +def main() -> int: + payload = read_payload() + tool_name = payload.get("tool_name", "") + if tool_name not in {"Edit", "Write", "apply_patch"}: + return 0 + + rulesets = Path( + os.environ.get("RULESETS_ROOT", "~/code/rulesets") + ).expanduser().resolve(strict=False) + cwd = Path(payload.get("cwd") or os.getcwd()).resolve(strict=False) + + # A rulesets session owns its own canonical files. + if inside(cwd, rulesets): + return 0 + + blocked: list[str] = [] + for raw in candidate_paths(payload.get("tool_input", {})): + try: + target = resolve(raw, cwd) + except (OSError, RuntimeError): + blocked.append(f"{raw} (real path could not be verified)") + continue + if inside(target, rulesets): + blocked.append(str(target)) + + if not blocked: + return 0 + + shown = ", ".join(blocked) + reason = ( + "Blocked cross-project write into rulesets: " + f"{shown}. This path may have been reached through an installed " + "symlink. Send the proposed change with inbox-send rulesets instead, " + "then let a rulesets session apply and wrap it." + ) + respond_deny(reason, system_message=reason) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) |
