From e5f05eff6ce40d87cb3ad7ebee29354ccfc22d8f Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Mon, 21 Sep 2026 14:32:52 -0400 Subject: [PATCH] feat(catalog): cover Windows guest-mutating tools in the starter list The catalog is opt-in: an unlisted tool passes through unguarded. The shipped starter list was ssh/exec and ansible/run_playbook, both Linux shaped, so an agent changing a protected Windows guest over WinRM or PowerShell was never guarded at all. That does not fail loudly, it simply never inserts a checkpoint. Adds winrm/run_command, winrm/run_ps, powershell/invoke_command and smb/write_file. The smb entry is there because a file written into a share changes the guest without any shell being involved. Test asserts the example config covers both platforms and that every entry names a vm_arg, since without one the guard cannot resolve a VM. Still illustrative, not exhaustive: tool names vary per MCP server, so users add their own with zerto_add_mutating_tool. That reactive model is the real weakness here and is worth revisiting separately. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016yVfC5nvZowoLFnEGWhLGn --- README.md | 2 +- config.example.json | 24 ++++++++++++++++++++++++ tests/test_catalog.py | 19 +++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5f8fd58..d354ec9 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ If the loop works, these tools are the delta to add to official ZVM MCP (`ZVM.MC 1. `zerto_find_protection` — VM name, hostname, or vmIdentifier to exactly one VM and every VPG. Zero or two-plus VMs: stop. 2. `zerto_create_tagged_checkpoint` / `zerto_guard_before_mutate` — same tag on every protecting VPG, wait until listed. The name records which agent and what it is doing: `ai: | | vm= | change= | `. 3. `zerto_recover_file` — FLR after a human sets `confirmed=true`. Linux and Windows guest paths. Locally replicated VPGs only: FLR runs at the VPG's recovery site. Reports its own unmount; `zerto_list_flr_sessions` / `zerto_end_flr_session` find and reap a mount orphaned by a crashed recovery. -4. Mutating catalog — opt-in list of MCP tools that must be guarded. Unlisted tools pass through. Users add entries. +4. Mutating catalog — opt-in list of MCP tools that must be guarded. Unlisted tools pass through. Users add entries. The starter list covers Linux (`ssh`, `ansible`) and Windows (`winrm`, `powershell`, `smb`), and is illustrative, not exhaustive. Official ZVM MCP already has inventory and failover test. It does not insert tagged checkpoints or run FLR. diff --git a/config.example.json b/config.example.json index 1fe7dac..9f7d56c 100644 --- a/config.example.json +++ b/config.example.json @@ -17,6 +17,30 @@ "tool": "run_playbook", "vm_arg": "limit", "notes": "Playbook target host/group. Resolve to a single VM before guard." + }, + { + "server": "winrm", + "tool": "run_command", + "vm_arg": "host", + "notes": "Windows remote shell over WinRM. vm_arg is the hostname." + }, + { + "server": "winrm", + "tool": "run_ps", + "vm_arg": "host", + "notes": "PowerShell over WinRM. Same blast radius as run_command." + }, + { + "server": "powershell", + "tool": "invoke_command", + "vm_arg": "computer_name", + "notes": "Invoke-Command against a remote Windows guest." + }, + { + "server": "smb", + "tool": "write_file", + "vm_arg": "host", + "notes": "Writes a file into a Windows share. Changes the guest without a shell." } ] } diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 2675394..52c9b21 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -31,3 +31,22 @@ def test_entry_requires_fields(): raise AssertionError("expected ValueError") except ValueError: pass + + +def test_example_config_covers_windows_and_linux(): + """The starter catalog must not be Linux-only. + + The catalog is opt-in: an unlisted tool passes through unguarded. A + Windows-only shop taking the shipped defaults would therefore mutate + protected guests with no checkpoint at all. + """ + example = Path(__file__).resolve().parent.parent / "config.example.json" + data = json.loads(example.read_text(encoding="utf-8")) + cat = MutatingCatalog.from_config(data) + assert cat.get("ssh", "exec") is not None + assert cat.get("winrm", "run_command") is not None + assert cat.get("winrm", "run_ps") is not None + assert cat.get("powershell", "invoke_command") is not None + # every entry must name the arg holding the VM, or the guard cannot resolve one + for entry in cat.list(): + assert entry.vm_arg, f"{entry.server}/{entry.tool} has no vm_arg"