aboutsummaryrefslogtreecommitdiff
path: root/hooks
diff options
context:
space:
mode:
Diffstat (limited to 'hooks')
-rw-r--r--hooks/README.md3
-rw-r--r--hooks/_common.py18
-rwxr-xr-xhooks/ai-wrap-teardown.sh110
-rwxr-xr-xhooks/git-commit-confirm.py57
-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.json14
-rw-r--r--hooks/tests/test_git_commit_confirm.py58
-rw-r--r--hooks/tests/test_rulesets_write_boundary.py110
10 files changed, 558 insertions, 0 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/_common.py b/hooks/_common.py
index e82f7ed..c1e0578 100644
--- a/hooks/_common.py
+++ b/hooks/_common.py
@@ -65,6 +65,24 @@ def respond_ask(reason: str, system_message: Optional[str] = None) -> None:
print(json.dumps(output))
+def respond_deny(reason: str, system_message: Optional[str] = None) -> None:
+ """Emit a PreToolUse response that blocks the tool call outright.
+
+ Unlike `respond_ask`, the user gets no approve option — the call is denied
+ and `reason` tells the agent why, so it can restructure and retry.
+ """
+ output: dict = {
+ "hookSpecificOutput": {
+ "hookEventName": "PreToolUse",
+ "permissionDecision": "deny",
+ "permissionDecisionReason": reason,
+ }
+ }
+ if system_message:
+ output["systemMessage"] = system_message
+ print(json.dumps(output))
+
+
def read_referenced_file(path: str, max_bytes: int = 1_000_000) -> Optional[str]:
"""Read a local file referenced by -F/--file/--body-file so its text can be
attribution-scanned. Return the text, or None if it can't be safely read
diff --git a/hooks/ai-wrap-teardown.sh b/hooks/ai-wrap-teardown.sh
new file mode 100755
index 0000000..ca73ae6
--- /dev/null
+++ b/hooks/ai-wrap-teardown.sh
@@ -0,0 +1,110 @@
+#!/usr/bin/env bash
+# Stop hook: tear down the ai-term session (or power off) AFTER a wrap-up.
+#
+# wrap-it-up.org drops a sentinel only at the very end of a teardown- or
+# shutdown-mode wrap, once commit+push is verified and the valediction is
+# delivered. This hook fires when Claude stops responding; on every NORMAL
+# stop there is no sentinel, so it is a silent no-op. Only after a wrap that
+# requested teardown does the matching sentinel exist, and only then does this
+# hook act — which is what keeps a routine end-of-turn from killing the
+# session.
+#
+# Decoupling via the Stop hook (rather than an inline workflow step) is what
+# lets the valediction flush before the session dies: teardown kills the very
+# tmux session Claude runs in, so it cannot happen inline.
+#
+# Two sentinels, keyed by the project basename (one ai-term session per
+# project, named aiv-<basename>):
+# /tmp/ai-wrap-teardown-<basename> -> cj/ai-term-quit "<basename>"
+# kill the aiv-<basename> tmux session (takes claude with it), kill the
+# vterm buffer, restore geometry. Defined in .emacs.d/modules/ai-term.el.
+# /tmp/ai-wrap-shutdown-<basename> -> cj/ai-term-shutdown-countdown
+# abort-able 10->1 echo-area countdown, then sudo shutdown now. Shutdown
+# supersedes teardown (killing the buffer is moot if powering off).
+#
+# The sentinel is removed BEFORE the emacsclient call: cj/ai-term-quit kills
+# the tmux session this hook runs inside, so the hook process may not survive
+# the call. Clearing first guarantees the sentinel never lingers to re-fire on
+# a later session in the same project. The emacs daemon is a separate process,
+# so it completes the teardown even when the hook is cut off mid-call.
+#
+# emacsclient absent or daemon unreachable: clear the sentinel and exit 0. The
+# session simply stays up — graceful degradation, never a wedge.
+#
+# Wire in ~/.claude/settings.json (see hooks/settings-snippet.json):
+#
+# "Stop": [
+# { "hooks": [
+# { "type": "command",
+# "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="$(printf '%s' "$payload" | jq -r '.cwd // empty' 2>/dev/null)"
+[ -z "$cwd" ] && cwd="$PWD"
+proj="$(basename "$cwd")"
+
+teardown_sentinel="/tmp/ai-wrap-teardown-${proj}"
+shutdown_sentinel="/tmp/ai-wrap-shutdown-${proj}"
+
+fire() {
+ # $1 = elisp form. Best-effort: only when emacsclient resolves.
+ command -v emacsclient >/dev/null 2>&1 || return 0
+ 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
+
+exit 0
diff --git a/hooks/git-commit-confirm.py b/hooks/git-commit-confirm.py
index 618ac20..cf01948 100755
--- a/hooks/git-commit-confirm.py
+++ b/hooks/git-commit-confirm.py
@@ -46,6 +46,7 @@ from _common import (
read_payload,
read_referenced_file,
respond_ask,
+ respond_deny,
scan_attribution,
)
@@ -53,6 +54,28 @@ from _common import (
MAX_FILES_SHOWN = 25
MAX_MESSAGE_LINES = 30
+# Recognized full-suite test runners across languages.
+TEST_RUNNER_RE = re.compile(
+ r"\b("
+ r"make\s+(?:test|check)"
+ r"|pytest"
+ r"|go\s+test"
+ r"|cargo\s+test"
+ r"|npm\s+(?:run\s+)?test"
+ r"|yarn\s+test"
+ r"|pnpm\s+(?:run\s+)?test"
+ r"|jest|vitest|bats|tox|rspec|phpunit|ctest"
+ r")\b"
+)
+
+GIT_COMMIT_RE = re.compile(r"(?:^|[\s;&|()\n])git\s+(?:-[^\s]+\s+)*commit\b")
+
+BUNDLED_TEST_REASON = (
+ "Blocked: a test run is bundled with `git commit` in one command, where a "
+ "red suite won't stop the commit. Run the full test suite as its own step, "
+ "read the result, and commit only on zero failures (see verification.md)."
+)
+
UNPARSEABLE_MESSAGE = (
"(commit message not parseable from command line; "
"will be edited interactively)"
@@ -68,6 +91,12 @@ def main() -> int:
if not is_git_commit(cmd):
return 0
+ # Hard gate: a test run bundled into the commit command is denied outright,
+ # because an ungated chain lets a red suite commit anyway.
+ if detect_bundled_test_run(cmd):
+ respond_deny(BUNDLED_TEST_REASON)
+ return 0
+
message = extract_commit_message(cmd)
staged = get_staged_files()
stats = get_diff_stats()
@@ -117,6 +146,34 @@ def collect_issues(message: str, staged: list[str], author: str) -> list[str]:
return issues
+def detect_bundled_test_run(cmd: str):
+ """Return a truthy reason if a `git commit` is chained after a test run in
+ one command via an ungated connector (a red suite wouldn't stop the commit).
+
+ `&&` between the test run and the commit is the one safe connector — the
+ commit runs only on a green suite — and is allowed. Any other chaining (`;`,
+ `&`, `|`, `||`, newline, or a pipe that masks the suite's exit) is flagged.
+ The test runner is matched only in the command *prefix* before `git commit`,
+ so a runner name inside the commit message never trips the detector.
+ """
+ if not cmd:
+ return None
+ commit = GIT_COMMIT_RE.search(cmd)
+ if not commit:
+ return None
+ prefix = cmd[: commit.start()]
+ runs = list(TEST_RUNNER_RE.finditer(prefix))
+ if not runs:
+ return None
+ # Everything between the last test run and the commit. Strip the safe `&&`
+ # connectors; anything left that chains commands means the commit isn't
+ # gated on the suite's success.
+ segment = prefix[runs[-1].end():].replace("&&", "")
+ if re.search(r"[;|\n&]", segment):
+ return BUNDLED_TEST_REASON
+ return None
+
+
def is_git_commit(cmd: str) -> bool:
"""True if the command invokes `git commit` (possibly with env/cd prefix)."""
# Strip leading assignments and subshells; find a `git commit` word boundary
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 a5f9d9c..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" },
@@ -30,6 +36,14 @@
{ "type": "command", "command": "~/.claude/hooks/destructive-bash-confirm.py" }
]
}
+ ],
+ "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_git_commit_confirm.py b/hooks/tests/test_git_commit_confirm.py
index 83519ad..4bf95cf 100644
--- a/hooks/tests/test_git_commit_confirm.py
+++ b/hooks/tests/test_git_commit_confirm.py
@@ -95,3 +95,61 @@ def test_oversized_file_falls_through_and_hook_asks(tmp_path, monkeypatch):
# And the hook would ask, because UNPARSEABLE_MESSAGE is a flagged issue.
issues = hook.collect_issues(msg, staged=["a.py"], author="Dev <d@e.com>")
assert any("not parseable" in i for i in issues)
+
+
+# --- bundled test-run + commit: the hard gate ------------------------------
+
+def test_bundle_semicolon_make_test_is_flagged():
+ assert hook.detect_bundled_test_run('make test; git commit -m "x"')
+
+
+def test_bundle_ampersand_gated_is_allowed():
+ # `&&` runs the commit only on a green suite — safe, not flagged.
+ assert hook.detect_bundled_test_run('make test && git commit -m "x"') is None
+
+
+def test_bundle_pytest_semicolon_is_flagged():
+ assert hook.detect_bundled_test_run('pytest ; git commit -m "x"')
+
+
+def test_bundle_npm_test_is_flagged():
+ assert hook.detect_bundled_test_run('npm test; git commit -m "x"')
+
+
+def test_bundle_go_test_is_flagged():
+ assert hook.detect_bundled_test_run('go test ./...; git commit -m "x"')
+
+
+def test_bundle_cargo_test_is_flagged():
+ assert hook.detect_bundled_test_run('cargo test ; git commit -m "x"')
+
+
+def test_bundle_bats_is_flagged():
+ assert hook.detect_bundled_test_run('bats tests/ ; git commit -m "x"')
+
+
+def test_bundle_pipe_masks_exit_is_flagged():
+ # `make test | tee log` exits with tee's status, so && gates on tee, not
+ # the suite — a red suite would still commit. Flag it.
+ assert hook.detect_bundled_test_run('make test | tee log && git commit -m "x"')
+
+
+def test_bundle_or_connector_is_flagged():
+ assert hook.detect_bundled_test_run('make test || git commit -m "x"')
+
+
+def test_runner_only_in_message_is_not_flagged():
+ # "make test" inside the commit message must not trip the detector.
+ assert hook.detect_bundled_test_run('git commit -m "remember to make test"') is None
+
+
+def test_plain_commit_is_not_flagged():
+ assert hook.detect_bundled_test_run('git commit -m "fix: thing"') is None
+
+
+def test_gated_chain_before_commit_is_allowed():
+ assert hook.detect_bundled_test_run('cd proj && pytest && git commit -m "x"') is None
+
+
+def test_empty_command_is_not_flagged():
+ assert hook.detect_bundled_test_run("") is None
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