diff options
Diffstat (limited to 'hooks')
| -rw-r--r-- | hooks/README.md | 3 | ||||
| -rwxr-xr-x | hooks/ai-wrap-teardown.sh | 43 | ||||
| -rwxr-xr-x | hooks/inbox-boundary-check.sh | 52 | ||||
| -rwxr-xr-x | hooks/rulesets-write-boundary.py | 94 | ||||
| -rwxr-xr-x | hooks/session-start-disarm.sh | 42 | ||||
| -rw-r--r-- | hooks/settings-snippet.json | 7 | ||||
| -rw-r--r-- | hooks/tests/test_rulesets_write_boundary.py | 110 |
7 files changed, 350 insertions, 1 deletions
diff --git a/hooks/README.md b/hooks/README.md index 71b3613..9c4268d 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -10,6 +10,9 @@ Machine-wide Claude Code hooks that install into `~/.claude/hooks/` and apply to | `git-commit-confirm.py` | `PreToolUse(Bash)` | Silent-unless-suspicious gate on `git commit`. Only prompts when the message contains AI-attribution patterns, the message can't be parsed (editor would open), no files are staged, or the git author is unusable. Clean commits pass through without a modal. Parses both HEREDOC and `-m`/`--message` forms. | | `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/inbox-boundary-check.sh b/hooks/inbox-boundary-check.sh new file mode 100755 index 0000000..916c000 --- /dev/null +++ b/hooks/inbox-boundary-check.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Stop hook: soft-nudge the agent to process pending inbox/ handoffs before it +# yields control back to Craig. +# +# The "check inbox/ at every task boundary" rule (protocols.org, Inbox +# Monitoring Cadence) is otherwise prose-only, so it holds only as well as the +# agent remembers it. A Stop is the agent finishing a turn and about to yield, +# which is the harness's closest event to the rule's own trigger ("after +# finishing a unit of work, before reporting back"). When handoffs are pending +# this hook blocks the yield once and injects a reason, so the agent processes +# them instead of returning with items unseen. +# +# Soft-nudge, not hard-block: on the harness re-entry (stop_hook_active: true) +# the hook steps aside and lets the turn end. An item the agent genuinely can't +# process, or a mid-task pause to ask Craig a question (also a Stop), never +# wedges the session. +# +# Self-skips everywhere it doesn't apply: no inbox/ dir, no inbox-status, or a +# clean inbox each exit 0 silently. One hook, every project, no config. +# +# Wire in ~/.claude/settings.json (see hooks/settings-snippet.json), Stop array. +set -u + +payload="$(cat)" + +# Re-entry after our own nudge: don't nudge twice, let the turn end. +active="$(printf '%s' "$payload" | jq -r '.stop_hook_active // false' 2>/dev/null)" +[ "$active" = "true" ] && exit 0 + +cwd="$(printf '%s' "$payload" | jq -r '.cwd // empty' 2>/dev/null)" +[ -z "$cwd" ] && cwd="$PWD" +cd "$cwd" 2>/dev/null || exit 0 + +# Nothing to enforce without an inbox to watch. +[ -d inbox ] || exit 0 + +# Prefer the project-local inbox-status; fall back to one on PATH. Absent both, +# degrade to a no-op rather than block on a check we can't run. +status_bin=".ai/scripts/inbox-status" +[ -x "$status_bin" ] || status_bin="$(command -v inbox-status 2>/dev/null)" || exit 0 +[ -n "$status_bin" ] || exit 0 + +# inbox-status -q: exit 1 = pending, 0 = clean, 2 = no inbox. Only 1 nudges. +out="$("$status_bin" -q 2>/dev/null)" +[ $? -eq 1 ] || exit 0 + +count="$(printf '%s' "$out" | grep -oE '[0-9]+' | head -1)" +[ -n "$count" ] || count="Some" + +reason="$count pending inbox handoff(s) in $(basename "$cwd"). Process per inbox.org before yielding." +printf '{"decision":"block","reason":%s}\n' "$(printf '%s' "$reason" | jq -Rs .)" +exit 0 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/session-start-disarm.sh b/hooks/session-start-disarm.sh new file mode 100755 index 0000000..520d8c0 --- /dev/null +++ b/hooks/session-start-disarm.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# +# SessionStart: disarm any wrap sentinel left over from a previous session. +# +# wrap-it-up drops /tmp/ai-wrap-teardown-<project> (or -shutdown-) to ask the +# Stop hook to kill the tmux session once the wrap certifies clean. The Stop +# hook deliberately PRESERVES that sentinel when certification fails, so a wrap +# blocked by a dirty tree can retry on a later stop in the same session without +# the user re-running the workflow. +# +# Nothing bounded that retry to the session. A sentinel armed by a wrap that +# never certified survived indefinitely and fired in whatever session next +# happened to reach a clean tree: +# +# work, 2026-07-27. The 11:37 wrap requested teardown, failed certification +# on a dirty tree, and left the sentinel armed. A fresh session started at +# 13:20, committed twice during startup, went clean — and the next stop +# consumed the two-hour-old sentinel and killed the terminal mid-work. +# archsetup's had been armed for two days on a live attached session. +# +# A new session means the wrap that armed the sentinel is gone, so its pending +# teardown is meaningless: clear it. Within-session retry is untouched, because +# this only runs at session start. If the user still wants teardown, wrap-it-up +# re-arms it. +# +# Scoped to the current project's sentinels only — a concurrent session in +# another project keeps its own. +# +# Silent and exit 0 always. A SessionStart hook must never block a session from +# starting, and there is nothing here a user needs told. + +set -u + +payload="$(cat 2>/dev/null || true)" + +cwd="$(printf '%s' "$payload" | jq -r '.cwd // empty' 2>/dev/null)" +[ -z "$cwd" ] && cwd="$PWD" +proj="$(basename "$cwd")" + +rm -f "/tmp/ai-wrap-teardown-${proj}" "/tmp/ai-wrap-shutdown-${proj}" + +exit 0 diff --git a/hooks/settings-snippet.json b/hooks/settings-snippet.json index 0f0e784..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" }, @@ -34,6 +40,7 @@ "Stop": [ { "hooks": [ + { "type": "command", "command": "~/.claude/hooks/inbox-boundary-check.sh" }, { "type": "command", "command": "~/.claude/hooks/ai-wrap-teardown.sh" } ] } 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 |
