Repository navigation
feat(ssh): announce AI_AGENT=mcp-ssh-manager on every command channel - #84
mthamil107 wants to merge 1 commit into
Conversation
An operator reading their own server logs cannot tell a session this tool drives from one a person drives: both present the same key and sshd records the same username. This sends AI_AGENT=mcp-ssh-manager on each channel that carries a command, so the distinction is available to anything already reading those logs. SSHManager opens five channels. Three announce — execCommand, execCommandStream and requestShell — covering ssh_execute and every tool routed through it, ssh_tail --follow, and ssh_session_*. Two do not. getSFTP is excluded deliberately: ssh2 calls reqEnv with a callback there, so want_reply is 1 and a server without AcceptEnv fails the whole transfer, unlike exec and shell where the request is fire-and-forget. forwardOut cannot, as direct-tcpip channels have no env request. ssh_sync runs rsync over the system ssh binary and would need -o SetEnv instead. requestShell passes the announcement as ssh2's third argument rather than folding it into the existing options object. ssh2 reassigns opts = wndopts and drops wndopts when the first object carries env, which would discard term, cols, rows and modes — including modes.ECHO = 0, which the session marker protocol depends on. That failure would pass every existing test. announce_agent / SSH_SERVER_[NAME]_ANNOUNCE_AGENT opts out per server, defaulting to true. AcceptEnv governs what a server stores, not what a client sends, so the request reaches every host either way; since command output returns to a model, a host worth distrusting learns something actionable. The default-on flag needs its own parser, because parseBool treats anything unrecognised as false and would opt every server out. Tests: six channel checks in test-agent-announce.js, plus announceAgent coverage in test-config-field-names.js for both loaders and both export round trips. Each was verified by breaking the matching production line on its own and confirming the suite fails.
Adapts the AI_AGENT announcement contributed in the previous commit to V4. Two changes of substance, one extension, and the tests to hold all three. Off unless chosen. The contribution turned it on for every server, which would break the promise V4 makes to anyone upgrading from 3.8 — that nothing reaching their servers changes on its own. It also reaches every host whatever its AcceptEnv (sshd declines to store it, it still receives it), so a host the operator does not control would learn an agent is on the other end and could shape its output for one. The operators who benefit are the ones who configured AcceptEnv AI_AGENT; they can also flip one switch: `announce_agent` per server, or SSH_MANAGER_ANNOUNCE_AGENT for all of them. The per-server value is now three-state so an explicit false keeps one host silent under the global switch, and both explicit values survive an export round trip while an unset one is never written out as a choice. Never for a person. In V4 the control plane opens its own connections for a shell, a file browser, a health probe and a command across a group. Ported as it was, each of those clicks would reach the server labelled as an agent — the exact confusion the convention exists to end. connectServer() now takes the actor, the control plane passes `actor: 'human'` on all five calls, and a wiring test fails if a sixth is added without it. ssh_sync too. It drives rsync through the system ssh, not ssh2, so it gets the announcement via SendEnv (OpenSSH 3.9+) rather than the SetEnv the contribution suggested (7.8+): someone who opted in on an older client should lose a label, not a working sync. Verified against a real OpenSSH with AcceptEnv AI_AGENT: nothing arrives by default; exec, interactive shell (pty size intact) and ssh+SendEnv carry it once chosen; a per-server false and a human action both stay silent under the global switch. Six mutations — default on, the per-server false ignored, the human marker dropped, one control-plane call unmarked, SendEnv unwired, the env folded into the pty options — each fail the suite. test-working-directory's ssh2 stub gains the three-argument exec, as the contribution already did for test-sudo-stdin.
|
Thank you — this is in V4, and in the preview published today: 4.0.0-beta.1. Your commit is kept as it was, with your name on it (c3b40c0, via #80). One commit on top adapts it to V4 (93ad2e1). Three differences, and why:
Checked against a real OpenSSH with Closing this one since it landed through #80 rather than into the 3.8 line on |
Every command channel this tool opens now sends
AI_AGENT=mcp-ssh-manager, so an operator reading their own server logs can tell a session this tool drives from one a person drives. SSH cannot distinguish them today — both present the same key, and the log records the same username either way.This follows the AI_AGENT over SSH convention. Disclosure: I wrote that document.
tufantunc/ssh-mcpmerged the same change in #227 and released it in v2.11.0; the review there found two real defects in my patch, and this one is written to avoid both.The call sites
SSHManageris the only file that touches ssh2, and it opens a channel in five places. Three carry the announcement, one is excluded on purpose, one cannot:execCommandexecssh_executeand every other tool routed through itexecCommandStreamexecssh_tailwithfollow: true— one caller, easy to missrequestShellshellssh_session_*getSFTPsftpforwardOutdirect-tcpipWhy SFTP is excluded. ssh2 does support
sftp(env, cb), but its env path differs from exec and shell in a way that matters.execandshellcallreqEnv(chan, opts.env)with no callback, sowant_replyis 0 and an unaccepted name cannot fail the channel.sftpcallsreqEnv(sftp, env, cb)with a callback, so a server that refuses the request fails the whole SFTP session. Mostsshd_configfiles shipAcceptEnv LANG LC_*only, so announcing there would breakssh_upload,ssh_downloadandssh_deployon the majority of hosts to gain a label on one channel type. The reasoning is in a comment on the method so it is not re-litigated later.Not covered by this PR:
ssh_syncshells out torsyncover the systemsshbinary rather than ssh2, so it needs-o SetEnv=AI_AGENT=...in the option list built aroundsrc/index.js, not a channel option. Happy to follow up separately if you want it — I left it out rather than grow this diff.The ssh2
shell()traprequestShellnow takes ssh2's third argument rather than folding the env into the existing options object. This is not stylistic.shell(wndopts, opts, cb)does:So adding
envto the object that currently carriesterm,cols,rowsandmodesmakes ssh2 reclassify the whole object and request a pty with its own defaults instead.modes: { ECHO: 0 }would be lost, andSSHSession.initialize()depends on echo-off for the marker protocol from #30. The announcement would look correct, every existing test would still pass, and interactive sessions would quietly misbehave.tests/test-agent-announce.jsasserts the window options survive on the first argument, so that mistake fails a test rather than shipping.Configuration
announce_agent/SSH_SERVER_[NAME]_ANNOUNCE_AGENT, default true.The announcement is the feature, so it is on by default. But
AcceptEnvis the server's policy about what it stores, not a gate on what the client sends — the request reaches every host either way. Since this tool returns command output into a model's context, "an agent is driving this" is an actionable fact to hand an untrusted host. So there is an opt-out.Because it defaults on, it needed its own parser:
parseBoolreturns false for anything that is not an explicit truthy string, which would opt every server out.parseBoolDefaultTrueis its mirror, disabling only on an explicitfalse/0/no/off.exportToTomlandexportToEnvwrite only the opt-out, so a config round trip cannot silently re-enable announcing.Tests
tests/test-agent-announce.js, six checks, added to thetestchain astest:agentannounce.Mutation-checked one at a time, restoring the file between each, because your 3.8.5 notes set that bar and the ssh-mcp review found my previous tests could not fail:
execCommandloses the envexecCommandStreamloses the envrequestShellloses the envannounceAgentignoredundefinedThe stubs reproduce ssh2's own argument shifting, including the
opts = wndoptsreassignment, so the fold lands on the assertion that names it rather than dying on an arity mismatch.That last mutation is a real bug I hit while writing this: ssh2's
execreadsopts.allowHalfOpenwithout guarding, so returningundefinedwhen opted out throws at the call — and it would only ever break for the users who turned the announcement off.channelEnv()returns{}instead.The config wiring is covered the same way, in
tests/test-config-field-names.js:announceAgentis inEXPECTED_CAMEL_FIELDSfor both loaders, and a new coercion case pins the default-on behaviour, the"false"/"0"strings, and the opt-out surviving bothexportToTomlandexportToEnv. Deleting any one of the fourannounceAgentlines inconfig-loader.js, or flippingparseBoolDefaultTrue's default, fails that suite. It did not before I added this — the first version of this branch had the config layer fully deletable with everything green, which is the same defect the ssh-mcp review found in my channel tests.tests/test-sudo-stdin.jsneeded its stub widened fromexec(cmd, cb)toexec(cmd, _opts, cb), sinceexecCommandnow always passes options.Verification
npm run typecheckandnpm run lintclean.npm testis an&&chain and stops attest:lifecycle, which fails on my machine on an unmodified checkout too — the three SIGTERM/SIGINT cases, which is Windows signal handling rather than anything in this change. I verified that by stashing the branch and re-running. I then ran the suites after it individually: all pass excepttest:dbinjection, which needs/bin/shand cannot run on Windows at all. So Linux CI is the real check here, not my local run.No dependency change, so
package-lock.jsonis untouched.CI will sit at
action_requireduntil you approve it, as it does for any fork PR, so the above is local.Targeted at
main. Thev4branch keeps the same five call sites in the same file, so I am happy to port it if you would rather take it there.