diff --git a/README.md b/README.md index 3809324..3627c29 100755 --- a/README.md +++ b/README.md @@ -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 ` + -ServerCommand C:\tools\windbg-mcp.exe +``` + +`-PublicKeyPath ` 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
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 `