#!/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())