aboutsummaryrefslogtreecommitdiff
path: root/hooks/rulesets-write-boundary.py
diff options
context:
space:
mode:
Diffstat (limited to 'hooks/rulesets-write-boundary.py')
-rwxr-xr-xhooks/rulesets-write-boundary.py94
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())