BagOS signs and submits Solana transactions on your behalf. This document describes what it can do, what it cannot, and how it handles keys.
The server runs locally, as a subprocess of your MCP client. The realistic threats are:
- A model is convinced to spend more than you intended. Prompt injection through token names, descriptions, or web content the model has read.
- Key material escaping into logs, error messages, or model context.
- Wrong-network execution — believing you are on devnet while signing against mainnet.
- A transaction reported as successful that never landed.
Every control below exists for one of those four.
- Read public Solana and Bags data (analytics, quotes, claimable positions)
- Sign and submit swaps and fee claims with the keypair you configure
- Create token metadata via the Bags API
- Launch a token.
bags_prepare_token_metadatacreates metadata only. A real launch needs a Meteora fee-share config whose fee-claimer split must be your decision, so it is deliberately unimplemented rather than approximated. - Move funds without passing the token gate, the spend caps, and the confirmation step
- Export, print, or transmit your private key
| Property | Behaviour |
|---|---|
| Source | A JSON byte-array file at BAGS_KEYPAIR_PATH. Never an env var. |
| Scope | Read once per tool call, used to sign, never persisted or cached |
| Logging | Never logged. The server writes no key material to stdout or stderr. |
| Errors | JSON.parse failures on the keypair file are caught and replaced with a generic message — parser errors can quote file contents. |
| Model exposure | toolError() returns error.message only, never a stack trace, and redacts keypair-shaped byte arrays, long base58 strings, and long base64 blobs. |
| RPC credentials | The startup report prints the RPC endpoint with every query parameter and any basic-auth credentials replaced by REDACTED. |
If you believe a key has been exposed, move the funds and generate a new keypair. This server has no way to rotate one for you.
Every write goes through the same sequence. There is no code path that skips it.
token gate → spend caps → confirmation → simulate → sign → send → confirm
Token gate. Write tools require the configured BOS_TOKEN_MINT balance to
meet BOS_REQUIRED_BALANCE.
Spend caps. Two independent limits, both in SOL:
| Variable | Default | Scope |
|---|---|---|
BAGS_MAX_SOL_PER_TX |
0.1 |
One transaction |
BAGS_MAX_SOL_PER_SESSION |
1.0 |
One server process |
Caps are checked before the SDK is called, so an over-cap request never reaches the network. The amount is reserved against the session cap in the same step it is checked, so two concurrent writes cannot both pass a cap only one of them fits under. The reservation is kept if the transaction confirms, and also if its outcome is unknown (see fails closed under Known limitations), because it may still land. It is released — and the budget returned — only when the spend provably did not happen: a failed build, a failed simulation, a send the node refused before forwarding, or an on-chain failure.
Confirmation. On by default. The first call to a write tool returns a preview and a single-use token; nothing is signed. The token is a SHA-256 fingerprint of the tool name plus the exact arguments, so a token issued for a 0.01 SOL swap cannot authorize a 10 SOL one. Tokens expire after 5 minutes and are consumed on every outcome, including failure, so they cannot be replayed.
Set BAGS_ALLOW_UNCONFIRMED=true to disable the preview step. Caps still
apply — this weakens one control, not all of them.
Simulation. Every transaction is simulated before signing. A failed simulation aborts the write; nothing is submitted.
The simulation also has to show what the transaction costs. BagOS reads the wallet's SOL balance, asks the simulation for the balance afterwards, and refuses to sign if the wallet would lose more than the amount reserved against the caps plus 0.01 SOL for fees and account rent. A fee claim reserves nothing, so it may cost fees only. A simulation that does not report the balance is refused too. Before this check, the caps bound the amount a tool was asked to spend, while the transaction that got signed was built by the Bags API and never compared with it. An outside review found that gap.
Confirmation of landing. The server waits for network confirmation and returns the signature and explorer link. If confirmation fails, it reports the failure and the signature — it never reports success for a transaction that did not land.
This last property is the reason for the 2.0.0 release. In 1.x the write tools built transactions, discarded them, and reported success unconditionally. See CHANGELOG.md.
- Devnet is the default. An unconfigured install cannot touch mainnet.
- Mainnet requires
BAGS_NETWORK=mainnet, and the startup report marks it. - If your RPC endpoint's cluster disagrees with
BAGS_NETWORK, the server refuses to start rather than picking a winner. A "devnet" banner over a mainnet endpoint is how people lose money by accident.
These are real gaps, documented rather than hidden. Adversarial reviews of v2.0.0 and later releases surfaced them; each is a deliberate deferral, not an oversight.
The caps only bind on SOL. They cannot value an arbitrary token, so a
non-SOL-input swap is uncapped. Such swaps are refused unless you set
BAGS_ALLOW_UNCAPPED_TOKEN_SWAPS=true, and the preview then says so. In
v2.0.0-pre this silently displayed "Spend: 0 SOL" and passed every cap.
The simulation check reads SOL, not tokens. It bounds the SOL leaving the
wallet. It does not read token balances, so if the Bags API returned a swap or
claim transaction that also moved SPL tokens the wallet holds, the check would
not see it. Those transactions come from the Bags SDK, whose endpoint is fixed
to public-api-v2.bags.fm, so this is trust in the Bags API itself. The balance
before the transaction is read just before simulating, so SOL arriving in
between makes the outflow look smaller by that amount.
The caps count the amount you approve, not fees. A swap reserves its SOL amount against the per-transaction and session caps. The simulation check then allows that amount plus 0.01 SOL per transaction for fees and account rent, and that allowance is not counted against either cap. A fee claim reserves nothing and may use the allowance once per transaction in its set. So with the default 0.1 SOL cap, a single swap may move up to 0.11 SOL, and ten swaps may move up to 0.1 SOL more than the 1.0 SOL session cap. Real fees are far below the allowance; it is a bound, not a charge.
The simulation check trusts your RPC. The balance before and after comes
from the RPC endpoint you configure. An endpoint that lies about the simulated
balance can make a transaction look cheaper than it is. It only defeats the
check together with a malicious transaction from the Bags API, but use an RPC
you trust. It can only be set in your MCP client's config, never from a .env
in the working directory.
The confirmation token binds arguments, not the quoted price. Confirming
re-runs the quote, so the expect/min figures you approved are not what
executes if the price moved inside the five-minute window. Your slippage
setting bounds execution against the re-fetched quote, not against the
preview you approved: the total move from the previewed price is the market
drift over that window plus slippage on the new quote, so slippage alone does
not bound it. Treat the preview numbers as indicative, not a guarantee, and keep
the window short. The same applies to fee claims, where the transaction set is
re-fetched at confirmation.
The session cap fails closed on an unknown outcome. Each write reserves
its amount against the cap in the same synchronous step as the cap check, so
concurrent calls cannot both fit under a cap only one of them fits under. If
the transaction was sent but confirmation threw (timeout, expired block
height) or the send call failed with anything other than a pre-forward
rejection (preflight simulation failure, signature verification failure,
undecodable transaction), including timeouts, proxy 5xx/429 and RPC internal
errors, the tool reports ConfirmationUnknownError with the signature and
counts the spend, because it may still land. A spend that never left
(build failure, failed simulation, refused send, failed on chain) is released.
Check the signature on an explorer before retrying; restart the server to reset
the counter if it did not land.
Configuration comes from the operator only. The server does not read a
.env from its working directory: MCP clients start stdio servers in the open
project's folder, so that file may belong to a repository you cloned. Name an
env file explicitly with BAGS_ENV_FILE. bags_authenticate takes no keypair
path from the model, only talks to https://*.bags.fm unless
BAGS_ALLOW_CUSTOM_API_URL=true, and signs only Bags' own
wallet-verification text (as published in bagsfm/bags-skill auth.md)
carrying the nonce from the same init response. Anything else is refused: a
Solana transaction message, any binary payload, another service's sign-in
text. A signature over a transaction message is a signed transaction.
HTTP mode is unauthenticated. --http / npm run start:http serves /mcp
on 0.0.0.0 with permissive CORS and no auth — anyone who can reach the port
can invoke the write tools, and all callers share one spend counter and one
token namespace. Do not run HTTP mode on a funded wallet or an untrusted
network. The controls in this document assume the default stdio transport,
running locally as a subprocess of your MCP client.
Cluster detection is substring-based. A testnet endpoint is not recognised
and is taken on trust under whatever BAGS_NETWORK says.
Known transitive audit advisories. npm audit reports high/moderate
advisories in this tree. What can be fixed has been: js-yaml is pinned to
^4.3.2 / ^3.15.2 via overrides. The remainder — chiefly bigint-buffer
(GHSA-3gc7-fjrx-p6mg, no patched version exists), plus toml, jayson and
stream-json — arrives transitively through @bagsfm/bags-sdk and the Meteora /
Solana stack, where the only "fix" npm offers is a breaking downgrade of the SDK
that removes trade and partner functionality. Those are tracked and ratcheted in
.audit-baseline.json (CI fails on any new or critical advisory) rather than
force-fixed. They live in dependencies used to build and sign transactions, not
in a path that parses untrusted YAML/TOML, so reachability is low. Root-project
overrides protect this repository and its CI but do not reach consumers of the
published package; that requires the SDK to unpin its own transitives upstream.
Open a security advisory at https://github.com/edycutjong/bagos/security/advisories/new. Please do not open a public issue for anything affecting key handling or the write path.