DEV Community

Artemii Amelin
Artemii Amelin

Posted on

Loopjacking Spends One Approval on a Different Action. shell.online's Team MCP Grants Re-Ask the Owner on Every Call

A paper posted to arXiv on September 17 gives a name to a failure that most agent frameworks with a human-approval step turn out to share. Adithyan Arun Kumar calls it loopjacking: "a human approves what they understand as operation A, while the implementation uses that decision for a materially different operation B." The paper splits it in two. In the representation variant, B is already encoded in the pending action but omitted or misrepresented at approval time. In the state-substitution variant, the human sees the correct A, and mutable workflow state replaces it with B after the approval lands.

The affected list is not fringe. Agno AgentOS through 3.0.9, LangGraph Agent Server through 0.14.0, and OpenClaw 2026.2.23 (patched in 2026.2.24) all reproduced. The OpenAI Agents SDK at 0.22.0 and 0.22.2 resisted, because it serializes the continuation at approval time.

The general lesson is older than agents. An approval is only as good as the binding between the decision and what the system does with it later. The longer a "yes" sits in storage as a flag, the more room state has to drift under it.

What a team grant is on shell.online

That framing is why the team MCP work that merged into shell.online on September 21 (pull request #226) is built the way it is. Two facts first, so nobody reads more into it than the repo supports. It landed on main after the v0.22.0 tag was cut, so it is not in any tagged release. And the MCP docs say plainly that the configuration they describe is a rollout requirement, "not a claim that this branch is deployed."

The feature lets a teammate point their own MCP client at a session they do not own. The owner turns it on per session with shell permissions <id> --mcp-team-access=true. The teammate runs shell mcp team <id> and waits. The session's own machine, not the account service and not the relay, is the only party that can mint the grant. It polls for work every five seconds, mints an observe-only grant labelled with the requester's account ID, and reports the bearer back. The relay refuses to mint a team grant with any scope other than observe. That check is a hard 400 in the Durable Object, not a UI default.

Three places where the approval is re-bound

The paper's two variants map onto three decisions in the shell.online source.

The credential is sealed to a key that exists for one request. The requesting CLI generates a P-256 key pair and keeps the private half in process memory only. The account service seals the host-issued bearer to that public key with the existing ECDH/HKDF/AES-GCM construction, and mixes the session ID, requester UID and request ID into the binding. Sealing failure throws before anything is written, and migration 022 adds a table constraint that the bearer column must be null unless the row is marked sealed. A bearer minted for request A cannot be reattached to request B.

Delivery happens once. The Postgres store selects the row FOR UPDATE, returns the ciphertext, and nulls the bearer column in the same transaction. A second fetch, racing or late, finds nothing. If the CLI died before printing, the teammate asks again, which costs the owner at most one more observe-only grant.

The owner's consent is re-read on every request, not at issuance. This is the part that answers state substitution directly. The relay does not trust its own copy of a teammate's grant, because the host that issued it may be offline and the copy arbitrarily stale. On each incoming MCP request it calls the account service, which reads the current rows: session not closed, team access still true, requester still present in the memberships table, grant still in issued status and unexpired. Anything other than an explicit authorized: true is a 403. An unreachable service, a timeout past two seconds, a redirect, or a response over 1024 bytes is a 503. Both deny. The same check runs again around tool execution, so a long shell_wait that finishes after the owner flips consent off returns nothing. Turning consent back on does not revive an old grant. The owner's own grants never take this path, since there is no third party whose standing could have changed.

That is the difference between storing an approval and storing a question. The loopjacked frameworks stored the approval and let state drift underneath it. The team grant stores nothing the relay can act on by itself. Every use re-asks the owner's current record.

Where it is honest to stop

None of this makes a teammate's screen read safe on its own. The grant is observe-only, but observe means the current rendered terminal grid, and terminal output is untrusted input to whatever agent is reading it. The docs are explicit that a bearer label proves nothing about which agent called. The v0.22.0 release notes say the team gateway was deliberately left out of that release. The cap structure (four pending requests per session, eight issued, 512 rows across the table) is a backstop against a burst, not a rate limit anyone should lean on.

The same principle sits under the transport layer of Pilot Protocol. Tunnel keys are negotiated between the two peers with X25519, and a relay in the NAT-traversal path forwards ciphertext it cannot open. Our implementation never hands the overlay a key it could reuse later. The team grant applies that same rule to a credential instead of a tunnel.

If there is one takeaway from the paper for anyone building an approval step, it is that a "yes" needs a time-to-live measured in requests, not days. The team grant's pending window is five minutes. Its useful life is one request at a time, and each request asks again.

Top comments (0)