diff options
Diffstat (limited to 'hooks')
| -rw-r--r-- | hooks/README.md | 3 | ||||
| -rw-r--r-- | hooks/_common.py | 18 | ||||
| -rwxr-xr-x | hooks/ai-wrap-teardown.sh | 110 | ||||
| -rwxr-xr-x | hooks/git-commit-confirm.py | 57 | ||||
| -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 | 14 | ||||
| -rw-r--r-- | hooks/tests/test_git_commit_confirm.py | 58 | ||||
| -rw-r--r-- | hooks/tests/test_rulesets_write_boundary.py | 110 |
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 |
