-
Notifications
You must be signed in to change notification settings - Fork 156
hol-guard-pretool #939
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
hol-guard-pretool #939
Changes from all commits
9fbfb75
676c96b
2ec5e76
335b0a9
675494d
5483dcd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import subprocess | ||
| import sys | ||
| from typing import Any | ||
|
|
||
| BLOCK_EXIT = 2 | ||
| TIMEOUT_SECONDS = 10.0 | ||
|
|
||
|
|
||
| def _command_context(payload: Any) -> tuple[str, str | None] | None: | ||
| if not isinstance(payload, dict): | ||
| return None | ||
| if payload.get("hook_event") != "PreToolUse": | ||
| return None | ||
| if payload.get("tool_name") != "Bash": | ||
| return None | ||
| tool_input = payload.get("tool_input") | ||
| if not isinstance(tool_input, dict): | ||
| return None | ||
| command = tool_input.get("command") | ||
| if not isinstance(command, str) or not command.strip(): | ||
| return None | ||
| cwd = tool_input.get("cwd") | ||
| if cwd is not None and (not isinstance(cwd, str) or not cwd.strip()): | ||
| return None | ||
| return command, cwd | ||
|
|
||
|
|
||
| def _guard_allows(command: str, cwd: str | None = None) -> tuple[bool, str]: | ||
| try: | ||
| result = subprocess.run( | ||
| ["hol-guard", "command", "test", command, "--json"], | ||
| capture_output=True, | ||
| text=True, | ||
| timeout=TIMEOUT_SECONDS, | ||
| check=False, | ||
| cwd=cwd, | ||
| ) | ||
| except FileNotFoundError: | ||
| return False, "guard_unavailable" | ||
| except subprocess.TimeoutExpired: | ||
| return False, "guard_timeout" | ||
|
Comment on lines
+41
to
+44
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1] Convert subprocess launch and decoding failures into blocking exits Only FileNotFoundError and TimeoutExpired are caught here. A hol-guard executable without execute permission raises PermissionError; invalid UTF-8 on either captured stream raises UnicodeDecodeError inside subprocess.run(text=True). Both escape main() and make this hook exit with code 1. The existing _execute_command_hook treats code 1 as a non-blocking error, so a Bash command that otherwise has permission can proceed without a successful guard check. I reproduced exit_code=1 and blocking_error=None through the real hook runner, including valid review JSON on stdout with invalid bytes on stderr. Handle launch OSError and decoding failures so these paths return BLOCK_EXIT, and add subprocess-level assertions that they actually deny the tool. |
||
| except OSError: | ||
| return False, "guard_error" | ||
| except UnicodeError: | ||
| return False, "guard_invalid_output" | ||
|
|
||
| if result.returncode != 0: | ||
| return False, "guard_error" | ||
|
|
||
| try: | ||
| payload = json.loads(result.stdout) | ||
| except (json.JSONDecodeError, TypeError): | ||
| return False, "guard_invalid_output" | ||
|
|
||
| if not isinstance(payload, dict): | ||
| return False, "guard_invalid_output" | ||
| classification = payload.get("classification") | ||
| if not isinstance(classification, dict): | ||
| return False, "guard_invalid_output" | ||
| if classification.get("explicitly_benign") is True and payload.get("minimum_action") == "allow": | ||
| return True, "guard_allow" | ||
| return False, "guard_block" | ||
|
|
||
|
|
||
| def evaluate(payload: Any) -> tuple[bool, str]: | ||
| context = _command_context(payload) | ||
| if context is None: | ||
| return False, "guard_invalid_input" | ||
| command, cwd = context | ||
| return _guard_allows(command, cwd) | ||
|
|
||
|
|
||
| def main() -> int: | ||
| try: | ||
| payload = json.load(sys.stdin) | ||
| except (json.JSONDecodeError, TypeError): | ||
| print("guard_invalid_input", file=sys.stderr) | ||
| return BLOCK_EXIT | ||
|
|
||
| allowed, reason = evaluate(payload) | ||
| if allowed: | ||
| return 0 | ||
| print(reason, file=sys.stderr) | ||
| return BLOCK_EXIT | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| raise SystemExit(main()) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,222 @@ | ||
| import json | ||
| import os | ||
| import subprocess | ||
| import sys | ||
| from pathlib import Path | ||
| from types import SimpleNamespace | ||
|
|
||
| import pytest | ||
|
|
||
| from src.hooks import hol_guard_pretool as guard | ||
| from src.services.tool_execution.tool_hooks import _prepare_pre_tool_hook_input | ||
|
|
||
|
|
||
| def _input( | ||
| command: object = "git status", | ||
| *, | ||
| cwd: str | None = None, | ||
| ) -> dict[str, object]: | ||
| tool_input: dict[str, object] = {"command": command} | ||
| if cwd is not None: | ||
| tool_input["cwd"] = cwd | ||
| return { | ||
| "hook_event": "PreToolUse", | ||
| "tool_name": "Bash", | ||
| "tool_input": tool_input, | ||
| } | ||
|
|
||
|
|
||
| def _result(payload: object, returncode: int = 0) -> SimpleNamespace: | ||
| return SimpleNamespace(returncode=returncode, stdout=json.dumps(payload), stderr="") | ||
|
|
||
|
|
||
| def test_explicit_benign_allow(monkeypatch): | ||
| monkeypatch.setattr( | ||
| guard.subprocess, | ||
| "run", | ||
| lambda *args, **kwargs: _result( | ||
| {"classification": {"explicitly_benign": True}, "minimum_action": "allow"} | ||
| ), | ||
| ) | ||
| assert guard.evaluate(_input()) == (True, "guard_allow") | ||
|
|
||
|
|
||
| def test_implicit_allow_blocks(monkeypatch): | ||
| monkeypatch.setattr( | ||
| guard.subprocess, | ||
| "run", | ||
| lambda *args, **kwargs: _result( | ||
| {"classification": {"explicitly_benign": False}, "minimum_action": "allow"} | ||
| ), | ||
| ) | ||
| assert guard.evaluate(_input()) == (False, "guard_block") | ||
|
|
||
|
|
||
| def test_review_blocks(monkeypatch): | ||
| monkeypatch.setattr( | ||
| guard.subprocess, | ||
| "run", | ||
| lambda *args, **kwargs: _result( | ||
| {"classification": {"explicitly_benign": False}, "minimum_action": "review"} | ||
| ), | ||
| ) | ||
| assert guard.evaluate(_input()) == (False, "guard_block") | ||
|
|
||
|
|
||
| def test_malformed_output_blocks(monkeypatch): | ||
| monkeypatch.setattr( | ||
| guard.subprocess, | ||
| "run", | ||
| lambda *args, **kwargs: SimpleNamespace(returncode=0, stdout="{", stderr=""), | ||
| ) | ||
| assert guard.evaluate(_input()) == (False, "guard_invalid_output") | ||
|
|
||
|
|
||
| def test_nonzero_guard_exit_blocks(monkeypatch): | ||
| monkeypatch.setattr( | ||
| guard.subprocess, | ||
| "run", | ||
| lambda *args, **kwargs: SimpleNamespace(returncode=1, stdout="", stderr=""), | ||
| ) | ||
| assert guard.evaluate(_input()) == (False, "guard_error") | ||
|
|
||
|
|
||
| def test_timeout_blocks(monkeypatch): | ||
| def raise_timeout(*args, **kwargs): | ||
| raise subprocess.TimeoutExpired(cmd="hol-guard", timeout=guard.TIMEOUT_SECONDS) | ||
|
|
||
| monkeypatch.setattr(guard.subprocess, "run", raise_timeout) | ||
| assert guard.evaluate(_input()) == (False, "guard_timeout") | ||
|
|
||
|
|
||
| def test_missing_guard_blocks(monkeypatch): | ||
| def raise_missing(*args, **kwargs): | ||
| raise FileNotFoundError | ||
|
|
||
| monkeypatch.setattr(guard.subprocess, "run", raise_missing) | ||
| assert guard.evaluate(_input()) == (False, "guard_unavailable") | ||
|
|
||
|
|
||
| def test_permission_error_blocks(monkeypatch): | ||
| def raise_permission(*args, **kwargs): | ||
| raise PermissionError | ||
|
|
||
| monkeypatch.setattr(guard.subprocess, "run", raise_permission) | ||
| assert guard.evaluate(_input()) == (False, "guard_error") | ||
|
|
||
|
|
||
| def test_decode_error_blocks(monkeypatch): | ||
| def raise_decode(*args, **kwargs): | ||
| raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte") | ||
|
|
||
| monkeypatch.setattr(guard.subprocess, "run", raise_decode) | ||
| assert guard.evaluate(_input()) == (False, "guard_invalid_output") | ||
|
|
||
|
|
||
| def test_missing_command_blocks(): | ||
| assert guard.evaluate(_input(None)) == (False, "guard_invalid_input") | ||
|
|
||
|
|
||
| def test_command_is_passed_as_one_argv_item(monkeypatch): | ||
| seen = {} | ||
|
|
||
| def fake_run(argv, **kwargs): | ||
| seen["argv"] = argv | ||
| return _result( | ||
| {"classification": {"explicitly_benign": True}, "minimum_action": "allow"} | ||
| ) | ||
|
|
||
| monkeypatch.setattr(guard.subprocess, "run", fake_run) | ||
| command = "echo $(touch /tmp/guard-argv-test)" | ||
| assert guard.evaluate(_input(command)) == (True, "guard_allow") | ||
| assert seen["argv"] == ["hol-guard", "command", "test", command, "--json"] | ||
|
|
||
|
|
||
| def test_guard_runs_in_explicit_bash_cwd(monkeypatch, tmp_path): | ||
| seen = {} | ||
|
|
||
| def fake_run(argv, **kwargs): | ||
| seen["cwd"] = kwargs.get("cwd") | ||
| return _result( | ||
| {"classification": {"explicitly_benign": True}, "minimum_action": "allow"} | ||
| ) | ||
|
|
||
| monkeypatch.setattr(guard.subprocess, "run", fake_run) | ||
| assert guard.evaluate(_input(cwd=str(tmp_path))) == (True, "guard_allow") | ||
| assert seen["cwd"] == str(tmp_path) | ||
|
|
||
|
|
||
| def test_persisted_bash_cwd_is_added_to_hook_input(tmp_path): | ||
| workspace = tmp_path / "workspace" | ||
| prior_cwd = workspace / "nested" | ||
| workspace.mkdir() | ||
| prior_cwd.mkdir() | ||
| context = SimpleNamespace(cwd=prior_cwd, workspace_root=workspace) | ||
| tool = SimpleNamespace(name="Bash") | ||
|
|
||
| prepared = _prepare_pre_tool_hook_input(context, tool, {"command": "git status"}) | ||
|
|
||
| assert prepared == {"command": "git status", "cwd": str(prior_cwd)} | ||
|
|
||
|
|
||
| def test_explicit_bash_cwd_wins_over_persisted_context(tmp_path): | ||
| workspace = tmp_path / "workspace" | ||
| persisted = workspace / "persisted" | ||
| explicit = workspace / "explicit" | ||
| workspace.mkdir() | ||
| persisted.mkdir() | ||
| explicit.mkdir() | ||
| context = SimpleNamespace(cwd=persisted, workspace_root=workspace) | ||
| tool = SimpleNamespace(name="Bash") | ||
| original = {"command": "git status", "cwd": str(explicit)} | ||
|
|
||
| prepared = _prepare_pre_tool_hook_input(context, tool, original) | ||
|
|
||
| assert prepared is original | ||
| assert prepared["cwd"] == str(explicit) | ||
|
|
||
|
|
||
| @pytest.mark.skipif(os.name == "nt", reason="POSIX executable permission semantics") | ||
| def test_non_executable_guard_exits_with_blocking_code(tmp_path): | ||
| fake_guard = tmp_path / "hol-guard" | ||
| fake_guard.write_text("#!/bin/sh\nexit 0\n") | ||
| fake_guard.chmod(0o644) | ||
| env = os.environ.copy() | ||
| env["PATH"] = f"{tmp_path}{os.pathsep}{env.get('PATH', '')}" | ||
|
|
||
| result = subprocess.run( | ||
| [sys.executable, str(Path(guard.__file__))], | ||
| input=json.dumps(_input()), | ||
| capture_output=True, | ||
| text=True, | ||
| env=env, | ||
| check=False, | ||
| ) | ||
|
|
||
| assert result.returncode == guard.BLOCK_EXIT | ||
| assert "guard_error" in result.stderr | ||
|
|
||
|
|
||
| @pytest.mark.skipif(os.name == "nt", reason="POSIX executable script fixture") | ||
| def test_invalid_utf8_guard_output_exits_with_blocking_code(tmp_path): | ||
| fake_guard = tmp_path / "hol-guard" | ||
| fake_guard.write_text( | ||
| "#!/usr/bin/env python3\n" | ||
| "import os\n" | ||
| "os.write(1, b'\\xff')\n" | ||
| ) | ||
| fake_guard.chmod(0o755) | ||
| env = os.environ.copy() | ||
| env["PATH"] = f"{tmp_path}{os.pathsep}{env.get('PATH', '')}" | ||
|
|
||
| result = subprocess.run( | ||
| [sys.executable, str(Path(guard.__file__))], | ||
| input=json.dumps(_input()), | ||
| capture_output=True, | ||
| text=True, | ||
| env=env, | ||
| check=False, | ||
| ) | ||
|
|
||
| assert result.returncode == guard.BLOCK_EXIT | ||
| assert "guard_invalid_output" in result.stderr |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P2] Inspect the command in its effective Bash working directory
This subprocess inherits the ClawCodex process directory, but _bash_call executes in tool_input["cwd"] when supplied, otherwise context.cwd or context.workspace_root. The hook discards the explicit cwd, and execute_pre_tool_hooks does not send the persisted context.cwd. I confirmed that both cases invoke the guard in the launcher directory. HOL Guard command test passes Path.cwd() into inspect_command, whose checks depend on local paths, executables, and repository state; an allow result can therefore describe different files or configuration from those Bash will actually use. Carry the effective Bash cwd into the hook and set it on the guard subprocess, with coverage for explicit cwd and a prior directory change.