Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
67b86db
open ssh into the VM for a remote MCP client
glslang Aug 21, 2026
7d85c17
never lose the record of what setup changed
glslang Aug 21, 2026
5bdb839
make the ACL, the port match and the group check mean what they say
glslang Aug 21, 2026
00a8972
stop guessing, and put the ACL back too
glslang Aug 21, 2026
8c7bc0d
print the address, not its first character
glslang Aug 21, 2026
c431dc5
escape the printed command for the client shell
glslang Aug 21, 2026
6b15c6e
leave the machine's own files and record alone
glslang Aug 21, 2026
d22c0ac
retire a component from the record once it is back
glslang Aug 21, 2026
4adc98a
keep the bytes, the record and the identity honest
glslang Aug 21, 2026
16fed00
do not report a boundary the firewall did not accept
glslang Aug 21, 2026
e8fc54e
check what a blanket rule reaches instead of assuming
glslang Aug 21, 2026
e45d56d
document what the last commit changed
glslang Aug 21, 2026
8d49dd8
do not fail a run over a rule that widens nothing
glslang Aug 21, 2026
1fb1a8b
narrow rules, never widen them
glslang Aug 21, 2026
e4cb523
own only the rules this run actually changed
glslang Aug 21, 2026
06c5187
give a changed rule back to whoever changed it
glslang Aug 21, 2026
9ead5d5
keep the rollback record true after a rerun or a retry
claude Aug 21, 2026
66cb048
replace five rollback implementations with one ledger
claude Aug 21, 2026
752f7ac
merge only the fields a rerun actually wrote
claude Aug 21, 2026
63a3ebe
close access before handing the firewall back, and own fields not com…
claude Aug 21, 2026
ca86b30
enforce the boundary before listening, and treat a rule's scope as on…
claude Aug 21, 2026
0bb33d2
put the record on disk before the change, and never publish a half-se…
claude Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,102 @@ After the debuggee VM shuts down, Secure Boot can be restored on the Hyper-V hos
.\scripts\Setup-KernelDebugger.ps1 -VMName AgentDevSandbox -EnableVmSecureBoot -SkipFirewall
```

### Remote MCP over SSH

An MCP server that has to run on Windows -- a debugger, above all -- does not have to run where the
harness driving it runs. `Setup-RemoteMcp.ps1` opens ssh into the VM so a client on another machine
can start a **stdio** MCP server here and talk to it over the ssh channel, which is already a pair of
pipes. Neither side needs transport code, and a client disconnect still releases whatever the server
was holding: closing the channel closes the remote stdin.

Inside the VM, run as Administrator with the client's public key:

```powershell
powershell -ExecutionPolicy RemoteSigned -File C:\Setup-RemoteMcp.ps1 `
-PublicKey "ssh-ed25519 AAAA... you@client" `
-ClientAddress <client-ip> `
-ServerCommand C:\tools\windbg-mcp.exe
```

`-PublicKeyPath <file>` takes the key from a `.pub` file instead. `-ServerCommand` is only used to
print the client-side registration, so it can be left off and filled in later. The run ends with the
`~/.ssh/config` block, the handshake probe, and the `claude mcp add` line to paste on the client.

Four things have to be true for this to work, and three of them fail silently, which is why they are
scripted rather than described:

