Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
181 changes: 181 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,186 @@ 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, and
`-ClientIdentityFile` names the private half in the printed `~/.ssh/config` block when it is not the
default `~/.ssh/id_ed25519` -- with `BatchMode yes`, pointing at the wrong key fails authentication
without a prompt. 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.
Narrows, never widens: a rule scoped to `Any` becomes the allowlist, one already naming only
addresses from it is left alone, one that overlaps is cut to the overlap, and one whose scope
cannot be compared to the request without guessing at containment (`10.0.0.5` against
`LocalSubnet`) is left untouched and fails the run rather than being written over. A rule it does
not change it also does not record: a record is a claim of ownership, and `-Disable` acts on it.
On the way back out it restores a rule only where that rule is still exactly as this script left
it; one an administrator has since disabled or tightened is left to them, since reinstating a
broad original over it would hand out access at the moment the script is taking its own away.
That is rule 3 of the ledger below, and rule 2 is what keeps it honest across reruns and retries.
A rule counts as being on that port whether its filter names the port exactly or a range spanning
it, and a rule that cannot be narrowed -- one managed by group policy, typically -- fails the run
rather than letting it report a boundary the rule still overrides. One whose filter is `LocalPort Any` is never
rescoped -- such a rule is usually scoped to a program rather than to a port, and narrowing every
one of them to the ssh client would take the VM's other inbound traffic down with it -- but its
program and service filters are read to see whether it can reach sshd at all. One that cannot is
ignored by name, as is one that can but only from inside the scope this run asked for -- that
widens nothing. One that reaches sshd from outside it fails the run alongside the rules that would
not narrow, since either way `-ClientAddress` is not the boundary the summary would report. 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. The key is appended to that file, never
written over it, so other keys and comments in it survive -- including the file's exact ending: if
its last line had no terminator, the newline this script inserts to keep the new key off that line
is a ledger entry of its own, recorded by position rather than as "the last byte", and taken back
out once every managed key has left the file -- so the file comes back byte for byte however many
runs appended to it. If something else has appended a line since, that newline now terminates
somebody else's content: the run reports it as changed and leaves it, rather than joining two
lines that were never joined. Keys are matched the way sshd reads
them, on the type and the base64 blob, and the type is *found* rather than assumed to be the first
field -- so neither a comment edited by hand nor an entry hardened with `restrict` or `from="..."`
hides a key from `-Disable`, and neither gets it installed a second time by a rerun. A file
starting with a byte-order mark is refused instead -- sshd reads it as plain bytes, so a UTF-16 file (what Windows PowerShell
redirection writes by default) is already not being read the way it looks. 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. It puts it back only
once the key it protects is gone, though: if removing a managed key fails, that file keeps the
tightened DACL, so a `-Disable` that could not finish never leaves a live authorized-keys file
writable by whoever the original DACL allowed.

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.

The printed `~/.ssh/config` escapes what it quotes, so a client identity path containing a double
quote or a backslash survives ssh's parser rather than producing a config it refuses.

`-SshHostAlias` may not contain whitespace: ssh refuses a destination with a space in it whatever the
quoting, so such an alias could never be connected to. A `-User` with a space is fine -- the printed
`~/.ssh/config` quotes it, which the real parser accepts.

`-Disable` exits non-zero when something it tried to put back is still in place, so a script driving
it can tell that from a clean close; a `-Skip` switch is a choice rather than a failure and does not
affect the status.

To close it again:

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

Every change goes into one ledger in `C:\ProgramData\agent-sandbox\remote-mcp-ssh.json` -- a
competing firewall rule's scope, profile and enabled state; the previous default shell and its
command option; the sshd startup type and running state; the DACL of every authorized-keys file
rebuilt; each key installed; and the newline added to a file that had none. One entry per change,
carrying what the machine had (`Original`) and what this script wrote (`Applied`), under one set of
rules for all of them rather than a separate implementation per component:

1. **Record before mutating, on disk.** A change made and not written down is one `-Disable` can
never put back, and a record held in memory does not survive what interrupts a run -- a reset, a
kill, power loss. The ledger is rewritten (atomically, whole-file) every time it changes, so the
record is behind the machine by at most the single call in flight and never ahead of it. A run
that cannot write it stops at the first change it cannot record, rather than making all of them
and failing at the end.
2. **Track what was applied, not what was planned.** `Applied` starts equal to `Original` and
advances only as each individual write returns, so it describes the machine as the script
actually left it -- however far through a multi-field change it got. A rerun contributes only
the fields it actually wrote: one it merely looked at keeps whatever the earlier run recorded,
so a value somebody else set in between is never mistaken for this script's own.
3. **Verify before reverting.** A field goes back only where the machine still holds the value this
script wrote; anything else belongs to whoever changed it since. Ownership is per *field* where
the fields stand alone: an administrator who sets sshd to `Disabled` keeps that, while the
service this script started is still stopped. Where they do not, the change is all-or-nothing --
the default shell, because `<shell> <option>` has to agree with itself, and a firewall rule,
because its address, profile and enabled flag are one scope. Giving a rule's address back while
somebody else owns its profile would produce a combination neither party configured. A rule left
narrower than this script found it is the safe failure there.
4. **Drop what is restored, keep what is not.** What stays on record is exactly the work still
outstanding, so a later `-Disable` resumes rather than re-applies.

Restore order comes from the entry kind, and within one file the lower kind goes first -- which is
what puts a key out before that file's terminator, and both before the DACL protecting them. The
order is also the order a run that is *closing* access has to work in: sshd stops and the key comes
out first, the firewall is handed back last, so `-Disable` never widens the machine while the
credential it installed is still live. Enable runs the same way round: the firewall boundary is
enforced *before* sshd is started, so the listener never answers from outside `-ClientAddress`. On a
rerun, the script's own rule is disabled while its port, address and profile filters are rewritten,
and re-enabled only once the whole scope is in place -- that is three calls, and leaving it enabled
through them would publish combinations nobody asked for. One consequence worth knowing: a `-Disable` run over the very
ssh access it is closing loses its session part way through, so run it on the guest. 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, so a run
that changes the machine and then cannot write the record fails rather than reporting success. A record
that exists but cannot be parsed stops the run before it changes anything, rather than being taken
for no record at all: restore or move the file and rerun.

The ledger holds only what the script actually wrote, so `-Disable` reverts only that: after an
enable with `-SkipDefaultShell` it leaves a `DefaultShell` someone else configured alone, and a rule
already inside the requested scope is never recorded at all. Rule 3 applies to every kind alike --
a shell, a firewall rule, the sshd service and a DACL each stay put if somebody changed them after
setup, and the run names the field that moved. Rule 4 is what makes a `-Disable` that could not
finish -- a firewall rule held by group policy, a `-Skip` switch -- resumable: a later run picks up
where that one stopped and cannot re-apply a value from before the first enable over whatever was
configured in the meantime.

`-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 +453,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