feat(hooks): enforce the guard from a PreToolUse hook #9
@@ -0,0 +1,72 @@
|
||||
# PreToolUse guard hook
|
||||
|
||||
Makes the rewind guard enforceable instead of advisory.
|
||||
|
||||
The MCP server cannot see another server's tool calls, so `zerto_check_tool` and
|
||||
`zerto_guard_before_mutate` only work if the model chooses to call them. A model
|
||||
that skips the step is not stopped by anything. A `PreToolUse` hook runs in the
|
||||
host, where the tool call genuinely pauses, so a failed checkpoint stops the
|
||||
change.
|
||||
|
||||
## Decisions
|
||||
|
||||
| situation | decision | effect |
|
||||
|---|---|---|
|
||||
| tool in `read_only_tools` | none | runs, no checkpoint |
|
||||
| mutating, checkpoint confirmed | none, plus `additionalContext` | runs, and the model is told which checkpoint to recover from |
|
||||
| mutating, checkpoint failed | `deny` | **the call never happens** |
|
||||
| mutating, VM unknown to Zerto | `prompt` | human decides; nothing to rewind to |
|
||||
| mutating, no VM in the arguments | `prompt` | catalog's `vm_arg` did not match |
|
||||
| unlisted tool | `prompt` | nobody said it was read-only |
|
||||
|
||||
`ZERTO_HOOK_UNKNOWN` switches the unlisted case to `allow` or `deny`.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
cp hooks/settings.example.json /tmp/x # then merge the hooks block into
|
||||
# .claude/settings.json
|
||||
export ZERTO_REWIND_CONFIG=/abs/path/config.json
|
||||
```
|
||||
|
||||
Both paths in the command must be absolute, and the interpreter must be the
|
||||
venv that has this package installed.
|
||||
|
||||
## Timeouts, which matter here
|
||||
|
||||
The hook is synchronous: the host waits. That is the point, because the
|
||||
checkpoint has to exist before the change does.
|
||||
|
||||
Budget for the slow path, not the fast one. A successful tag took about 4s
|
||||
against a healthy VPG, but a **refusal took 63s**: `wait_for_tag` waits 45s for
|
||||
a checkpoint that is never going to appear, which is exactly what happens on a
|
||||
VPG whose protected site is AWS or Azure, where tagged checkpoints are not
|
||||
supported.
|
||||
|
||||
So:
|
||||
|
||||
- `timeout` in settings.json: 180 (seconds)
|
||||
- `ZERTO_HOOK_GUARD_TIMEOUT`: 150 (seconds), kept under it
|
||||
|
||||
If the host's timeout fires first it cancels the hook and **discards its
|
||||
output**, and the tool call carries on through the normal permission flow. A
|
||||
timeout is therefore a silent failure of the guard, which is why the hook's own
|
||||
budget is the smaller of the two: it would rather deny than be cancelled.
|
||||
|
||||
## Verified behaviour
|
||||
|
||||
Against a live ZVM 10.9.10, all six rows of the table above. Two worth naming:
|
||||
|
||||
- `jp-ubuntu` (healthy, local VPG) tagged checkpoint 7180 and allowed the call,
|
||||
passing the tag back through `additionalContext`.
|
||||
- `win2019-1` (VPG `CMH-AWS-1`, protected site AWS) was **denied**: Zerto cannot
|
||||
insert a tagged checkpoint there, so the change would not have been
|
||||
recoverable.
|
||||
|
||||
Both decisions held with `permission_mode: bypassPermissions`. A hook still
|
||||
blocks when the user has turned permissions off, which is when an agent is most
|
||||
likely to be running unattended.
|
||||
|
||||
## Log
|
||||
|
||||
`~/.zerto-guard-hook.log`, or `ZERTO_HOOK_LOG`. One line per decision.
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"_comment": "Copy the hooks block into .claude/settings.json (project) or ~/.claude/settings.json (all projects). Use absolute paths.",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "mcp__.*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "/home/you/zerto-ai-rewind/.venv/bin/python /home/you/zerto-ai-rewind/hooks/zerto_guard_hook.py",
|
||||
"timeout": 180
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Executable
+247
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Claude Code PreToolUse hook: capture before execute, enforced by the host.
|
||||
|
||||
The MCP server cannot see another server's tool calls, so the guard it exposes
|
||||
is advice the model may skip. This hook sits in the host, where the call really
|
||||
does pause, so a failed checkpoint stops the change instead of merely
|
||||
suggesting it should.
|
||||
|
||||
Decisions:
|
||||
|
||||
read-only tool allow, no checkpoint
|
||||
mutating, guard ok allow, and tell the model which checkpoint to use
|
||||
mutating, guard failed DENY. No checkpoint means no rewind, so no change.
|
||||
mutating, VM unprotected prompt. Zerto cannot rewind it; a human decides.
|
||||
unknown tool prompt. Nobody said it was read-only.
|
||||
|
||||
Exit 0 always, with the decision in stdout JSON. Exit 2 would block
|
||||
unconditionally and ignore the JSON, which loses the reason text.
|
||||
|
||||
Install (project .claude/settings.json). Give it room: the guard inserts a
|
||||
tagged checkpoint and waits for the Zerto task to reach Completed.
|
||||
|
||||
{"hooks": {"PreToolUse": [{
|
||||
"matcher": "mcp__.*",
|
||||
"hooks": [{"type": "command",
|
||||
"command": "/path/to/.venv/bin/python /path/to/hooks/zerto_guard_hook.py",
|
||||
"timeout": 180}]}]}}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
LOG = os.environ.get("ZERTO_HOOK_LOG", os.path.expanduser("~/.zerto-guard-hook.log"))
|
||||
# Seconds the hook will wait for the checkpoint. Must stay under the hook
|
||||
# timeout configured in settings.json, or the host cancels us and the tool
|
||||
# call proceeds unguarded through the normal permission flow.
|
||||
GUARD_TIMEOUT_S = float(os.environ.get("ZERTO_HOOK_GUARD_TIMEOUT", "150"))
|
||||
UNKNOWN_DECISION = os.environ.get("ZERTO_HOOK_UNKNOWN", "prompt") # prompt | allow | deny
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
try:
|
||||
with open(LOG, "a") as fh:
|
||||
fh.write(f"{time.strftime('%Y-%m-%dT%H:%M:%S')} {msg}\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def emit(decision: str | None, reason: str = "", context: str = "") -> None:
|
||||
"""Write the PreToolUse decision and leave. Never raises."""
|
||||
if decision is None and not context:
|
||||
sys.exit(0) # no opinion; normal permission flow applies
|
||||
out: dict = {"hookEventName": "PreToolUse"}
|
||||
if decision:
|
||||
out["permissionDecision"] = decision
|
||||
out["permissionDecisionReason"] = reason
|
||||
if context:
|
||||
out["additionalContext"] = context
|
||||
print(json.dumps({"hookSpecificOutput": out}))
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def split_mcp_name(tool_name: str) -> tuple[str, str] | None:
|
||||
"""mcp__<server>__<tool> -> (server, tool). Server names may contain _."""
|
||||
m = re.match(r"^mcp__(.+?)__(.+)$", tool_name or "")
|
||||
return (m.group(1), m.group(2)) if m else None
|
||||
|
||||
|
||||
def resolve_vm(tool_input: dict, vm_arg: str) -> str:
|
||||
"""Pull the VM identifier out of the pending call's arguments."""
|
||||
if vm_arg and tool_input.get(vm_arg):
|
||||
return str(tool_input[vm_arg])
|
||||
# vm_arg is the catalog's answer, but fall back to the usual suspects so a
|
||||
# slightly-wrong catalog entry degrades to a prompt rather than a crash.
|
||||
for key in (
|
||||
"host",
|
||||
"hostname",
|
||||
"computer_name",
|
||||
"computerName",
|
||||
"vm",
|
||||
"vm_name",
|
||||
"target",
|
||||
"limit",
|
||||
"address",
|
||||
"server",
|
||||
):
|
||||
if tool_input.get(key):
|
||||
return str(tool_input[key])
|
||||
return ""
|
||||
|
||||
|
||||
async def run_guard(query: str, change_id: str, action: str) -> dict:
|
||||
from zerto_rewind_mcp.checkpoints import make_tag, tag_vpgs
|
||||
from zerto_rewind_mcp.client import ZertoClient, ZertoError
|
||||
from zerto_rewind_mcp.config import load_config
|
||||
from zerto_rewind_mcp.protection import find_from_rows
|
||||
|
||||
settings = load_config()
|
||||
client = ZertoClient(
|
||||
base_url=str(settings["zerto_url"]),
|
||||
username=str(settings["username"]),
|
||||
password=str(settings.get("password") or ""),
|
||||
client_id=str(settings.get("client_id") or "zerto-client"),
|
||||
verify_tls=bool(settings.get("verify_tls")),
|
||||
)
|
||||
try:
|
||||
# Two different failures hide here. "Zerto has never heard of this VM"
|
||||
# is not the same as "Zerto refused to tag it", and they get different
|
||||
# answers, so a lookup miss must not surface as a refusal. Looking up a
|
||||
# plain hostname by vmIdentifier returns HTTP 400, which is a miss.
|
||||
rows = []
|
||||
for kwargs in ({"vm_name": query}, {"vm_identifier": query}):
|
||||
try:
|
||||
rows = await client.get_vms(**kwargs)
|
||||
except ZertoError:
|
||||
rows = []
|
||||
if rows:
|
||||
break
|
||||
if not rows:
|
||||
return {
|
||||
"ok": False,
|
||||
"protected": False,
|
||||
"message": f"Zerto has no VM matching {query!r}.",
|
||||
}
|
||||
result = find_from_rows(query, rows)
|
||||
if result.outcome != "ok" or not result.taggable_vpgs:
|
||||
return {"ok": False, "protected": False, "message": result.message}
|
||||
tag = make_tag(
|
||||
"claude-code",
|
||||
change_id,
|
||||
action=action,
|
||||
vm_name=result.vm.vm_name if result.vm else None,
|
||||
)
|
||||
out = await tag_vpgs(client, result, tag)
|
||||
out["protected"] = True
|
||||
return out
|
||||
except ZertoError as exc:
|
||||
# Reached Zerto and it refused, or never reached it at all. Either way
|
||||
# there is no checkpoint, so this is not a pass.
|
||||
return {"ok": False, "protected": True, "message": str(exc)}
|
||||
finally:
|
||||
try:
|
||||
await client.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
payload = json.loads(sys.stdin.read() or "{}")
|
||||
except Exception as exc:
|
||||
log(f"unparseable stdin: {exc}")
|
||||
emit(None)
|
||||
|
||||
tool_name = payload.get("tool_name") or ""
|
||||
tool_input = payload.get("tool_input") or {}
|
||||
parts = split_mcp_name(tool_name)
|
||||
if not parts:
|
||||
emit(None) # not an MCP tool; this hook has nothing to say
|
||||
server, tool = parts
|
||||
|
||||
try:
|
||||
from zerto_rewind_mcp.catalog import MUTATING, READ_ONLY
|
||||
from zerto_rewind_mcp.config import load_catalog, load_config
|
||||
|
||||
catalog = load_catalog(load_config())
|
||||
verdict = catalog.classify(server, tool)
|
||||
entry = catalog.get(server, tool)
|
||||
except Exception as exc:
|
||||
log(f"catalog unavailable ({exc}); staying out of the way")
|
||||
emit(None)
|
||||
|
||||
if verdict == READ_ONLY:
|
||||
log(f"{tool_name}: read-only, allowed")
|
||||
emit(None)
|
||||
|
||||
if verdict != MUTATING:
|
||||
log(f"{tool_name}: unknown -> {UNKNOWN_DECISION}")
|
||||
emit(
|
||||
UNKNOWN_DECISION,
|
||||
f"{server}/{tool} is not a known read-only tool and is not in the "
|
||||
"Zerto mutating catalog, so no checkpoint was taken. If it changes a "
|
||||
"protected VM, that change cannot be rewound. Allow it?",
|
||||
)
|
||||
|
||||
vm = resolve_vm(tool_input, entry.vm_arg if entry else "")
|
||||
if not vm:
|
||||
log(f"{tool_name}: mutating but no VM in args {sorted(tool_input)}")
|
||||
emit(
|
||||
"prompt",
|
||||
f"{server}/{tool} is a guest-mutating tool, but no VM could be read "
|
||||
f"from its arguments (expected {entry.vm_arg if entry else '?'}). "
|
||||
"No checkpoint was taken. Allow it?",
|
||||
)
|
||||
|
||||
change_id = f"{payload.get('session_id', 'session')[:8]}-{payload.get('tool_use_id', '')[-8:]}"
|
||||
action = f"{server}/{tool} on {vm}"
|
||||
try:
|
||||
guard = asyncio.run(asyncio.wait_for(run_guard(vm, change_id, action), GUARD_TIMEOUT_S))
|
||||
except TimeoutError:
|
||||
log(f"{tool_name}: guard timed out after {GUARD_TIMEOUT_S}s -> deny")
|
||||
emit(
|
||||
"deny",
|
||||
f"Zerto did not confirm a tagged checkpoint for {vm} within "
|
||||
f"{GUARD_TIMEOUT_S:.0f}s, so the change cannot be rewound. Refusing.",
|
||||
)
|
||||
except Exception as exc:
|
||||
log(f"{tool_name}: guard raised {type(exc).__name__}: {exc} -> deny")
|
||||
emit("deny", f"The Zerto guard failed for {vm}: {exc}. No checkpoint, so refusing.")
|
||||
|
||||
if guard.get("ok"):
|
||||
tagged = (guard.get("tagged") or [{}])[0]
|
||||
log(f"{tool_name}: tagged cp {tagged.get('checkpoint_id')} on {vm}, allowed")
|
||||
emit(
|
||||
None,
|
||||
context=(
|
||||
f"Zerto tagged checkpoint {tagged.get('checkpoint_id')} on VPG "
|
||||
f"{tagged.get('vpg_name')} for {vm} before this call, tag "
|
||||
f"{guard.get('tag')!r}. If this change breaks the guest, recover from "
|
||||
"that checkpoint with zerto_recover_file."
|
||||
),
|
||||
)
|
||||
|
||||
if not guard.get("protected", True):
|
||||
log(f"{tool_name}: {vm} not protected -> prompt")
|
||||
emit(
|
||||
"prompt",
|
||||
f"{vm} is not protected by Zerto, so this change cannot be rewound. "
|
||||
f"({guard.get('message', '')}) Allow it anyway?",
|
||||
)
|
||||
|
||||
log(f"{tool_name}: guard failed on {vm} -> deny :: {guard.get('message')}")
|
||||
emit(
|
||||
"deny",
|
||||
f"Zerto could not tag a checkpoint for {vm}, so this change would not be "
|
||||
f"recoverable: {guard.get('message')}",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -41,3 +41,8 @@ ignore = ["ASYNC240"]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["src"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
# A PreToolUse hook runs inside the host's tool call. If it raises, it breaks
|
||||
# the call it was meant to protect, so it swallows everything and logs instead.
|
||||
"hooks/*.py" = ["BLE001", "S110"]
|
||||
|
||||
@@ -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