aboutsummaryrefslogtreecommitdiff
path: root/hooks
diff options
context:
space:
mode:
Diffstat (limited to 'hooks')
-rw-r--r--hooks/README.md3
-rwxr-xr-xhooks/ai-wrap-teardown.sh43
-rwxr-xr-xhooks/inbox-boundary-check.sh52
-rwxr-xr-xhooks/rulesets-write-boundary.py94
-rwxr-xr-xhooks/session-start-disarm.sh42
-rw-r--r--hooks/settings-snippet.json7
-rw-r--r--hooks/tests/test_rulesets_write_boundary.py110
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