- **The firewall.** The rule the OpenSSH capability installs covers `Domain` and `Private` only, and
a hypervisor's guest network usually comes up `Public` -- so sshd is running and listening and
looks correct from inside the guest while the client's SYNs are dropped. That presents as ssh
*hanging* after `Connecting to <address> port 22`, where nothing listening at all would have said
`Connection refused` at once. The script adds its own rule on `-FirewallProfile` (default `Any`)
scoped to `-ClientAddress`, and narrows every other inbound rule on that port to the same
allowlist -- firewall allow rules are additive, so a narrow rule cannot claw back a wider one.
A rule counts as being on that port whether its filter names the port exactly or a range spanning
it. One whose filter is `LocalPort Any` is *reported* rather than rescoped: such a rule is usually
scoped to a program rather than to a port, making it an app's inbound rule rather than an ssh
hole, and narrowing every one of them to the ssh client would take the VM's other inbound traffic
down with it. Reclassifying the network to `Private` instead would work in one line, at the cost of activating
every other `Private` inbound rule: file and printer sharing, network discovery.
- **The default shell.** OpenSSH runs an exec request through it. `cmd /c` hands the child the
inherited pipe handles and stays out of the way; PowerShell captures the output through its own
pipeline and applies its output encoding, which can rewrite the line endings underneath
line-delimited JSON-RPC, and serializes its progress and error streams as CLIXML on stderr. Either
one corrupts the transport. The script points `HKLM:\SOFTWARE\OpenSSH\DefaultShell` at `cmd.exe`,
and corrects `DefaultShellCommandOption` beside it when it is set to something else -- a machine
configured for a Unix-style or PowerShell shell carries `-c` or `-Command` there, and sshd would
then run `cmd.exe -c ...`, which cmd does not understand. `-SkipDefaultShell` leaves both alone
for a VM where something else owns that setting.
- **Which authorized-keys file.** For a member of the Administrators group -- which the VM user is if
you are kernel debugging -- sshd ignores `~/.ssh/authorized_keys` and reads
`C:\ProgramData\ssh\administrators_authorized_keys`. A key in the other file is not an error
anywhere; it is just a login that never authenticates. Membership is resolved through nested
groups, since sshd reads it off the logon token. Where it cannot be resolved -- a domain group in
the chain, which no local lookup can expand -- the run refuses to guess and stops before changing
anything, naming the group it could not read; `-AuthorizedKeysFile Administrators` or
`-AuthorizedKeysFile PerUser` settles it. The summary always names the file it used.
- **Its ACL.** sshd refuses an authorized-keys file any other principal can write, and logs the
refusal nowhere you would think to look. The script rebuilds the file's DACL to grant only
Administrators and SYSTEM, by well-known SID rather than by group name so it works on a
non-English Windows too. Rebuilt rather than amended: `icacls /inheritance:r /grant` reads like it
tightens a file, but `/inheritance:r` drops only *inherited* entries and `/grant` only adds or
updates the trustees it names, so an explicit entry for anyone else would survive both. Since
rebuilding is destructive -- an entry another account or a management tool relied on goes with it
-- the DACL being replaced is recorded first, and `-Disable` puts it back.

Environment matters differently over ssh: the session inherits machine and user variables from the
registry but **nothing** from a PowerShell `$PROFILE`, so a server configured by environment variable
needs those set machine-wide. One configured by a file under the user profile -- windbg-mcp's
`%USERPROFILE%\.windbg-mcp\profiles.json`, for instance -- is immune to how the process was started,
and is the better place for anything secret.

Windows OpenSSH hands a member of the Administrators group a full, unfiltered token, so a server's
elevation-only tools do work over this. That is the host's logon policy rather than a guarantee, and
the printed summary includes the one-line check.

To close it again:

```powershell
powershell -ExecutionPolicy RemoteSigned -File C:\Setup-RemoteMcp.ps1 -Disable
```

Prior scope, profile, and enabled state of every rule it touched, the previous default shell, the
sshd startup type and running state, the DACL of every authorized-keys file it rebuilt, and the keys
it installed go to
`C:\ProgramData\agent-sandbox\remote-mcp-ssh.json` so `-Disable` puts them back exactly. Rerunning
enable never overwrites that record, and a run that fails part way still records what it had already
changed -- a machine left modified with nothing written down is one `-Disable` cannot help.

The record also tracks *which* components the script actually wrote, so `-Disable` only reverts
those: after an enable with `-SkipDefaultShell`, it leaves a `DefaultShell` someone else configured
alone rather than deleting it. And it survives a `-Disable` that could not finish -- a firewall rule
held by group policy, a `-Skip` switch -- so a later run can pick up where that one stopped; the run
says what is still outstanding.

`-Disable` leaves the OpenSSH capability and its host keys installed, because removing them would
change the fingerprint every client has already accepted; `-SkipKeys` keeps the installed keys when
only the network exposure is being closed.

### Restore to clean state

```powershell
Expand Down Expand Up @@ -273,6 +369,7 @@ agent-sandbox-vm/
| |-- Install-GhStack.ps1 # VM-side: installs `gh stack` + the gh-stack skill
| |-- Setup-KernelDebugger.ps1 # Host-side: WinDbg + KDNET firewall setup
| |-- Setup-KernelDebuggee.ps1 # VM-side: BCD/KDNET debuggee setup
| |-- Setup-RemoteMcp.ps1 # VM-side: ssh access for a remote MCP client
| |-- Save-BaseSnapshot.ps1 # Takes the clean base snapshot
| |-- Save-VMCredentials.ps1 # Stores VM credentials encrypted on host
| +-- Copy-Artifacts.ps1 # Extracts build outputs from VM to host
Expand Down
Loading
Loading