{"slug": "my-ai-trading-tool-would-sign-a-wallet-drain-as-a-login-challenge", "title": "My AI trading tool would sign a wallet drain as a login challenge", "summary": "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.", "body_md": "BagOS is an MCP server I maintain. It lets an AI agent read token data on Bags, a Solana launchpad,\n\nand, if you configure a wallet, trade and claim creator fees. I built it around one idea: a model\n\nshould be able to *propose* a spend but never complete one on its own. The first call to a write\n\ntool signs nothing. It returns a preview and a single-use token bound to the exact arguments. Every\n\ntrade is capped. Every transaction is simulated before it's signed.\n\nThis week an outside review showed that none of that mattered. There was a way to drain the wallet\n\nwithout touching a single write tool.\n\nIt needed two flaws. Either one alone was harmless.\n\n**1. The server trusted the folder you had open.** MCP clients such as Claude Code start a local\n\nserver in the current project folder. BagOS called `dotenv.config()`, which reads `.env` from the\n\nworking directory. So any repository you opened could supply configuration you never set, including\n\n`BAGS_API_URL`, the endpoint the login tool talks to.\n\n**2. The login tool signed whatever it was given.** `bags_authenticate` proves you own a wallet: it\n\nfetches a challenge from the auth endpoint, signs it, and trades the signature for an API key. It\n\nsigned the challenge bytes without checking what they were.\n\nOn Solana, a transaction signature is an ed25519 signature over the transaction's serialized\n\nmessage. So if the \"challenge\" is a transaction message, the signature the tool sends back is a\n\nvalid signature for that transaction. Whoever receives it can broadcast it.\n\nPut them together. A repo ships a `.env` that points `BAGS_API_URL` at a server its author\n\ncontrols. You open the repo, and your agent calls `bags_authenticate`, maybe because a README told\n\nit to. The fake endpoint returns a transfer of your balance as the challenge. The tool signs it and\n\nsends the signature to that server.\n\nThe login tool wasn't a write tool, so none of the guardrails applied: no token gate, no cap, no\n\npreview, no confirmation. My docs even said \"Signing a challenge is not signing a transaction.\"\n\nBefore the fix, that wasn't true.\n\nThe suite had 100% line, branch and function coverage, enforced in CI. Every line of the auth tool\n\nwas tested. The tests checked that it fetched a challenge, signed it and exchanged it, and it did\n\nall of that correctly.\n\nCoverage measures which lines run. It says nothing about which inputs you assumed were safe. My\n\ntests used a well-behaved endpoint and a config I wrote myself, because I had never asked who else\n\ncould write that config or what else could arrive as a challenge. The missing check wasn't\n\nuntested; it had never been written.\n\n3.0.0 is a breaking release, because it changes how configuration loads.\n\nThe server no longer reads `.env` from the working directory. You name a file explicitly, and the\n\npath must be absolute:\n\n``` js\nconst explicit = env[\"BAGS_ENV_FILE\"]?.trim();\nif (explicit) {\n  // A relative path resolves against the working directory, which is the\n  // exact thing this function exists not to trust.\n  if (!isAbsolute(explicit)) {\n    console.error(/* \"refusing BAGS_ENV_FILE=...: it must be an absolute path\" */);\n    return \"refused-relative\";\n  }\n```\n\nIf a `.env` is sitting in the working directory, the server says it's ignoring it, on stderr.\n\nThe login tool now signs only Bags' exact sign-in text, with the nonce from the same init response.\n\nIt also refuses anything that decodes as a Solana transaction, and anything that isn't printable\n\ntext:\n\n```\nexport function isTransactionMessage(bytes: Uint8Array): boolean {\n  try {\n    const message = VersionedMessage.deserialize(bytes);\n    return Buffer.from(message.serialize()).equals(Buffer.from(bytes));\n  } catch {\n    return false;\n  }\n}\n```\n\nThe auth endpoint is pinned to https on `bags.fm` unless the operator sets\n\n`BAGS_ALLOW_CUSTOM_API_URL=true`. The model can no longer choose the keypair path either.\n\nI drafted a private GitHub security advisory, fixed the bug on a temporary private fork, and merged\n\nit from the advisory page. Then:\n\nOne trap for anyone doing this with release-please: merging from the advisory page squashes the\n\nprivate fork into one commit titled \"Merge commit from fork.\" That isn't a conventional commit, so\n\nrelease-please computed a **patch** version for a breaking change. I caught it by checking the\n\nrelease PR before merging, and fixed it by pushing an empty commit that restated the breaking\n\nchange.\n\nHours later, I had the review check my launch plan against the code, and it turned up a second\n\ngap.\n\nThe spend caps checked the amount the agent asked for. But the swap transaction that actually gets\n\nsigned is built by the Bags API, and nothing compared the two. The cap bounded the request, not\n\nthe signature.\n\n3.0.5 closes that. Before signing, BagOS reads the wallet's SOL balance, asks the simulation for\n\nthe balance afterwards, and refuses if the difference is more than the approved amount plus 0.01\n\nSOL for fees and rent. A fee claim approves nothing, so it may cost fees only. If the simulation\n\ndoesn't report the balance, the transaction isn't signed. The check is required on every path,\n\nso no transaction can skip it.\n\nThese are documented in SECURITY.md rather than hidden:\n\n`--http` serves `/mcp` on `0.0.0.0`. Don't run it with a funded\nwallet. The stdio default is unaffected.\nLinks: [repo](https://github.com/edycutjong/BagOS) ·\n\n[advisory](https://github.com/edycutjong/BagOS/security/advisories/GHSA-g679-3wq7-mh3m) ·\n\n[SECURITY.md](https://github.com/edycutjong/BagOS/blob/main/.github/SECURITY.md) ·\n\n[npm](https://www.npmjs.com/package/bagos-mcp-server)\n\nEarlier 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](https://dev.to/edycutjong/i-shipped-an-mcp-server-that-reported-success-without-signing-anything-6oh).\n\nI build safety layers for AI agents that move money. I'm open to remote work.", "url": "https://wpnews.pro/news/my-ai-trading-tool-would-sign-a-wallet-drain-as-a-login-challenge", "canonical_source": "https://dev.to/edycutjong/my-ai-trading-tool-would-sign-a-wallet-drain-as-a-login-challenge-7o7", "published_at": "2026-09-25 01:44:23+00:00", "updated_at": "2026-09-25 01:58:56.444767+00:00", "lang": "en", "topics": ["ai-agents", "agent-protocols", "ai-safety", "ai-tools"], "entities": ["BagOS", "Bags", "Solana", "Claude Code", "MCP", "GitHub"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/my-ai-trading-tool-would-sign-a-wallet-drain-as-a-login-challenge", "markdown": "https://wpnews.pro/news/my-ai-trading-tool-would-sign-a-wallet-drain-as-a-login-challenge.md", "text": "https://wpnews.pro/news/my-ai-trading-tool-would-sign-a-wallet-drain-as-a-login-challenge.txt", "jsonld": "https://wpnews.pro/news/my-ai-trading-tool-would-sign-a-wallet-drain-as-a-login-challenge.jsonld"}}