aboutsummaryrefslogtreecommitdiff
path: root/hooks/rulesets-write-boundary.py
blob: 35088ea29b34e4d6bd11dfccee3045aaf6027391 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
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())