Adversa's September MCP security roundup lists three server CVEs. One of them is CVE-2026-73498 in mcp-atlassian, CVSS 7.7. Before 0.22.0, confluence_upload_attachment passed its client-supplied file_path directly to open(file_path, "rb"). A string the caller controls ended up deciding what the server was allowed to touch.
We shipped our own version of that bug today, in the release that added MCP to shell.online. It lived for three hours and eleven minutes between the two GitHub releases. It is worth writing down exactly, because the feature it sat in is a permission system.
What 0.21.0 added
v0.21.0 lets an agent attach to a shared terminal over MCP at https://shell.online/mcp. The tool surface in docs/MCP.md is five entries: shell_status, shell_screen, shell_output and shell_wait need an observe grant, and shell_send (1 to 8192 UTF-8 bytes, a required UUID-v4 operation_id) needs input. Nothing is implicit. The host that owns the PTY has to issue a grant:
shell mcp grant <session-id> <label> observe 900
The label is a display name for the grant. The third argument is a preset (observe, or control for observe plus input), and the fourth is a lifetime in seconds. The bearer that comes back is separate from the browser link and password, is returned once, and dies on revocation, expiry, or a live password rotation.
How a label became a scope
The shell mcp grant command does not talk to the relay itself. It sends one line over a local control channel to the running host process, which holds the session's key and makes the API call. In 0.21.0 the CLI built that line like this:
fmt.Sprintf("mcp grant %s %s %d", label, scopes, ttl)
and the host read it back with strings.Fields, then took args[1] as the label, args[2] as the scopes and args[3] as the lifetime, discarding the error from strconv.Atoi.
So quote a label with a space in it and every field shifts. The regression test added in 0.21.1 uses the label "Agent control 86400" with a requested grant of observe 900. On a 0.21.0 host that line tokenizes to label Agent, scopes control, lifetime 86400. The operator asked for fifteen minutes of read access and the host requested input for a day. An ordinary label fails the other way: "My Agent" turns into a request for a scope named Agent, which validateScopes on the relay rejects as unknown.
Three things bounded the damage. The relay's clampLifetime caps a control preset at 3600 seconds regardless of what is requested, so the day would have been cut to an hour. Input still requires a control-enabled session, and --read-only denies it. And the control channel is local to the machine that owns the PTY, so nobody could reach it remotely. The person typing the label already owns the session.
That last point is also why it matters. Labels are the one field likely to be filled in by something other than the operator's judgment: a wrapper script that names each grant after whatever the agent calls itself. In 0.21.0 that script would have handed the agent the scope argument.
The fix is a format change, and it refuses to downgrade
v0.21.1 replaces the whitespace-delimited line with a versioned verb. cmd/shell/mcp_grant_request.go JSON-encodes {label, scopes, ttl}, base64url-encodes that, and sends mcp grant-v2 <blob>. The decoder walks the object token by token. It rejects duplicate keys, unknown keys, nulls, anything other than exactly three fields, and trailing data. Labels may be empty or Unicode, up to 256 bytes, without control characters. A lifetime must be all digits, so not-a-number, -1 and an overflowing value now exit with code 2 before anything reaches the issuance path.
The part that cost something is compatibility. A 0.21.0 host does not know grant-v2 and answers unknown mcp command. The new CLI could have retried with the old format. It does not, and the comment above the encoder says why: "Never fall back to whitespace-delimited labels: doing so can turn label data into grant authority." You get an error telling you to update and restart that session's host when it is safe to. Listing and revoking grants still work against the old host, and no running target gets restarted for you.
New hosts do still accept the legacy verb, but only as a label, a scope preset and an optional lifetime, with nothing after them. The 0.21.0 CLI always appended a lifetime, so any label with a space in it arrives with too many fields and is refused.
The same rule in a signing envelope
Pilot Protocol has a delimited format too, and it holds up for the opposite reason. The request envelope a node signs to prove a request came from it, specified in docs/SIGNATURE-VERIFICATION.md, is pipe-separated: pilot-req-v1|address|ts|nonce|body hash|audience. Every field has a fixed charset that excludes |. The spec goes as far as banning the usual text address form (N:NNNN.HHHH.LLLL) inside the envelope because it contains delimiter characters, and uses 12 hex characters instead. Parsers reject non-canonical encodings.
A delimiter is safe when no field can contain it. A free-text label can contain anything, so it needed a real encoding from the first commit. The mcp-atlassian advisory's complaint is a path opened without validate_safe_path. Our fix was to stop letting a parameter sit in the same token stream as the permission it describes.
If you started a session on 0.21.0 and want MCP grants on it, the upgrade note in the release applies: update the CLI, then restart the host process for that session.
Top comments (1)
The failure is a great example of why display data and authority data should never share an ad-hoc token stream. The versioned JSON envelope plus refusal to downgrade is the right kind of fix: it repairs both ambiguity and compatibility behavior. I would also property-test the control protocol with whitespace, Unicode, empty labels, and delimiter-like input while asserting that requested scopes and TTL never change after round-tripping. Are you planning to fuzz the other local control verbs against the same “free text cannot alter authority” invariant?