Skip to content

feat(ssh): announce AI_AGENT=mcp-ssh-manager on every command channel - #84

Closed
mthamil107 wants to merge 1 commit into
bvisible:mainfrom
mthamil107:announce-ai-agent
Closed

mthamil107 wants to merge 1 commit into
bvisible:mainfrom
mthamil107:announce-ai-agent

Conversation

@mthamil107

Copy link
Copy Markdown

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-mcp merged 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

SSHManager is 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:

# Site Channel Announces Why
1 execCommand exec yes ssh_execute and every other tool routed through it
2 execCommandStream exec yes ssh_tail with follow: true — one caller, easy to miss
3 requestShell shell yes ssh_session_*
4 getSFTP sftp no see below
5 forwardOut direct-tcpip no no env request exists for this channel type, and ssh2 exposes no options argument

Why SFTP is excluded. ssh2 does support sftp(env, cb), but its env path differs from exec and shell in a way that matters. exec and shell call reqEnv(chan, opts.env) with no callback, so want_reply is 0 and an unaccepted name cannot fail the channel. sftp calls reqEnv(sftp, env, cb) with a callback, so a server that refuses the request fails the whole SFTP session. Most sshd_config files ship AcceptEnv LANG LC_* only, so announcing there would break ssh_upload, ssh_download and ssh_deploy on 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_sync shells out to rsync over the system ssh binary rather than ssh2, so it needs -o SetEnv=AI_AGENT=... in the option list built around src/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() trap

requestShell now takes ssh2's third argument rather than folding the env into the existing options object. This is not stylistic. shell(wndopts, opts, cb) does:

if (wndopts && (wndopts.x11 !== undefined || wndopts.env !== undefined)) {
  opts = wndopts;
  wndopts = undefined;
}

So adding env to the object that currently carries term, cols, rows and modes makes ssh2 reclassify the whole object and request a pty with its own defaults instead. modes: { ECHO: 0 } would be lost, and SSHSession.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.js asserts 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 AcceptEnv is 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: parseBool returns false for anything that is not an explicit truthy string, which would opt every server out. parseBoolDefaultTrue is its mirror, disabling only on an explicit false/0/no/off. exportToToml and exportToEnv write only the opt-out, so a config round trip cannot silently re-enable announcing.

Tests

tests/test-agent-announce.js, six checks, added to the test chain as test: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:

Mutation Caught by
execCommand loses the env check 1
execCommandStream loses the env check 2
requestShell loses the env check 3
env folded into the window options check 4 (pty settings)
announceAgent ignored check 5
opt-out returns undefined check 5

The stubs reproduce ssh2's own argument shifting, including the opts = wndopts reassignment, 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 exec reads opts.allowHalfOpen without guarding, so returning undefined when 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: announceAgent is in EXPECTED_CAMEL_FIELDS for both loaders, and a new coercion case pins the default-on behaviour, the "false"/"0" strings, and the opt-out surviving both exportToToml and exportToEnv. Deleting any one of the four announceAgent lines in config-loader.js, or flipping parseBoolDefaultTrue'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.js needed its stub widened from exec(cmd, cb) to exec(cmd, _opts, cb), since execCommand now always passes options.

Verification

npm run typecheck and npm run lint clean.

npm test is an && chain and stops at test: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 except test:dbinjection, which needs /bin/sh and cannot run on Windows at all. So Linux CI is the real check here, not my local run.

No dependency change, so package-lock.json is untouched.

CI will sit at action_required until you approve it, as it does for any fork PR, so the above is local.

Targeted at main. The v4 branch keeps the same five call sites in the same file, so I am happy to port it if you would rather take it there.

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.
bvisible added a commit that referenced this pull request Sep 24, 2026
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.
@bvisible

Copy link
Copy Markdown
Owner

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:

  1. Off by default. V4 makes a promise to anyone upgrading from 3.8: nothing that reaches their servers changes on its own — a test installs 3.8.5 from the registry and upgrades over it to prove it. You also named the other reason yourself: the request reaches every host whatever its AcceptEnv, so a host the operator does not control learns an agent is on the other end. SSH_MANAGER_ANNOUNCE_AGENT=true turns it on for every server. announce_agent per server is now three-state, so an explicit false keeps one host silent under the global switch, and both explicit values survive the export round trip.
  2. Never for a person. V4 adds a control plane — a desktop app — that opens its own connections for a shell, a file browser, a health probe, a command across a group. Ported as it was, each of those clicks would have reached the server labelled as an agent. connectServer() now takes the actor; all five control-plane calls pass actor: 'human', and a test fails if a sixth appears without it.
  3. ssh_sync too, as you suggested — via SendEnv (OpenSSH 3.9+) rather than SetEnv (7.8+), so someone who opts in on an older client loses a label rather than a working sync.

Checked against a real OpenSSH with AcceptEnv AI_AGENT: absent by default; present on exec, the interactive shell and ssh+SendEnv once enabled; absent under a per-server false and for control-plane actions. Your shell(wndopts, opts, cb) analysis was exactly right — the pty size survives — and your mutation bar held: six mutations, including folding the env into the window options, each fail the suite.

Closing this one since it landed through #80 rather than into the 3.8 line on main. Thank you for the care that went into it; the ssh2 trap in particular would have been easy to miss.

@bvisible bvisible closed this Sep 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants