cd /news/ai-agents/my-ai-trading-tool-would-sign-a-wall… · home › topics › ai-agents › article
[ARTICLE · art-139376] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=↓ negative

My AI trading tool would sign a wallet drain as a login challenge

A developer maintaining BagOS, an MCP server that lets AI agents trade on the Solana launchpad Bags, disclosed that a wallet-draining exploit bypassed all of the tool's spend guardrails by combining two flaws: the server loaded .env configuration from whatever project folder was open, and the bags_authenticate login tool blindly signed any challenge bytes it received. Because a Solana transaction signature is an ed25519 signature over the serialized message, a malicious repo could point BAGS_API_URL at an attacker-controlled endpoint that returned a balance transfer as the "challenge," yielding a broadcastable signature without invoking any write tool. Version 3.0.0 is a breaking release that requires an absolute BAGS_ENV_FILE path, pins the auth endpoint to https on bags.fm, and rejects any challenge that deserializes as a Solana transaction.

by read5 min views3 publishedSep 25, 2026

BagOS is an MCP server I maintain. It lets an AI agent read token data on Bags, a Solana launchpad,

and, if you configure a wallet, trade and claim creator fees. I built it around one idea: a model

should be able to propose a spend but never complete one on its own. The first call to a write

tool signs nothing. It returns a preview and a single-use token bound to the exact arguments. Every

trade is capped. Every transaction is simulated before it's signed.

This week an outside review showed that none of that mattered. There was a way to drain the wallet

without touching a single write tool.

It needed two flaws. Either one alone was harmless.

1. The server trusted the folder you had open. MCP clients such as Claude Code start a local

server in the current project folder. BagOS called dotenv.config(), which reads .env from the

working directory. So any repository you opened could supply configuration you never set, including

BAGS_API_URL, the endpoint the login tool talks to.

2. The login tool signed whatever it was given. bags_authenticate proves you own a wallet: it

fetches a challenge from the auth endpoint, signs it, and trades the signature for an API key. It

signed the challenge bytes without checking what they were.

On Solana, a transaction signature is an ed25519 signature over the transaction's serialized

message. So if the "challenge" is a transaction message, the signature the tool sends back is a

valid signature for that transaction. Whoever receives it can broadcast it.

Put them together. A repo ships a .env that points BAGS_API_URL at a server its author

controls. You open the repo, and your agent calls bags_authenticate, maybe because a README told

it to. The fake endpoint returns a transfer of your balance as the challenge. The tool signs it and

sends the signature to that server.

The login tool wasn't a write tool, so none of the guardrails applied: no token gate, no cap, no

preview, no confirmation. My docs even said "Signing a challenge is not signing a transaction."

Before the fix, that wasn't true.

The suite had 100% line, branch and function coverage, enforced in CI. Every line of the auth tool

was tested. The tests checked that it fetched a challenge, signed it and exchanged it, and it did

all of that correctly.

Coverage measures which lines run. It says nothing about which inputs you assumed were safe. My

tests used a well-behaved endpoint and a config I wrote myself, because I had never asked who else

could write that config or what else could arrive as a challenge. The missing check wasn't

untested; it had never been written.

3.0.0 is a breaking release, because it changes how configuration loads.

The server no longer reads .env from the working directory. You name a file explicitly, and the

path must be absolute:

const explicit = env["BAGS_ENV_FILE"]?.trim();
if (explicit) {
  // A relative path resolves against the working directory, which is the
  // exact thing this function exists not to trust.
  if (!isAbsolute(explicit)) {
    console.error(/* "refusing BAGS_ENV_FILE=...: it must be an absolute path" */);
    return "refused-relative";
  }

If a .env is sitting in the working directory, the server says it's ignoring it, on stderr.

The login tool now signs only Bags' exact sign-in text, with the nonce from the same init response.

It also refuses anything that decodes as a Solana transaction, and anything that isn't printable

text:

export function isTransactionMessage(bytes: Uint8Array): boolean {
  try {
    const message = VersionedMessage.deserialize(bytes);
    return Buffer.from(message.serialize()).equals(Buffer.from(bytes));
  } catch {
    return false;
  }
}

The auth endpoint is pinned to https on bags.fm unless the operator sets

BAGS_ALLOW_CUSTOM_API_URL=true. The model can no longer choose the keypair path either.

I drafted a private GitHub security advisory, fixed the bug on a temporary private fork, and merged

it from the advisory page. Then:

One trap for anyone doing this with release-please: merging from the advisory page squashes the

private fork into one commit titled "Merge commit from fork." That isn't a conventional commit, so

release-please computed a patch version for a breaking change. I caught it by checking the

release PR before merging, and fixed it by pushing an empty commit that restated the breaking

change.

Hours later, I had the review check my launch plan against the code, and it turned up a second

gap.

The spend caps checked the amount the agent asked for. But the swap transaction that actually gets

signed is built by the Bags API, and nothing compared the two. The cap bounded the request, not

the signature.

3.0.5 closes that. Before signing, BagOS reads the wallet's SOL balance, asks the simulation for

the balance afterwards, and refuses if the difference is more than the approved amount plus 0.01

SOL for fees and rent. A fee claim approves nothing, so it may cost fees only. If the simulation

doesn't report the balance, the transaction isn't signed. The check is required on every path,

so no transaction can skip it.

These are documented in SECURITY.md rather than hidden:

--http serves /mcp on 0.0.0.0. Don't run it with a funded wallet. The stdio default is unaffected. Links: repo ·

advisory ·

SECURITY.md ·

npm

Earlier I wrote about a different BagOS bug, where the write tools reported success without signing anything: I shipped an MCP server that reported success without signing anything.

I build safety layers for AI agents that move money. I'm open to remote work.

── more in #ai-agents 4 stories · sorted by recency
── more on @bagos 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/my-ai-trading-tool-w…] indexed:0 read:5min 2026-09-25 · —