feat(hooks): enforce the guard from a PreToolUse hook
The catalog and zerto_check_tool are advice. An MCP server cannot see or
block another server's tool calls, so a model that skips the guard is not
stopped by anything, and the change lands with no checkpoint behind it.
A PreToolUse hook runs in the host, where the tool call actually pauses.
That turns capture-before-execute from a convention into something the
host enforces.
read-only no decision, runs
mutating, checkpoint confirmed no decision, plus additionalContext
telling the model which checkpoint to
recover from
mutating, checkpoint failed DENY, the call never happens
mutating, VM unknown to Zerto prompt, nothing to rewind to
mutating, no VM in the args prompt, the catalog's vm_arg missed
unlisted tool prompt
Decisions go out as JSON rather than exit 2. Exit 2 blocks
unconditionally but discards the JSON, and with it the reason, so the
model would be refused without being told why.
A lookup miss is not a refusal. Asking Zerto for a plain hostname by
vmIdentifier returns HTTP 400, and an early version reported that as
"could not tag a checkpoint", which denied changes to machines Zerto had
simply never heard of. Those are now separated: unknown VM prompts, a VM
Zerto knows but will not tag denies.
Timeouts are budgeted for the slow path. A successful tag took about 4s,
but a refusal took 63s, because wait_for_tag spends 45s waiting for a
checkpoint that will never arrive on an AWS or Azure protected VPG. If
the host's timeout fires first it cancels the hook and discards its
output, and the call proceeds unguarded, so the hook's own budget (150s)
stays under the configured one (180s): better to deny than be cancelled.
Verified against a live ZVM 10.9.10. jp-ubuntu tagged checkpoint 7180 and
was allowed; win2019-1, whose VPG is protected at an AWS site where
tagged checkpoints are unsupported, was denied. Both held under
permission_mode bypassPermissions, which is when an agent is most likely
running unattended.
Broad excepts in hooks/ are deliberate and scoped in pyproject: a hook
that raises breaks the tool call it exists to protect.
pytest 60 passed (12 new).
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_016yVfC5nvZowoLFnEGWhLGn
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
"""The PreToolUse hook's pure helpers. The decision paths are exercised live."""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"zerto_guard_hook", Path(__file__).resolve().parent.parent / "hooks" / "zerto_guard_hook.py"
|
||||
)
|
||||
hook = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(hook)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("tool_name", "expected"),
|
||||
[
|
||||
("mcp__ssh__exec", ("ssh", "exec")),
|
||||
("mcp__winrm__run_ps", ("winrm", "run_ps")),
|
||||
# server names may themselves contain underscores; split on the FIRST __
|
||||
("mcp__plugin_my_thing__write_file", ("plugin_my_thing", "write_file")),
|
||||
("mcp__ob1__OB1__search_thoughts", ("ob1", "OB1__search_thoughts")),
|
||||
("Bash", None),
|
||||
("", None),
|
||||
],
|
||||
)
|
||||
def test_split_mcp_name(tool_name, expected):
|
||||
assert hook.split_mcp_name(tool_name) == expected
|
||||
|
||||
|
||||
def test_resolve_vm_prefers_the_catalog_arg():
|
||||
assert hook.resolve_vm({"host": "a", "computer_name": "b"}, "computer_name") == "b"
|
||||
|
||||
|
||||
def test_resolve_vm_falls_back_when_catalog_arg_is_wrong():
|
||||
# a slightly wrong catalog entry should degrade to a prompt, not a crash
|
||||
assert hook.resolve_vm({"computer_name": "web01"}, "host") == "web01"
|
||||
|
||||
|
||||
def test_resolve_vm_returns_empty_when_no_vm_present():
|
||||
assert hook.resolve_vm({"command": "ls"}, "host") == ""
|
||||
|
||||
|
||||
def test_emit_with_no_decision_and_no_context_exits_silently(capsys):
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
hook.emit(None)
|
||||
assert exc.value.code == 0
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
|
||||
def test_emit_deny_shape(capsys):
|
||||
import json
|
||||
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
hook.emit("deny", "no checkpoint")
|
||||
assert exc.value.code == 0 # exit 2 would discard the JSON and lose the reason
|
||||
out = json.loads(capsys.readouterr().out)["hookSpecificOutput"]
|
||||
assert out["hookEventName"] == "PreToolUse"
|
||||
assert out["permissionDecision"] == "deny"
|
||||
assert out["permissionDecisionReason"] == "no checkpoint"
|
||||
|
||||
|
||||
def test_emit_context_without_decision(capsys):
|
||||
import json
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
hook.emit(None, context="checkpoint 7180")
|
||||
out = json.loads(capsys.readouterr().out)["hookSpecificOutput"]
|
||||
assert "permissionDecision" not in out
|
||||
assert out["additionalContext"] == "checkpoint 7180"
|
||||
Reference in New Issue
Block a user