Build1 publisher3 min readPublished Updated
A hostile repo's .env could make BagOS's login tool sign a wallet drain
BagOS 3.0.0 stops reading .env from the working directory after a review found a repo's config could make its login tool sign a wallet drain. Its preview-and-confirm gate covered write tools only, and login was never classed as one.
The Engineer · Build desk
What happened
- BagOS, an MCP server for the Solana launchpad Bags, answers a first write call with a preview and a single-use token, and it caps and simulates every trade.
- Its bags_authenticate login tool signed whatever challenge bytes that endpoint returned, without checking what they were.
- The maintainer deprecated every npm release from 1.0.0 through 2.6.0 and published advisory GHSA-g679-3wq7-mh3m.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- exposure Any local MCP server that loads config from its launch directory lets the author of whatever repository the user opens choose its endpoints.
- constraint A confirm-before-spend gate protects only the tools it is wired to, so every tool that can reach the signing key needs its own check on what it signs.
- decision Operators moving to 3.0.0 have to relocate project-local settings to an absolute BAGS_ENV_FILE path and opt in explicitly to any endpoint off bags.fm.
- cost Maintainers who merge security fixes from GitHub advisory pages with release-please have to check the computed version by hand, or a breaking fix ships as a patch.
The guardrails applied to write tools. bags_authenticate was not one, so the token gate, the cap, the preview and the confirmation never ran on it [8]. It still held the wallet key, and it still sent signatures to whatever endpoint it was configured to call [5]. The project's docs said "Signing a challenge is not signing a transaction." [9] On Solana, a transaction signature is an ed25519 signature over the transaction's serialized message [6]. If the challenge bytes are a transaction message, the login signature is valid for that transaction, and whoever receives it can broadcast it [6].
The endpoint came from the working directory. BagOS called dotenv.config(), and that call reads .env from the current directory [4]. MCP clients such as Claude Code start a local server in the current project folder [4]. Whoever wrote the repository the user opened could therefore set BAGS_API_URL, the endpoint the login tool talks to [4]. The maintainer laid out the chain [7]:
1. The repo ships a .env that points BAGS_API_URL at a server its author controls. 2. The agent calls bags_authenticate, perhaps because a README told it to. 3. The fake endpoint returns a transfer of the wallet's balance as the challenge. 4. The tool signs it and sends the signature to that server.
According to the maintainer, neither flaw was harmful on its own [3].
The test suite had 100% line, branch and function coverage, enforced in CI [10]. Those tests ran against a well-behaved endpoint and a config the maintainer wrote [10]. "Coverage measures which lines run. It says nothing about which inputs you assumed were safe," the maintainer wrote [11]. "The missing check wasn't untested; it had never been written." [12]
Version 3.0.0 is a breaking release [13]. The server no longer reads .env from the working directory. An operator names a file in BAGS_ENV_FILE, and a relative path is refused [13]. A stray .env in the working directory gets a notice on stderr saying it is being ignored [13]. A comment in the new loader explains the refusal: a relative path would resolve against the working directory, which is the very thing the loader is meant not to trust [14]. Anyone who kept settings in a project-local .env has to move them to a file at an absolute path.
The login tool now signs only Bags' exact sign-in text, with the nonce from the same init response [15]. It also refuses anything that decodes as a Solana transaction and anything that is not printable text [15]. The transaction check deserializes the bytes as a VersionedMessage. It returns true only when re-serializing reproduces the input exactly [15]. I think the allowlist is the right primary control here. The other two checks are backstops in case the sign-in format ever loosens. The auth endpoint is pinned to https on bags.fm unless the operator sets BAGS_ALLOW_CUSTOM_API_URL=true, and the model can no longer choose the keypair path [16].
The disclosure was handled well. The fix was built on a temporary private fork and merged from a private GitHub security advisory [17]. Version 3.0.0 shipped through the normal pipeline with npm provenance [17]. Every release from 1.0.0 through 2.6.0 was deprecated on npm with a message pointing to the advisory, GHSA-g679-3wq7-mh3m [17]. Private vulnerability reporting is now switched on [17]. There was one trap. Merging from the advisory page squashed the fork into a single commit titled "Merge commit from fork." [18] That title is not a conventional commit, so release-please computed a patch version for a breaking change [18]. The version bot trusted its input too. The maintainer wrote that they caught it [18].
What to watch
- Whether GHSA-g679-3wq7-mh3m is updated with evidence that any wallet running 1.0.0 through 2.6.0 actually signed a transaction-shaped challenge.
- Whether other local MCP servers that call dotenv.config() in their launch directory publish similar breaking fixes.
- Whether MCP clients such as Claude Code change which directory they launch local servers in, or what environment they pass them.