From 79234791e6c8a25881692aee1d67c362888b1187 Mon Sep 17 00:00:00 2001 From: jonahgabriel Date: Fri, 1 May 2026 17:43:59 -0400 Subject: [PATCH 1/6] feat(hooks): add task-boundary test discovery hook [OMN-7261] Task-boundary test hook fires on git commit / gh pr create, discovers relevant tests for staged Python files, and runs them with a 120s budget. Blocks on test failure; fails open on timeout/infra errors. Opt-in via TASK_BOUNDARY_TESTS bit in ONEX_HOOKS_MASK (default: disabled until user enables it). Files: - lib/task_boundary_tests.py: test discovery + execution logic - scripts/pre_tool_use_task_boundary_tests.sh: shell hook wrapper - hooks.json: registered under PreToolUse with Bash matcher - lib/hook_bits.sh: regenerated with TASK_BOUNDARY_TESTS bit 58 - tests/hooks/test_task_boundary_tests.py: 37 unit tests --- plugins/onex/hooks/hooks.json | 9 + plugins/onex/hooks/lib/hook_bits.sh | 3 +- plugins/onex/hooks/lib/task_boundary_tests.py | 192 +++++++++++ .../pre_tool_use_task_boundary_tests.sh | 103 ++++++ tests/hooks/test_task_boundary_tests.py | 308 ++++++++++++++++++ 5 files changed, 614 insertions(+), 1 deletion(-) create mode 100644 plugins/onex/hooks/lib/task_boundary_tests.py create mode 100755 plugins/onex/hooks/scripts/pre_tool_use_task_boundary_tests.sh create mode 100644 tests/hooks/test_task_boundary_tests.py diff --git a/plugins/onex/hooks/hooks.json b/plugins/onex/hooks/hooks.json index e3c9d7db9e..f3fd01138a 100644 --- a/plugins/onex/hooks/hooks.json +++ b/plugins/onex/hooks/hooks.json @@ -365,6 +365,15 @@ } ] }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/scripts/pre_tool_use_task_boundary_tests.sh" + } + ] + }, { "matcher": "^SendMessage$", "hooks": [ diff --git a/plugins/onex/hooks/lib/hook_bits.sh b/plugins/onex/hooks/lib/hook_bits.sh index 1ea7ef1101..92f7dd51b7 100644 --- a/plugins/onex/hooks/lib/hook_bits.sh +++ b/plugins/onex/hooks/lib/hook_bits.sh @@ -4,7 +4,7 @@ # GENERATED BY omnibase_core/scripts/gen_hook_bits.py — DO NOT EDIT # Regenerate via: uv run python scripts/gen_hook_bits.py --write -HOOK_BITS_DEFAULT_MASK=0x3ffffffffffffff +HOOK_BITS_DEFAULT_MASK=0x7ffffffffffffff # hook_bits_bit_for_name NAME # Prints the bit assigned to NAME. Bash 3.2 has no associative arrays, and @@ -70,6 +70,7 @@ hook_bits_bit_for_name() { SESSION_START) echo 0x80000000000000 ;; SESSION_START_ONEX_CLI_PIN_CHECK) echo 0x100000000000000 ;; HANDOFF_NUDGE) echo 0x200000000000000 ;; + TASK_BOUNDARY_TESTS) echo 0x400000000000000 ;; *) return 1 ;; esac } diff --git a/plugins/onex/hooks/lib/task_boundary_tests.py b/plugins/onex/hooks/lib/task_boundary_tests.py new file mode 100644 index 0000000000..86a7f6b975 --- /dev/null +++ b/plugins/onex/hooks/lib/task_boundary_tests.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +"""Task-boundary test discovery and execution hook. + +Fired on PreToolUse(Bash) when the command contains ``git commit`` or +``gh pr create``. Discovers relevant tests for staged Python files and +runs them with a 120 s budget. Blocks the tool call on test failure; +fails open on timeout / infrastructure errors. + +Exit codes: + 0 — all checks pass (or no tests applicable / infra failure) + 2 — test failures found (blocks the commit/PR) +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +DEBOUNCE_SECONDS = 60 +TEST_TIMEOUT_SECONDS = 120 + +_RE_GIT_COMMIT = re.compile(r"(^|\s|&&|\|\||;)\s*git\s+commit\b") +_RE_GH_PR_CREATE = re.compile(r"(^|\s|&&|\|)\s*gh\s+pr\s+create\b") + + +def _is_trigger_command(command: str) -> bool: + return bool(_RE_GIT_COMMIT.search(command) or _RE_GH_PR_CREATE.search(command)) + + +def _repo_root_for(cwd: str | None) -> Path | None: + start = Path(cwd) if cwd else Path.cwd() + for p in [start, *start.parents]: + if (p / ".git").exists() or (p / ".git").is_file(): + return p + return None + + +def _staged_python_files(repo_root: Path) -> list[str]: + try: + r = subprocess.run( + ["git", "diff", "--cached", "--name-only", "--", "*.py"], + capture_output=True, + text=True, + cwd=repo_root, + timeout=5, + check=False, + ) + if r.returncode == 0: + return [f for f in r.stdout.strip().split("\n") if f] + except (subprocess.TimeoutExpired, Exception): + pass + return [] + + +def _discover_tests(repo_root: Path, changed_files: list[str]) -> list[str]: + tests_dir = repo_root / "tests" + if not tests_dir.is_dir(): + return [] + + collected: list[str] = [] + for changed in changed_files: + stem = Path(changed).stem + candidates = [ + tests_dir / f"test_{stem}.py", + ] + parent = Path(changed).parent + if str(parent) != ".": + candidates.append(tests_dir / parent / f"test_{stem}.py") + candidates.append(tests_dir / "hooks" / f"test_{stem}.py") + + for cand in candidates: + if cand.is_file() and str(cand) not in collected: + collected.append(str(cand)) + + return collected + + +def _debounce_key(repo_root: Path) -> str: + h = hashlib.sha256(str(repo_root).encode()).hexdigest()[:12] + return os.path.join(tempfile.gettempdir(), f"onex_task_boundary_last_run_{h}") + + +def _should_skip_debounce(key: str) -> bool: + try: + p = Path(key) + if not p.exists(): + return False + mtime = p.stat().st_mtime + return (time.time() - mtime) < DEBOUNCE_SECONDS + except OSError: + return False + + +def _touch_debounce(key: str) -> None: + try: + Path(key).touch() + except OSError: + pass + + +def _run_tests(repo_root: Path, test_files: list[str]) -> tuple[bool, str]: + try: + r = subprocess.run( + [ + sys.executable, + "-m", + "pytest", + *test_files, + "--timeout=120", + "-q", + "--tb=short", + ], + capture_output=True, + text=True, + cwd=repo_root, + timeout=TEST_TIMEOUT_SECONDS, + check=False, + ) + except subprocess.TimeoutExpired: + return True, "task-boundary-tests: timed out after 120s (failing open)" + except FileNotFoundError: + return True, "task-boundary-tests: pytest not found (failing open)" + + if r.returncode == 0: + return True, "" + + output = (r.stdout + "\n" + r.stderr).strip() + if len(output) > 2000: + output = output[:2000] + "\n... (truncated)" + return False, output + + +def main() -> None: + tool_info = json.loads(sys.stdin.read()) + + tool_name = tool_info.get("tool_name", "") + if tool_name != "Bash": + json.dump(tool_info, sys.stdout) + sys.exit(0) + + command = tool_info.get("tool_input", {}).get("command", "") + if not _is_trigger_command(command): + json.dump(tool_info, sys.stdout) + sys.exit(0) + + cwd = tool_info.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") + repo_root = _repo_root_for(cwd) + if repo_root is None: + json.dump(tool_info, sys.stdout) + sys.exit(0) + + debounce_key = _debounce_key(repo_root) + if _should_skip_debounce(debounce_key): + json.dump(tool_info, sys.stdout) + sys.exit(0) + + changed = _staged_python_files(repo_root) + if not changed: + json.dump(tool_info, sys.stdout) + sys.exit(0) + + tests = _discover_tests(repo_root, changed) + if not tests: + json.dump(tool_info, sys.stdout) + sys.exit(0) + + _touch_debounce(debounce_key) + + passed, message = _run_tests(repo_root, tests) + if passed: + json.dump(tool_info, sys.stdout) + sys.exit(0) + + result = { + "decision": "block", + "reason": "Task-boundary tests failed:\n" + message, + } + json.dump(result, sys.stdout) + sys.exit(2) + + +if __name__ == "__main__": + main() diff --git a/plugins/onex/hooks/scripts/pre_tool_use_task_boundary_tests.sh b/plugins/onex/hooks/scripts/pre_tool_use_task_boundary_tests.sh new file mode 100755 index 0000000000..0cad471f20 --- /dev/null +++ b/plugins/onex/hooks/scripts/pre_tool_use_task_boundary_tests.sh @@ -0,0 +1,103 @@ +#!/bin/bash +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +# PreToolUse Task-Boundary Tests Hook [OMN-7261] +# Intercepts git commit / gh pr create and runs targeted tests first. + +set -euo pipefail +_OMNICLAUDE_HOOK_NAME="$(basename "${BASH_SOURCE[0]}")" +source "$(dirname "${BASH_SOURCE[0]}")/error-guard.sh" 2>/dev/null || true + +_OMNICLAUDE_CALLER_CWD="${CLAUDE_PROJECT_DIR:-$PWD}" + +# shellcheck source=../lib/repo_guard.sh +. "$(dirname "${BASH_SOURCE[0]}")/../lib/repo_guard.sh" 2>/dev/null || true +if declare -F is_omninode_repo >/dev/null 2>&1; then + CLAUDE_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$_OMNICLAUDE_CALLER_CWD}" \ + is_omninode_repo || { + _OMNICLAUDE_PASSTHROUGH=$(cat) + echo "$_OMNICLAUDE_PASSTHROUGH" + trap - EXIT 2>/dev/null || true + exit 0 + } +fi + +cd "$HOME" 2>/dev/null || cd /tmp || true + +_SELF="$(realpath "${BASH_SOURCE[0]}" 2>/dev/null \ + || python3 -c "import os,sys; print(os.path.realpath(sys.argv[1]))" "${BASH_SOURCE[0]}")" +SCRIPT_DIR="$(cd "$(dirname "${_SELF}")" && pwd)" +PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "${SCRIPT_DIR}/../.." && pwd)}" +unset _SELF SCRIPT_DIR +HOOKS_DIR="${PLUGIN_ROOT}/hooks" +HOOKS_LIB="${HOOKS_DIR}/lib" +source "$(dirname "${BASH_SOURCE[0]}")/onex-paths.sh" || { echo "ONEX_STATE_DIR not set" >&2; exit 1; } +LOG_FILE="${ONEX_HOOK_LOG}" + +PROJECT_ROOT="${PLUGIN_ROOT}/../.." +if [[ -f "${PROJECT_ROOT}/.env" ]]; then + PROJECT_ROOT="$(cd "${PROJECT_ROOT}" && pwd)" +elif [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then + PROJECT_ROOT="${CLAUDE_PROJECT_DIR}" +else + PROJECT_ROOT="$(pwd)" +fi + +mkdir -p "$(dirname "$LOG_FILE")" + +export PYTHONPATH="${PROJECT_ROOT}:${PLUGIN_ROOT}/lib:${HOOKS_LIB}:${PYTHONPATH:-}" + +if [[ -f "$PROJECT_ROOT/.env" ]]; then + set -a + source "$PROJECT_ROOT/.env" 2>/dev/null || true + set +a +fi + +source "${HOOKS_DIR}/scripts/common.sh" +onex_hook_gate TASK_BOUNDARY_TESTS || exit 0 + +TOOL_INFO=$(cat) +if ! TOOL_NAME=$(echo "$TOOL_INFO" | jq -er '.tool_name // empty' 2>>"$LOG_FILE"); then + echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] ERROR: invalid hook JSON; failing open" >> "$LOG_FILE" + echo "$TOOL_INFO" + exit 0 +fi + +if [[ "$TOOL_NAME" != "Bash" ]]; then + echo "$TOOL_INFO" + exit 0 +fi + +COMMAND=$(echo "$TOOL_INFO" | jq -r '.tool_input.command // empty' 2>/dev/null) +if [[ -z "$COMMAND" ]]; then + echo "$TOOL_INFO" + exit 0 +fi + +if ! echo "$COMMAND" | grep -qE '(^|\s|&&|\|\||;)git\s+commit(\s|$)'; then + if ! echo "$COMMAND" | grep -qE '(^|\s|&&|\|)gh\s+pr\s+create(\s|$)'; then + echo "$TOOL_INFO" + exit 0 + fi +fi + +echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] Task-boundary tests triggered" >> "$LOG_FILE" + +set +e +RESULT=$(echo "$TOOL_INFO" | \ + env -u PYTHONPATH "$PYTHON_CMD" "${HOOKS_LIB}/task_boundary_tests.py" 2>>"$LOG_FILE") +EXIT_CODE=$? +set -e + +if [ $EXIT_CODE -eq 0 ]; then + echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] Task-boundary tests PASSED" >> "$LOG_FILE" + echo "$RESULT" +elif [ $EXIT_CODE -eq 2 ]; then + echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] Task-boundary tests BLOCKED" >> "$LOG_FILE" + echo "$RESULT" + exit 2 +else + echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] ERROR: Task-boundary tests failed with code $EXIT_CODE, failing open" >> "$LOG_FILE" + echo "$TOOL_INFO" + exit 0 +fi diff --git a/tests/hooks/test_task_boundary_tests.py b/tests/hooks/test_task_boundary_tests.py new file mode 100644 index 0000000000..9c50428852 --- /dev/null +++ b/tests/hooks/test_task_boundary_tests.py @@ -0,0 +1,308 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +"""Tests for task_boundary_tests hook — test discovery and execution. + +Coverage: + - Trigger condition matching (git commit / gh pr create) + - Non-trigger commands pass through + - Debounce logic (skip if <60s) + - Test discovery heuristics (file path → test file mapping) + - Skip when no tests found + - Skip when no staged Python files + - Timeout handling (failing open) + - Full main() integration with mocked subprocess/git +""" + +from __future__ import annotations + +import io +import json +import os +import pathlib +import sys +import time +from pathlib import Path +from unittest.mock import MagicMock, patch + +_LIB_DIR = ( + pathlib.Path(__file__).parent.parent.parent / "plugins" / "onex" / "hooks" / "lib" +) +if str(_LIB_DIR) not in sys.path: + sys.path.insert(0, str(_LIB_DIR)) + +import task_boundary_tests as tbt # noqa: E402 + + +class TestTriggerConditions: + def test_git_commit_triggers(self) -> None: + assert tbt._is_trigger_command("git commit -m 'fix: thing'") + + def test_git_commit_with_ampersand(self) -> None: + assert tbt._is_trigger_command("ruff check src/ && git commit -m 'x'") + + def test_git_commit_with_semicolon(self) -> None: + assert tbt._is_trigger_command("echo hi; git commit -m 'x'") + + def test_gh_pr_create_triggers(self) -> None: + assert tbt._is_trigger_command("gh pr create --title 'x'") + + def test_gh_pr_create_with_pipe(self) -> None: + assert tbt._is_trigger_command("echo body | gh pr create --title 'x'") + + def test_git_push_does_not_trigger(self) -> None: + assert not tbt._is_trigger_command("git push origin main") + + def test_ls_does_not_trigger(self) -> None: + assert not tbt._is_trigger_command("ls -la") + + def test_git_status_does_not_trigger(self) -> None: + assert not tbt._is_trigger_command("git status") + + def test_pytest_does_not_trigger(self) -> None: + assert not tbt._is_trigger_command("pytest tests/ -v") + + def test_empty_command_does_not_trigger(self) -> None: + assert not tbt._is_trigger_command("") + + +class TestDebounceLogic: + def test_no_marker_file_means_no_skip(self, tmp_path: Path) -> None: + key = str(tmp_path / "nonexistent") + assert not tbt._should_skip_debounce(key) + + def test_recent_marker_means_skip(self, tmp_path: Path) -> None: + marker = tmp_path / "last_run" + marker.touch() + key = str(marker) + assert tbt._should_skip_debounce(key) + + def test_old_marker_means_no_skip(self, tmp_path: Path) -> None: + marker = tmp_path / "last_run" + marker.touch() + old_time = time.time() - 120 + os.utime(str(marker), (old_time, old_time)) + key = str(marker) + assert not tbt._should_skip_debounce(key) + + def test_debounce_key_deterministic(self) -> None: + root = Path("/some/repo") + k1 = tbt._debounce_key(root) + k2 = tbt._debounce_key(root) + assert k1 == k2 + + def test_debounce_key_differs_per_repo(self) -> None: + k1 = tbt._debounce_key(Path("/repo/a")) + k2 = tbt._debounce_key(Path("/repo/b")) + assert k1 != k2 + + def test_touch_debounce_creates_file(self, tmp_path: Path) -> None: + marker = tmp_path / "marker" + tbt._touch_debounce(str(marker)) + assert marker.exists() + + +class TestDiscoverTests: + def test_finds_matching_test_file(self, tmp_path: Path) -> None: + tests = tmp_path / "tests" + tests.mkdir() + (tests / "test_foo.py").write_text("# test") + result = tbt._discover_tests(tmp_path, ["foo.py"]) + assert len(result) == 1 + assert "test_foo.py" in result[0] + + def test_finds_nested_test_file(self, tmp_path: Path) -> None: + tests = tmp_path / "tests" / "sub" + tests.mkdir(parents=True) + (tests / "test_bar.py").write_text("# test") + result = tbt._discover_tests(tmp_path, ["sub/bar.py"]) + assert len(result) == 1 + assert "test_bar.py" in result[0] + + def test_finds_hooks_test_file(self, tmp_path: Path) -> None: + tests = tmp_path / "tests" / "hooks" + tests.mkdir(parents=True) + (tests / "test_baz.py").write_text("# test") + result = tbt._discover_tests(tmp_path, ["hooks/baz.py"]) + assert len(result) == 1 + assert "test_baz.py" in result[0] + + def test_no_tests_dir_returns_empty(self, tmp_path: Path) -> None: + result = tbt._discover_tests(tmp_path, ["foo.py"]) + assert result == [] + + def test_no_matching_test_returns_empty(self, tmp_path: Path) -> None: + tests = tmp_path / "tests" + tests.mkdir() + result = tbt._discover_tests(tmp_path, ["nonexistent.py"]) + assert result == [] + + def test_deduplication(self, tmp_path: Path) -> None: + tests = tmp_path / "tests" + tests.mkdir() + (tests / "test_foo.py").write_text("# test") + result = tbt._discover_tests(tmp_path, ["foo.py", "foo.py"]) + assert len(result) == 1 + + +class TestStagedPythonFiles: + def test_returns_empty_on_non_repo(self, tmp_path: Path) -> None: + with patch("subprocess.run", side_effect=Exception("boom")): + result = tbt._staged_python_files(tmp_path) + assert result == [] + + +class TestRunTests: + def test_passing_tests(self, tmp_path: Path) -> None: + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "1 passed" + mock_result.stderr = "" + with patch("subprocess.run", return_value=mock_result): + ok, msg = tbt._run_tests(tmp_path, ["test_foo.py"]) + assert ok is True + assert msg == "" + + def test_failing_tests(self, tmp_path: Path) -> None: + mock_result = MagicMock() + mock_result.returncode = 1 + mock_result.stdout = "FAILED test_foo.py::test_x - assert False" + mock_result.stderr = "" + with patch("subprocess.run", return_value=mock_result): + ok, msg = tbt._run_tests(tmp_path, ["test_foo.py"]) + assert ok is False + assert "FAILED" in msg + + def test_timeout_fails_open(self, tmp_path: Path) -> None: + import subprocess + + with patch( + "subprocess.run", side_effect=subprocess.TimeoutExpired("pytest", 120) + ): + ok, msg = tbt._run_tests(tmp_path, ["test_foo.py"]) + assert ok is True + assert "timed out" in msg + + def test_missing_pytest_fails_open(self, tmp_path: Path) -> None: + with patch("subprocess.run", side_effect=FileNotFoundError): + ok, msg = tbt._run_tests(tmp_path, ["test_foo.py"]) + assert ok is True + assert "pytest not found" in msg + + +class TestMainIntegration: + @staticmethod + def _run_main(hook_input: dict) -> tuple[str, int]: + raw = json.dumps(hook_input) + captured = io.StringIO() + with patch("sys.stdin", io.StringIO(raw)): + with patch("sys.stdout", captured): + try: + tbt.main() + code = 0 + except SystemExit as exc: + code = exc.code if isinstance(exc.code, int) else 0 + return captured.getvalue(), code + + def test_non_bash_passes_through(self) -> None: + out, code = self._run_main({"tool_name": "Read", "tool_input": {}}) + assert code == 0 + assert json.loads(out)["tool_name"] == "Read" + + def test_non_trigger_command_passes_through(self) -> None: + out, code = self._run_main( + {"tool_name": "Bash", "tool_input": {"command": "git push origin main"}} + ) + assert code == 0 + assert json.loads(out)["tool_name"] == "Bash" + + def test_trigger_but_no_staged_files_passes(self) -> None: + with patch.object(tbt, "_repo_root_for", return_value=Path("/fake")): + with patch.object(tbt, "_staged_python_files", return_value=[]): + out, code = self._run_main( + { + "tool_name": "Bash", + "tool_input": {"command": "git commit -m 'x'"}, + } + ) + assert code == 0 + + def test_trigger_but_no_tests_found_passes(self) -> None: + with patch.object(tbt, "_repo_root_for", return_value=Path("/fake")): + with patch.object(tbt, "_staged_python_files", return_value=["foo.py"]): + with patch.object(tbt, "_discover_tests", return_value=[]): + out, code = self._run_main( + { + "tool_name": "Bash", + "tool_input": {"command": "git commit -m 'x'"}, + } + ) + assert code == 0 + + def test_trigger_debounced_passes(self) -> None: + with patch.object(tbt, "_repo_root_for", return_value=Path("/fake")): + with patch.object(tbt, "_should_skip_debounce", return_value=True): + out, code = self._run_main( + { + "tool_name": "Bash", + "tool_input": {"command": "git commit -m 'x'"}, + } + ) + assert code == 0 + + def test_failing_tests_blocks(self) -> None: + with patch.object(tbt, "_repo_root_for", return_value=Path("/fake")): + with patch.object(tbt, "_staged_python_files", return_value=["foo.py"]): + with patch.object( + tbt, "_discover_tests", return_value=["tests/test_foo.py"] + ): + with patch.object(tbt, "_should_skip_debounce", return_value=False): + with patch.object( + tbt, "_run_tests", return_value=(False, "FAILED test_x") + ): + with patch.object(tbt, "_touch_debounce"): + out, code = self._run_main( + { + "tool_name": "Bash", + "tool_input": {"command": "git commit -m 'x'"}, + } + ) + assert code == 2 + result = json.loads(out) + assert result["decision"] == "block" + assert "FAILED" in result["reason"] + + def test_passing_tests_allows(self) -> None: + with patch.object(tbt, "_repo_root_for", return_value=Path("/fake")): + with patch.object(tbt, "_staged_python_files", return_value=["foo.py"]): + with patch.object( + tbt, "_discover_tests", return_value=["tests/test_foo.py"] + ): + with patch.object(tbt, "_should_skip_debounce", return_value=False): + with patch.object(tbt, "_run_tests", return_value=(True, "")): + with patch.object(tbt, "_touch_debounce"): + out, code = self._run_main( + { + "tool_name": "Bash", + "tool_input": {"command": "git commit -m 'x'"}, + } + ) + assert code == 0 + assert json.loads(out)["tool_name"] == "Bash" + + +class TestRepoRootFor: + def test_finds_git_dir(self, tmp_path: Path) -> None: + (tmp_path / ".git").mkdir() + result = tbt._repo_root_for(str(tmp_path)) + assert result == tmp_path + + def test_finds_git_file(self, tmp_path: Path) -> None: + (tmp_path / ".git").write_text("gitdir: something") + result = tbt._repo_root_for(str(tmp_path)) + assert result == tmp_path + + def test_returns_none_when_no_git(self, tmp_path: Path) -> None: + sub = tmp_path / "subdir" + sub.mkdir() + result = tbt._repo_root_for(str(sub)) + assert result is None From 449b379caa60dc8bbc5f818dbe6d9ee511cc63db Mon Sep 17 00:00:00 2001 From: jonahgabriel Date: Sun, 3 May 2026 02:14:18 -0400 Subject: [PATCH 2/6] fix(OMN-7261): harden task-boundary hook gate --- plugins/onex/hooks/lib/task_boundary_tests.py | 5 +- .../pre_tool_use_task_boundary_tests.sh | 34 ++++++-- tests/hooks/test_task_boundary_tests.py | 86 +++++++++++++++++++ 3 files changed, 116 insertions(+), 9 deletions(-) diff --git a/plugins/onex/hooks/lib/task_boundary_tests.py b/plugins/onex/hooks/lib/task_boundary_tests.py index 86a7f6b975..677681ada1 100644 --- a/plugins/onex/hooks/lib/task_boundary_tests.py +++ b/plugins/onex/hooks/lib/task_boundary_tests.py @@ -29,7 +29,7 @@ TEST_TIMEOUT_SECONDS = 120 _RE_GIT_COMMIT = re.compile(r"(^|\s|&&|\|\||;)\s*git\s+commit\b") -_RE_GH_PR_CREATE = re.compile(r"(^|\s|&&|\|)\s*gh\s+pr\s+create\b") +_RE_GH_PR_CREATE = re.compile(r"(^|\s|&&|\||;)\s*gh\s+pr\s+create\b") def _is_trigger_command(command: str) -> bool: @@ -173,10 +173,9 @@ def main() -> None: json.dump(tool_info, sys.stdout) sys.exit(0) - _touch_debounce(debounce_key) - passed, message = _run_tests(repo_root, tests) if passed: + _touch_debounce(debounce_key) json.dump(tool_info, sys.stdout) sys.exit(0) diff --git a/plugins/onex/hooks/scripts/pre_tool_use_task_boundary_tests.sh b/plugins/onex/hooks/scripts/pre_tool_use_task_boundary_tests.sh index 0cad471f20..ff4260ea1a 100755 --- a/plugins/onex/hooks/scripts/pre_tool_use_task_boundary_tests.sh +++ b/plugins/onex/hooks/scripts/pre_tool_use_task_boundary_tests.sh @@ -28,12 +28,37 @@ _SELF="$(realpath "${BASH_SOURCE[0]}" 2>/dev/null \ || python3 -c "import os,sys; print(os.path.realpath(sys.argv[1]))" "${BASH_SOURCE[0]}")" SCRIPT_DIR="$(cd "$(dirname "${_SELF}")" && pwd)" PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "${SCRIPT_DIR}/../.." && pwd)}" -unset _SELF SCRIPT_DIR +unset _SELF HOOKS_DIR="${PLUGIN_ROOT}/hooks" HOOKS_LIB="${HOOKS_DIR}/lib" -source "$(dirname "${BASH_SOURCE[0]}")/onex-paths.sh" || { echo "ONEX_STATE_DIR not set" >&2; exit 1; } +TOOL_INFO=$(cat) +source "${SCRIPT_DIR}/onex-paths.sh" || { + echo "ONEX_STATE_DIR not set" >&2 + echo "$TOOL_INFO" + exit 0 +} +unset SCRIPT_DIR LOG_FILE="${ONEX_HOOK_LOG}" +HOOK_BITS_PATH="${HOOKS_LIB}/hook_bits.sh" +if [[ -f "$HOOK_BITS_PATH" ]]; then + # shellcheck source=../lib/hook_bits.sh + source "$HOOK_BITS_PATH" + TASK_BOUNDARY_BIT="$(hook_bits_bit_for_name TASK_BOUNDARY_TESTS 2>/dev/null || true)" + if [[ -n "$TASK_BOUNDARY_BIT" ]]; then + if [[ -z "${ONEX_HOOKS_MASK:-}" ]]; then + echo "$TOOL_INFO" + exit 0 + fi + TASK_BOUNDARY_MASK="$(hook_bits_parse_mask "$ONEX_HOOKS_MASK")" + if ! hook_bits_is_enabled "$TASK_BOUNDARY_MASK" "$TASK_BOUNDARY_BIT"; then + echo "$TOOL_INFO" + exit 0 + fi + fi +fi +unset HOOK_BITS_PATH TASK_BOUNDARY_BIT TASK_BOUNDARY_MASK + PROJECT_ROOT="${PLUGIN_ROOT}/../.." if [[ -f "${PROJECT_ROOT}/.env" ]]; then PROJECT_ROOT="$(cd "${PROJECT_ROOT}" && pwd)" @@ -54,9 +79,6 @@ if [[ -f "$PROJECT_ROOT/.env" ]]; then fi source "${HOOKS_DIR}/scripts/common.sh" -onex_hook_gate TASK_BOUNDARY_TESTS || exit 0 - -TOOL_INFO=$(cat) if ! TOOL_NAME=$(echo "$TOOL_INFO" | jq -er '.tool_name // empty' 2>>"$LOG_FILE"); then echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] ERROR: invalid hook JSON; failing open" >> "$LOG_FILE" echo "$TOOL_INFO" @@ -75,7 +97,7 @@ if [[ -z "$COMMAND" ]]; then fi if ! echo "$COMMAND" | grep -qE '(^|\s|&&|\|\||;)git\s+commit(\s|$)'; then - if ! echo "$COMMAND" | grep -qE '(^|\s|&&|\|)gh\s+pr\s+create(\s|$)'; then + if ! echo "$COMMAND" | grep -qE '(^|\s|&&|\||;)gh\s+pr\s+create(\s|$)'; then echo "$TOOL_INFO" exit 0 fi diff --git a/tests/hooks/test_task_boundary_tests.py b/tests/hooks/test_task_boundary_tests.py index 9c50428852..8f80614544 100644 --- a/tests/hooks/test_task_boundary_tests.py +++ b/tests/hooks/test_task_boundary_tests.py @@ -19,6 +19,7 @@ import json import os import pathlib +import subprocess import sys import time from pathlib import Path @@ -49,6 +50,9 @@ def test_gh_pr_create_triggers(self) -> None: def test_gh_pr_create_with_pipe(self) -> None: assert tbt._is_trigger_command("echo body | gh pr create --title 'x'") + def test_gh_pr_create_with_semicolon(self) -> None: + assert tbt._is_trigger_command("echo hi; gh pr create --title 'x'") + def test_git_push_does_not_trigger(self) -> None: assert not tbt._is_trigger_command("git push origin main") @@ -289,6 +293,27 @@ def test_passing_tests_allows(self) -> None: assert code == 0 assert json.loads(out)["tool_name"] == "Bash" + def test_debounce_marker_touched_only_after_success(self) -> None: + with patch.object(tbt, "_repo_root_for", return_value=Path("/fake")): + with patch.object(tbt, "_staged_python_files", return_value=["foo.py"]): + with patch.object( + tbt, "_discover_tests", return_value=["tests/test_foo.py"] + ): + with patch.object(tbt, "_should_skip_debounce", return_value=False): + with patch.object( + tbt, "_run_tests", return_value=(False, "FAILED test_x") + ): + with patch.object(tbt, "_touch_debounce") as touch: + out, code = self._run_main( + { + "tool_name": "Bash", + "tool_input": {"command": "git commit -m 'x'"}, + } + ) + assert code == 2 + assert "FAILED" in json.loads(out)["reason"] + touch.assert_not_called() + class TestRepoRootFor: def test_finds_git_dir(self, tmp_path: Path) -> None: @@ -306,3 +331,64 @@ def test_returns_none_when_no_git(self, tmp_path: Path) -> None: sub.mkdir() result = tbt._repo_root_for(str(sub)) assert result is None + + +class TestShellHook: + @staticmethod + def _run_shell_hook( + extra_env: dict[str, str] | None = None, + ) -> subprocess.CompletedProcess: + repo_root = pathlib.Path(__file__).parent.parent.parent + script = ( + repo_root + / "plugins" + / "onex" + / "hooks" + / "scripts" + / "pre_tool_use_task_boundary_tests.sh" + ) + payload = { + "tool_name": "Bash", + "tool_input": {"command": "git commit -m 'x'"}, + } + env = os.environ.copy() + env.update( + { + "CLAUDE_PLUGIN_ROOT": str(repo_root / "plugins" / "onex"), + "CLAUDE_PROJECT_DIR": str(repo_root), + "ONEX_HOOKS_MASK": "0", + "ONEX_STATE_DIR": str(repo_root / ".onex_state_test"), + } + ) + if extra_env: + env.update(extra_env) + + return subprocess.run( + [str(script.resolve())], + input=json.dumps(payload), + text=True, + capture_output=True, + env=env, + check=False, + timeout=10, + ) + + def test_shell_hook_drains_input_when_gate_disabled(self) -> None: + payload = { + "tool_name": "Bash", + "tool_input": {"command": "git commit -m 'x'"}, + } + result = self._run_shell_hook({"ONEX_HOOKS_MASK": "0"}) + + assert result.returncode == 0 + assert json.loads(result.stdout) == payload + + def test_shell_hook_is_disabled_without_explicit_mask(self) -> None: + payload = { + "tool_name": "Bash", + "tool_input": {"command": "git commit -m 'x'"}, + } + result = self._run_shell_hook({"ONEX_HOOKS_MASK": ""}) + + assert result.returncode == 0 + assert json.loads(result.stdout) == payload From 220f927e4de49b5a2bbe7f6efeb94cfe3b3de3d3 Mon Sep 17 00:00:00 2001 From: jonahgabriel Date: Sun, 3 May 2026 02:15:57 -0400 Subject: [PATCH 3/6] fix(OMN-7261): rely on subprocess test timeout --- plugins/onex/hooks/lib/task_boundary_tests.py | 1 - tests/hooks/test_task_boundary_tests.py | 16 ++++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/plugins/onex/hooks/lib/task_boundary_tests.py b/plugins/onex/hooks/lib/task_boundary_tests.py index 677681ada1..bdc6eafc07 100644 --- a/plugins/onex/hooks/lib/task_boundary_tests.py +++ b/plugins/onex/hooks/lib/task_boundary_tests.py @@ -115,7 +115,6 @@ def _run_tests(repo_root: Path, test_files: list[str]) -> tuple[bool, str]: "-m", "pytest", *test_files, - "--timeout=120", "-q", "--tb=short", ], diff --git a/tests/hooks/test_task_boundary_tests.py b/tests/hooks/test_task_boundary_tests.py index 8f80614544..93c7c2a65a 100644 --- a/tests/hooks/test_task_boundary_tests.py +++ b/tests/hooks/test_task_boundary_tests.py @@ -166,6 +166,22 @@ def test_passing_tests(self, tmp_path: Path) -> None: assert ok is True assert msg == "" + def test_uses_subprocess_timeout_without_pytest_timeout_plugin( + self, tmp_path: Path + ) -> None: + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "1 passed" + mock_result.stderr = "" + with patch("subprocess.run", return_value=mock_result) as run: + ok, msg = tbt._run_tests(tmp_path, ["test_foo.py"]) + + assert ok is True + assert msg == "" + args = run.call_args.args[0] + assert "--timeout=120" not in args + assert run.call_args.kwargs["timeout"] == tbt.TEST_TIMEOUT_SECONDS + def test_failing_tests(self, tmp_path: Path) -> None: mock_result = MagicMock() mock_result.returncode = 1 From 594ee6e080ae73ee498599b673945233286f3cd5 Mon Sep 17 00:00:00 2001 From: jonahgabriel Date: Sun, 3 May 2026 02:23:26 -0400 Subject: [PATCH 4/6] fix(OMN-7261): align task-boundary trigger operators --- plugins/onex/hooks/lib/task_boundary_tests.py | 2 +- .../hooks/scripts/pre_tool_use_task_boundary_tests.sh | 4 ++-- tests/hooks/test_task_boundary_tests.py | 9 +++++++++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/plugins/onex/hooks/lib/task_boundary_tests.py b/plugins/onex/hooks/lib/task_boundary_tests.py index bdc6eafc07..b3684b8f32 100644 --- a/plugins/onex/hooks/lib/task_boundary_tests.py +++ b/plugins/onex/hooks/lib/task_boundary_tests.py @@ -29,7 +29,7 @@ TEST_TIMEOUT_SECONDS = 120 _RE_GIT_COMMIT = re.compile(r"(^|\s|&&|\|\||;)\s*git\s+commit\b") -_RE_GH_PR_CREATE = re.compile(r"(^|\s|&&|\||;)\s*gh\s+pr\s+create\b") +_RE_GH_PR_CREATE = re.compile(r"(^|\s|&&|\|\||\||;)\s*gh\s+pr\s+create\b") def _is_trigger_command(command: str) -> bool: diff --git a/plugins/onex/hooks/scripts/pre_tool_use_task_boundary_tests.sh b/plugins/onex/hooks/scripts/pre_tool_use_task_boundary_tests.sh index ff4260ea1a..2eb37c7e69 100755 --- a/plugins/onex/hooks/scripts/pre_tool_use_task_boundary_tests.sh +++ b/plugins/onex/hooks/scripts/pre_tool_use_task_boundary_tests.sh @@ -96,8 +96,8 @@ if [[ -z "$COMMAND" ]]; then exit 0 fi -if ! echo "$COMMAND" | grep -qE '(^|\s|&&|\|\||;)git\s+commit(\s|$)'; then - if ! echo "$COMMAND" | grep -qE '(^|\s|&&|\||;)gh\s+pr\s+create(\s|$)'; then +if ! echo "$COMMAND" | grep -qE '(^|\s|&&|\|\||\||;)git\s+commit(\s|$)'; then + if ! echo "$COMMAND" | grep -qE '(^|\s|&&|\|\||\||;)gh\s+pr\s+create(\s|$)'; then echo "$TOOL_INFO" exit 0 fi diff --git a/tests/hooks/test_task_boundary_tests.py b/tests/hooks/test_task_boundary_tests.py index 93c7c2a65a..422e12f0ad 100644 --- a/tests/hooks/test_task_boundary_tests.py +++ b/tests/hooks/test_task_boundary_tests.py @@ -44,12 +44,21 @@ def test_git_commit_with_ampersand(self) -> None: def test_git_commit_with_semicolon(self) -> None: assert tbt._is_trigger_command("echo hi; git commit -m 'x'") + def test_git_commit_with_pipe(self) -> None: + assert tbt._is_trigger_command("echo hi | git commit -m 'x'") + + def test_git_commit_with_or_pipe(self) -> None: + assert tbt._is_trigger_command("echo hi || git commit -m 'x'") + def test_gh_pr_create_triggers(self) -> None: assert tbt._is_trigger_command("gh pr create --title 'x'") def test_gh_pr_create_with_pipe(self) -> None: assert tbt._is_trigger_command("echo body | gh pr create --title 'x'") + def test_gh_pr_create_with_or_pipe(self) -> None: + assert tbt._is_trigger_command("echo body || gh pr create --title 'x'") + def test_gh_pr_create_with_semicolon(self) -> None: assert tbt._is_trigger_command("echo hi; gh pr create --title 'x'") From 61851556f6836c12951d10de12cbbb9ef0700a10 Mon Sep 17 00:00:00 2001 From: jonahgabriel Date: Sun, 3 May 2026 02:30:43 -0400 Subject: [PATCH 5/6] fix(OMN-7261): fail open when hook common loader is missing --- .../pre_tool_use_task_boundary_tests.sh | 13 +++++++++++- tests/hooks/test_task_boundary_tests.py | 20 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/plugins/onex/hooks/scripts/pre_tool_use_task_boundary_tests.sh b/plugins/onex/hooks/scripts/pre_tool_use_task_boundary_tests.sh index 2eb37c7e69..03e6fbdbe3 100755 --- a/plugins/onex/hooks/scripts/pre_tool_use_task_boundary_tests.sh +++ b/plugins/onex/hooks/scripts/pre_tool_use_task_boundary_tests.sh @@ -78,7 +78,18 @@ if [[ -f "$PROJECT_ROOT/.env" ]]; then set +a fi -source "${HOOKS_DIR}/scripts/common.sh" +COMMON_SH="${HOOKS_DIR}/scripts/common.sh" +if [[ ! -f "$COMMON_SH" ]]; then + echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] ERROR: common.sh missing; failing open" >> "$LOG_FILE" + echo "$TOOL_INFO" + exit 0 +fi +if ! source "$COMMON_SH"; then + echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] ERROR: failed to source common.sh; failing open" >> "$LOG_FILE" + echo "$TOOL_INFO" + exit 0 +fi +unset COMMON_SH if ! TOOL_NAME=$(echo "$TOOL_INFO" | jq -er '.tool_name // empty' 2>>"$LOG_FILE"); then echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] ERROR: invalid hook JSON; failing open" >> "$LOG_FILE" echo "$TOOL_INFO" diff --git a/tests/hooks/test_task_boundary_tests.py b/tests/hooks/test_task_boundary_tests.py index 422e12f0ad..5a68602533 100644 --- a/tests/hooks/test_task_boundary_tests.py +++ b/tests/hooks/test_task_boundary_tests.py @@ -417,3 +417,23 @@ def test_shell_hook_is_disabled_without_explicit_mask(self) -> None: assert result.returncode == 0 assert json.loads(result.stdout) == payload + + def test_shell_hook_fails_open_when_common_script_missing( + self, tmp_path: Path + ) -> None: + payload = { + "tool_name": "Bash", + "tool_input": {"command": "git commit -m 'x'"}, + } + fake_plugin = tmp_path / "plugin" + (fake_plugin / "hooks" / "lib").mkdir(parents=True) + result = self._run_shell_hook( + { + "CLAUDE_PLUGIN_ROOT": str(fake_plugin), + "ONEX_HOOKS_MASK": "0x400000000000000", + "ONEX_STATE_DIR": str(tmp_path / "state"), + } + ) + + assert result.returncode == 0 + assert json.loads(result.stdout) == payload From 5b807d17cb713f170493e36d47233870650dff4c Mon Sep 17 00:00:00 2001 From: jonahgabriel Date: Sun, 3 May 2026 03:11:12 -0400 Subject: [PATCH 6/6] fix(OMN-7261): harden task-boundary bootstrap failures --- .../pre_tool_use_task_boundary_tests.sh | 46 +++++++++++++--- tests/hooks/test_task_boundary_tests.py | 54 +++++++++++++++++++ 2 files changed, 93 insertions(+), 7 deletions(-) diff --git a/plugins/onex/hooks/scripts/pre_tool_use_task_boundary_tests.sh b/plugins/onex/hooks/scripts/pre_tool_use_task_boundary_tests.sh index 03e6fbdbe3..27582cf955 100755 --- a/plugins/onex/hooks/scripts/pre_tool_use_task_boundary_tests.sh +++ b/plugins/onex/hooks/scripts/pre_tool_use_task_boundary_tests.sh @@ -24,10 +24,24 @@ fi cd "$HOME" 2>/dev/null || cd /tmp || true -_SELF="$(realpath "${BASH_SOURCE[0]}" 2>/dev/null \ - || python3 -c "import os,sys; print(os.path.realpath(sys.argv[1]))" "${BASH_SOURCE[0]}")" -SCRIPT_DIR="$(cd "$(dirname "${_SELF}")" && pwd)" -PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "${SCRIPT_DIR}/../.." && pwd)}" +if ! _SELF="$(realpath "${BASH_SOURCE[0]}" 2>/dev/null \ + || python3 -c "import os,sys; print(os.path.realpath(sys.argv[1]))" "${BASH_SOURCE[0]}")"; then + _OMNICLAUDE_PASSTHROUGH=$(cat) + echo "$_OMNICLAUDE_PASSTHROUGH" + exit 0 +fi +if ! SCRIPT_DIR="$(cd "$(dirname "${_SELF}")" && pwd)"; then + _OMNICLAUDE_PASSTHROUGH=$(cat) + echo "$_OMNICLAUDE_PASSTHROUGH" + exit 0 +fi +if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" ]]; then + PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT}" +elif ! PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"; then + _OMNICLAUDE_PASSTHROUGH=$(cat) + echo "$_OMNICLAUDE_PASSTHROUGH" + exit 0 +fi unset _SELF HOOKS_DIR="${PLUGIN_ROOT}/hooks" HOOKS_LIB="${HOOKS_DIR}/lib" @@ -43,14 +57,24 @@ LOG_FILE="${ONEX_HOOK_LOG}" HOOK_BITS_PATH="${HOOKS_LIB}/hook_bits.sh" if [[ -f "$HOOK_BITS_PATH" ]]; then # shellcheck source=../lib/hook_bits.sh - source "$HOOK_BITS_PATH" + if ! source "$HOOK_BITS_PATH"; then + echo "$TOOL_INFO" + exit 0 + fi TASK_BOUNDARY_BIT="$(hook_bits_bit_for_name TASK_BOUNDARY_TESTS 2>/dev/null || true)" if [[ -n "$TASK_BOUNDARY_BIT" ]]; then if [[ -z "${ONEX_HOOKS_MASK:-}" ]]; then echo "$TOOL_INFO" exit 0 fi - TASK_BOUNDARY_MASK="$(hook_bits_parse_mask "$ONEX_HOOKS_MASK")" + if ! TASK_BOUNDARY_MASK="$(hook_bits_parse_mask "$ONEX_HOOKS_MASK" 2>/dev/null)"; then + echo "$TOOL_INFO" + exit 0 + fi + if [[ -z "$TASK_BOUNDARY_MASK" ]]; then + echo "$TOOL_INFO" + exit 0 + fi if ! hook_bits_is_enabled "$TASK_BOUNDARY_MASK" "$TASK_BOUNDARY_BIT"; then echo "$TOOL_INFO" exit 0 @@ -116,11 +140,19 @@ fi echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] Task-boundary tests triggered" >> "$LOG_FILE" +RUNNER="${HOOKS_LIB}/task_boundary_tests.py" +if [[ ! -r "$RUNNER" ]]; then + echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] ERROR: runner missing/unreadable, failing open" >> "$LOG_FILE" + echo "$TOOL_INFO" + exit 0 +fi + set +e RESULT=$(echo "$TOOL_INFO" | \ - env -u PYTHONPATH "$PYTHON_CMD" "${HOOKS_LIB}/task_boundary_tests.py" 2>>"$LOG_FILE") + env -u PYTHONPATH "$PYTHON_CMD" "$RUNNER" 2>>"$LOG_FILE") EXIT_CODE=$? set -e +unset RUNNER if [ $EXIT_CODE -eq 0 ]; then echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] Task-boundary tests PASSED" >> "$LOG_FILE" diff --git a/tests/hooks/test_task_boundary_tests.py b/tests/hooks/test_task_boundary_tests.py index 5a68602533..bc0d94cef3 100644 --- a/tests/hooks/test_task_boundary_tests.py +++ b/tests/hooks/test_task_boundary_tests.py @@ -437,3 +437,57 @@ def test_shell_hook_fails_open_when_common_script_missing( assert result.returncode == 0 assert json.loads(result.stdout) == payload + + def test_shell_hook_fails_open_when_hook_bits_source_fails( + self, tmp_path: Path + ) -> None: + payload = { + "tool_name": "Bash", + "tool_input": {"command": "git commit -m 'x'"}, + } + fake_plugin = tmp_path / "plugin" + (fake_plugin / "hooks" / "lib").mkdir(parents=True) + (fake_plugin / "hooks" / "lib" / "hook_bits.sh").write_text("return 1\n") + result = self._run_shell_hook( + { + "CLAUDE_PLUGIN_ROOT": str(fake_plugin), + "ONEX_HOOKS_MASK": "0x400000000000000", + "ONEX_STATE_DIR": str(tmp_path / "state"), + } + ) + + assert result.returncode == 0 + assert json.loads(result.stdout) == payload + + def test_shell_hook_fails_open_when_runner_missing(self, tmp_path: Path) -> None: + payload = { + "tool_name": "Bash", + "tool_input": {"command": "git commit -m 'x'"}, + } + fake_plugin = tmp_path / "plugin" + (fake_plugin / "hooks" / "lib").mkdir(parents=True) + (fake_plugin / "hooks" / "scripts").mkdir(parents=True) + (fake_plugin / "hooks" / "lib" / "hook_bits.sh").write_text( + "\n".join( + [ + "HOOK_BITS_DEFAULT_MASK=0x0", + "hook_bits_bit_for_name() { echo 0x400000000000000; }", + 'hook_bits_parse_mask() { echo "$1"; }', + "hook_bits_is_enabled() { return 0; }", + "", + ] + ) + ) + (fake_plugin / "hooks" / "scripts" / "common.sh").write_text( + "PYTHON_CMD=/usr/bin/python3\nexport PYTHON_CMD\n" + ) + result = self._run_shell_hook( + { + "CLAUDE_PLUGIN_ROOT": str(fake_plugin), + "ONEX_HOOKS_MASK": "0x400000000000000", + "ONEX_STATE_DIR": str(tmp_path / "state"), + } + ) + + assert result.returncode == 0 + assert json.loads(result.stdout) == payload