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 | |
| parent | 3203bd803b6a05d10781634e7e18742879c1046a (diff) | |
| download | rulesets-f2609d9f9ad33486bef43211d753ba53e1e24181.tar.gz rulesets-f2609d9f9ad33486bef43211d753ba53e1e24181.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')
| -rw-r--r-- | hooks/README.md | 2 | ||||
| -rwxr-xr-x | hooks/ai-wrap-teardown.sh | 43 | ||||
| -rwxr-xr-x | hooks/rulesets-write-boundary.py | 94 | ||||
| -rw-r--r-- | hooks/settings-snippet.json | 6 | ||||
| -rw-r--r-- | hooks/tests/test_rulesets_write_boundary.py | 110 |
5 files changed, 254 insertions, 1 deletions
diff --git a/hooks/README.md b/hooks/README.md index e83469e..9c4268d 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -11,6 +11,8 @@ Machine-wide Claude Code hooks that install into `~/.claude/hooks/` and apply to | `gh-pr-create-confirm.py` | `PreToolUse(Bash)` | Gates `gh pr create` behind a confirmation modal showing title, base←head, reviewers, labels, assignees, milestone, draft flag, and body (HEREDOC or quoted). | | `destructive-bash-confirm.py` | `PreToolUse(Bash)` | Gates destructive commands (`git push --force`, `git reset --hard`, `git clean -f`, `git branch -D`, `rm -rf`) with a modal showing the command, local context (branch, uncommitted file counts, targeted paths), and a warning banner. Elevates severity when force-pushing protected branches or targeting root/home/wildcard paths. | | `inbox-boundary-check.sh` | `Stop` | Soft-nudges the agent to process pending `inbox/` handoffs before yielding. Blocks the stop once (injects a reason with the pending count) when `inbox-status -q` reports pending items; steps aside on the harness re-entry (`stop_hook_active`) so a mid-task pause or an unprocessable item never wedges. No-ops in any project without an `inbox/` or without `inbox-status`. | +| `ai-wrap-teardown.sh` | `Stop` | Re-verifies the strict clean-tree certificate and its HEAD before consuming a wrap sentinel. Dirty or uncertified state blocks Stop with an actionable report; verified state tears down the matching ai-term session or starts the guarded shutdown countdown. Emits the appropriate Claude or Codex response shape. | +| `rulesets-write-boundary.py` | `PreToolUse(Edit\|Write)` | Resolves write targets through symlinks and denies cross-project edits that land inside rulesets. Directs the sender through `inbox-send rulesets`; rulesets sessions themselves pass. Codex maps `apply_patch` to the same matcher. | Shared library (not a hook): `_common.py` — `read_payload()`, `respond_ask()`, `scan_attribution()`. Installed as a sibling symlink so the two Python hooks can `from _common import …` at runtime. diff --git a/hooks/ai-wrap-teardown.sh b/hooks/ai-wrap-teardown.sh index 6133075..ca73ae6 100755 --- a/hooks/ai-wrap-teardown.sh +++ b/hooks/ai-wrap-teardown.sh @@ -39,8 +39,10 @@ # "command": "~/.claude/hooks/ai-wrap-teardown.sh" } ] } ] set -u +payload="$(cat)" + # Stop-hook stdin JSON carries cwd; basename it to the project / aiv- session. -cwd="$(jq -r '.cwd // empty' 2>/dev/null)" +cwd="$(printf '%s' "$payload" | jq -r '.cwd // empty' 2>/dev/null)" [ -z "$cwd" ] && cwd="$PWD" proj="$(basename "$cwd")" @@ -53,15 +55,54 @@ fire() { emacsclient -e "$1" >/dev/null 2>&1 || true } +# A sentinel means wrap-up claimed completion. Re-prove that claim immediately +# before consuming it: the certificate binds a prior strict check to HEAD, and +# verify also performs a fresh strict check to catch late writes. +verify_wrap() { + local gate="${GIT_WORKTREE_GATE:-}" detail + if [ -z "$gate" ]; then + gate="$(command -v git-worktree-gate 2>/dev/null || true)" + fi + if [ -z "$gate" ] || [ ! -x "$gate" ]; then + gate="$HOME/code/rulesets/claude-templates/bin/git-worktree-gate" + fi + if [ ! -x "$gate" ]; then + detail="wrap blocked: git-worktree-gate is unavailable; install rulesets tooling and retry" + else + detail="$("$gate" verify "$cwd" 2>&1)" && return 0 + fi + + # Codex and Claude consume different Stop-hook response fields. Codex + # command-hook payloads always include model; Claude's do not. + if printf '%s' "$payload" | jq -e '.model? != null' >/dev/null 2>&1; then + jq -n --arg reason "$detail" \ + '{continue:false, stopReason:$reason, systemMessage:$reason}' + else + jq -n --arg reason "$detail" \ + '{decision:"block", reason:$reason}' + fi + return 1 +} + +consume_certificate() { + local dir + dir="$(git -C "$cwd" rev-parse --absolute-git-dir 2>/dev/null)" || return 0 + rm -f "$dir/ai-wrap-clean" +} + # Shutdown supersedes teardown when both are somehow present. if [ -f "$shutdown_sentinel" ]; then + verify_wrap || exit 0 rm -f "$shutdown_sentinel" "$teardown_sentinel" + consume_certificate fire '(cj/ai-term-shutdown-countdown)' exit 0 fi if [ -f "$teardown_sentinel" ]; then + verify_wrap || exit 0 rm -f "$teardown_sentinel" + consume_certificate fire "(cj/ai-term-quit \"${proj}\")" exit 0 fi 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()) diff --git a/hooks/settings-snippet.json b/hooks/settings-snippet.json index 26cbc18..50e3d31 100644 --- a/hooks/settings-snippet.json +++ b/hooks/settings-snippet.json @@ -23,6 +23,12 @@ ], "PreToolUse": [ { + "matcher": "Edit|Write", + "hooks": [ + { "type": "command", "command": "~/.claude/hooks/rulesets-write-boundary.py" } + ] + }, + { "matcher": "Bash", "hooks": [ { "type": "command", "command": "~/.claude/hooks/git-commit-confirm.py" }, diff --git a/hooks/tests/test_rulesets_write_boundary.py b/hooks/tests/test_rulesets_write_boundary.py new file mode 100644 index 0000000..826a941 --- /dev/null +++ b/hooks/tests/test_rulesets_write_boundary.py @@ -0,0 +1,110 @@ +import json +import os +import subprocess +import sys +from pathlib import Path + + +SCRIPT = Path(__file__).parents[1] / "rulesets-write-boundary.py" + + +def run_hook(payload: dict, rulesets: Path) -> dict | None: + proc = subprocess.run( + [sys.executable, str(SCRIPT)], + input=json.dumps(payload), + text=True, + capture_output=True, + env={**os.environ, "RULESETS_ROOT": str(rulesets)}, + check=True, + ) + return json.loads(proc.stdout) if proc.stdout else None + + +def test_allows_write_from_rulesets_session(tmp_path): + rulesets = tmp_path / "rulesets" + rulesets.mkdir() + target = rulesets / "file" + result = run_hook( + { + "cwd": str(rulesets), + "tool_name": "Write", + "tool_input": {"file_path": str(target)}, + }, + rulesets, + ) + assert result is None + + +def test_blocks_absolute_cross_project_write(tmp_path): + rulesets = tmp_path / "rulesets" + other = tmp_path / "other" + rulesets.mkdir() + other.mkdir() + result = run_hook( + { + "cwd": str(other), + "tool_name": "Edit", + "tool_input": {"file_path": str(rulesets / "rule.md")}, + }, + rulesets, + ) + assert result["hookSpecificOutput"]["permissionDecision"] == "deny" + assert "inbox-send rulesets" in result["hookSpecificOutput"][ + "permissionDecisionReason" + ] + + +def test_blocks_write_reached_through_symlink(tmp_path): + rulesets = tmp_path / "rulesets" + other = tmp_path / "other" + installed = tmp_path / "installed" + rulesets.mkdir() + other.mkdir() + (rulesets / "rules").mkdir() + installed.symlink_to(rulesets / "rules", target_is_directory=True) + result = run_hook( + { + "cwd": str(other), + "tool_name": "Write", + "tool_input": {"file_path": str(installed / "todo-format.md")}, + }, + rulesets, + ) + assert result["hookSpecificOutput"]["permissionDecision"] == "deny" + assert str(rulesets) in result["systemMessage"] + + +def test_blocks_apply_patch_target(tmp_path): + rulesets = tmp_path / "rulesets" + other = tmp_path / "other" + rulesets.mkdir() + other.mkdir() + patch = ( + f"*** Begin Patch\n*** Update File: {rulesets / 'file'}\n" + "@@\n-old\n+new\n*** End Patch\n" + ) + result = run_hook( + { + "cwd": str(other), + "tool_name": "apply_patch", + "tool_input": {"input": patch}, + }, + rulesets, + ) + assert result["hookSpecificOutput"]["permissionDecision"] == "deny" + + +def test_allows_unrelated_write(tmp_path): + rulesets = tmp_path / "rulesets" + other = tmp_path / "other" + rulesets.mkdir() + other.mkdir() + result = run_hook( + { + "cwd": str(other), + "tool_name": "Edit", + "tool_input": {"file_path": str(other / "file")}, + }, + rulesets, + ) + assert result is None |
