Going around BONDED is possible. Going around it unnoticed is not.
An AI agent trading a Binance account gets no credential of its own — only a mandate. Every order that reaches the exchange is reconciled against what BONDED actually authorised, so an order it never approved is detected, attributed, and burns the bond.
Built for the Binance Agent OS Mini Hackathon, Track A (Agent Workflows → Trading Workflows).
Binance ships two ways for an AI agent to trade your account, and they have very different security postures:
| Path | Guarded? |
|---|---|
MCP Server (agent.binance.com/mcp/agentic) |
Yes — dedicated Agentic sub-account, withdrawal scope never available, every trade confirmed by you first |
Skills Hub / binance-cli |
No — takes raw BINANCE_API_KEY / BINANCE_SECRET_KEY, no confirmation step |
Binance guarded the path where a human approves every trade. It did not guard the path where an agent holds your API keys directly — and that is the path every agent framework actually uses, because it is the one that runs unattended.
BONDED guards that one.
Every surface below is one Agent OS ships. What is real, and checkable:
| Agent OS surface | How BONDED uses it |
|---|---|
| Skills Hub | BONDED is packaged as an Agent OS skill — SKILL.md at the repository root, with the submission prepared in contrib/binance-skills-hub/ |
@binance/binance-cli |
A runtime dependency, in package.json. Reference prices can be read through Binance's own CLI: set BONDED_PRICE_SOURCE=binance-cli |
| MCP | BONDED is an MCP server, over stdio, so any MCP client can hold it |
| Binance MCP server | Runs alongside it — Binance's server for market data, BONDED for orders. See Connecting an agent |
And what it deliberately does not do: orders are signed by BONDED itself, against Spot
REST, not shelled out to the CLI. The gate has to control the exact query string it signs
and stamp an unforgeable clientOrderId on every order, so putting a process boundary in
the signing path would cost the guarantee and buy nothing. That reasoning is about the
write path; reads have no such constraint, which is why the price source can be the CLI.
One honest note on Binance's hosted MCP server, recorded so nobody repeats the
experiment: it answers 401 to an unauthenticated initialize, so the "market data needs
no auth" scope still sits behind an interactive OAuth consent, and it has no testnet. An
unattended server cannot complete a browser consent flow, so BONDED composes with it at
the agent's level — both servers registered side by side — rather than calling it.
- Holds the credential. The agent talks to BONDED's MCP server, never to Binance. It has no key of its own.
- Gates every order against a mandate it cannot read — symbol allowlist, order types, sides, max notional, max open orders, daily loss limit, drawdown, trading window, expiry. Denials are sub-millisecond and name the exact clause that fired.
- Reconciles what actually happened. Binance's own order history reports every order on the account — including ones BONDED never authorised. An unexplained order is a bypass: the bond burns and trade scope is revoked.
Point 3 is the one nobody else has built. A gate is blind to anything that goes around it, so BONDED keeps a second, independent account of reality and treats disagreement as the finding.
Five outcomes, and the differences matter:
| Outcome | Meaning |
|---|---|
AUTHORISED |
An order BONDED allowed, executed exactly as authorised |
MISMATCHED |
Authorised — but the order that executed is not the one authorised |
FOREIGN |
No BONDED identifier at all. A plain bypass |
FORGED |
Wears BONDED's namespace without a valid tag — worse than foreign |
UNKNOWN_AUTHENTIC |
Tag verifies, no matching record. A log-integrity problem |
Anything but the first burns the bond and revokes trade scope. Every finding ships with a plain explanation, the verbatim evidence, a Binance order id anyone can check independently, and an explicit list of what could not be determined.
The full sequence has run against live Binance Spot Testnet — allow, refuse, bypass, detect, revoke — not only against a stub.
From the run on 2026-09-07:
| What was attempted | What BONDED did |
|---|---|
| Agent asks for 900 USD of ETH | DENIED maxNotionalUsd, observed 900, limit 500 |
| Agent asks for 450 USD of ETH | check_order → WOULD_ALLOW, then PLACED / FILLED — order 9436212, 0.1802 ETH for 449.909 USDT, stamped bnd_8b80eb8e_1_6ebccfa57d02 |
| Restart | Chain verified over 2 records; authorisation index rebuilt from the log, so BONDED's own fill was not misread as foreign |
| Client id forged into BONDED's namespace | Filled as order 13078024, then classified FORGED — bond burned |
| Raw API order, BONDED nowhere in the path | Filled as order 13078025, then classified FOREIGN — bond burned |
| Any order after the burn | DENIED scope |
npm run redteam drives eight attacks over MCP and reports 8 of 8 held, each naming
the clause that refused it with the value it observed. The two bypass rows print their
Binance order ids, which are checkable independently of this project, and the run prints
the decision log's head hash so a third party can verify it with
bonded verify --head.
| Component | State |
|---|---|
Mandate compiler, content addressing, exchangeInfo grounding |
done |
| Pre-trade gate — 17 clauses, pure, fail-closed | done |
| Realised PnL from trade history — the figure the loss limits bind on | done |
| Hash-chained decision log with tamper detection | done |
| Binance Spot REST client — signing, timeouts, bounded retries | done |
MCP server — place_order, check_order, cancel_order, get_mandate_summary, get_account |
done |
| Boot guards + startup banner | done |
| Reconciler — authorisation index, classification, bond burn | done |
| Order sources — polling backstop | done |
| Order sources — user data stream | blocked upstream: Binance removed the listen-key endpoints (410); the listenToken replacement is not implemented |
| Owner console — one screen, live over SSE | done |
| Public API entry point, CI, process-level failure handling | done |
338 tests passing (unit, property, integration); typecheck, lint and format clean in CI.
One screen at http://127.0.0.1:7391, pushed over SSE. The bond state is the largest
thing on it and changes colour, so it reads on mute. TESTNET is permanently visible.
Its security posture is deliberate and tested: loopback only (never 0.0.0.0 — the
payload includes balances and order history), read-only (every method but GET is
refused, so a compromised browser tab cannot become a trading capability), and no
credential ever reaches the view model.
One difference from the agent's view: the console does show the mandate's thresholds.
The owner wrote them; hiding them would be theatre. get_mandate_summary still omits
them, because an agent that can read its limits can sit exactly inside them.
Every one of these is a real weakness. They are here rather than buried because a security design that does not name its own limits has not been examined, and because each is something a careful reader would find in ten minutes anyway.
BONDED is not a TEE and not a ZK circuit. It holds the Binance credential and the agent does not, which is what makes the constraint structural rather than advisory — but compromise the machine BONDED runs on and that constraint is gone. On the ladder of enforcement mechanisms this is the middle rung: a signed mandate with a public trace. The rungs above it need attested hardware or a ZK-native chain, and neither was reachable here.
A bypass order reaches the exchange and fills before BONDED sees it. What BONDED
guarantees is that it will not go unnoticed: the order is attributed, the bond burns, and
scope is revoked so nothing further can be placed through the gate. Deterrence plus
attribution, not prevention. Every FOREIGN finding says exactly this in its own
uncertainty list, on screen, in the demo.
The gate can prove an order was inside the mandate. It has no opinion on whether the trade was sensible, and it cannot acquire one. An agent that loses money strictly within its limits is a mandate problem, not a BONDED problem.
Not a limitation worked around — a choice, and it would be the same choice with more time.
The demo places an order that defeats a safety control. That is the whole argument: an order goes around BONDED, fills, and is caught. On mainnet that is real money being deliberately misused on camera, repeatedly, across takes. A security tool whose demonstration requires misusing a funded account has not thought about what it is asking its audience to do.
The code enforces this rather than trusting the operator to remember it. BONDED refuses
to start against production unless BONDED_ALLOW_PROD=1 is set explicitly, and the two
scripts that exist to defeat it — scripts/bypass-order.mjs and scripts/redteam.mjs —
refuse production with no override at all.
It also happens to be the only place the argument can be made. Binance's hosted MCP
server has no testnet: it trades a funded Agentic sub-account on mainnet, over OAuth,
with a human confirming every trade. That path is already guarded, and BONDED has
nothing to add to it. The path BONDED does guard — raw API keys, no confirmation, which
is what an unattended agent runs on — is the one that has a testnet. binance-cli,
Binance's own Agent OS tooling and now a dependency here, supports testnet alongside
prod and demo for exactly this reason.
What testnet costs is listed honestly below: the withdrawal guard cannot run there, and fills are simulated.
BONDED does not implement a withdrawal guard — it verifies the exchange enforces one, which
is stronger, because that guarantee survives BONDED being wrong about everything else. But
GET /sapi/v1/account/apiRestrictions does not exist on Spot Testnet, so on testnet the
guard reports WARN and states that it asserted BINANCE_API_ENV=testnet instead. It does
not report a pass it did not earn.
dailyLossLimitUsd is absolute; maxDrawdownPct is the same loss as a percentage of the
account's quote-asset balance. Whichever is tighter fires first. Both compare against
realised PnL computed with an average cost basis from Binance's own trade history —
the definition is stated in full in src/domain/pnl.ts, because an undefined risk metric
is worse than none.
Positions opened before the seven-day basis window have no known cost here. Selling one realises an unknown amount, so that quantity is excluded from the figure and reported rather than guessed at. The limits therefore understate activity in that case; they never overstate it.
A mandate that compiles to weaker rules than the owner intended fails silently — nothing downstream can detect the difference between "permitted" and "permitted by mistake". The mitigations are human: clauses render as numbered text before anything runs, and an unrecognised clause name is a hard error rather than an ignored key.
Testnet liquidity does not resemble production. No PnL figure produced here means anything, and none is presented as if it does.
GET /api/v3/allOrders requires a symbol — there is no account-wide REST listing — so the
polling backstop only ever sees the symbols it was given. The user data stream is the
only account-wide source. With it down, an order on a symbol the mandate never named
cannot be observed at all.
BONDED treats that as a loss of the audit path rather than a degraded one: without an
account-wide source, trading stops. BONDED_ALLOW_PARTIAL_AUDIT=1 accepts symbol-scoped
coverage and keeps trading, and the boot banner says which of the two is in force.
BONDED_WATCH_SYMBOLS widens the poller beyond the mandate's own symbols.
As of 2026-09-06 that is not a hypothetical. Binance removed the listen-key REST
endpoints in February 2026: POST /api/v3/userDataStream now answers 410 Gone on
Spot Testnet, verified on a real machine. The account-wide source is therefore
unavailable, and BONDED refuses to start unless BONDED_ALLOW_PARTIAL_AUDIT=1 says
symbol-scoped coverage is acceptable — which is the guard behaving exactly as designed,
refusing to trade with less observation than it claims.
Migrating to the replacement endpoint is the first item on the roadmap. Until then, run with:
BONDED_ALLOW_PARTIAL_AUDIT=1
BONDED_POLL_INTERVAL_MS=5000
Detection then comes from polling the mandate's symbols every five seconds instead of from the stream — slower, and blind to symbols outside the mandate, which is precisely what the WARN on the banner says.
A mid-session stream loss that still leaves the poller healthy degrades detection from near-instant to one polling interval, for the symbols the poller covers.
The order path is serialised, and orders placed since the current account observation
count toward maxOpenOrders, so a burst cannot slip several orders through one stale
count. That closes the concurrency hole; it does not make the caps instantaneous.
dailyLossLimitUsd and maxDrawdownPct compare against realised PnL derived from
trade history that is refreshed on a 30-second budget. A loss is only realised when a
position closes, and BONDED learns of it when the snapshot refreshes. So a fast sequence
of losing trades can breach a loss limit and keep trading until the next refresh sees it.
This is inherent to enforcing a limit against an exchange that fills orders without
asking, and it is a lag, not a hole: the cap binds as soon as the loss is observable.
Positions that stay open are not counted at all — that is what "realised" means.
Short list, and it is worth being precise about what is on it.
Never run: anything on mainnet, deliberately — see above. The withdrawalPermission
guard, because apiRestrictions does not exist on Spot Testnet. The dailyLossLimitUsd
and maxDrawdownPct clauses have never denied an order in anger: they are covered by
unit and property tests, but no live run has yet lost enough money to trip them.
Multi-day operation, and the listen-token replacement for the removed stream.
Also worth stating: testnet fills are simulated. An order that fills there tells you the request was well-formed and accepted, not that it would have found a counterparty on a real book.
Three pieces, in the order they are worth building. Each is scoped work rather than an aspiration, so the reason it is not done yet is stated with it.
Binance removed the listen-key REST endpoints in February 2026, so the account-wide
stream is gone and polling is currently the only order source. Migrating to
POST /sapi/v1/userListenToken plus userDataStream.subscribe.listenToken brings
detection back from one poll interval to near-instant, and restores coverage of symbols
outside the mandate — which polling structurally cannot see, because allOrders requires
a symbol.
Today every endpoint is /api/v3/*. There is no fapi, no dapi and no margin surface,
and a mandate cannot express a leverage cap or a position side because BONDED has no
concept of either.
Extending it is not a configuration change, and the clause list is the smallest part:
- Different clauses, not more of them — leverage, liquidation distance, funding, reduce-only, position side.
- Drawdown binds on the wrong number.
maxDrawdownPctis computed from realised PnL out of trade history. On a leveraged position the figure that matters is unrealised, which means position tracking, mark price and funding. - Reconciliation needs a sixth outcome. Every Spot position change has an order behind it. A liquidation moves a position with no order to match, so it fits none of the five outcomes — a new category of event, not a new clause.
Spot is also the right venue for the demonstration: it places an order that deliberately defeats a safety control, and doing that on a leveraged position adds risk that teaches a viewer nothing about the mechanism.
Signing inside an enclave or against an HSM, so compromising the host no longer compromises the credential. This raises the rung on the ladder rather than removing the ladder — trust moves to attestation instead of to the machine — but it is the difference between a guarantee that survives a compromised host and one that does not.
| Document | Contents |
|---|---|
| PLAN.md | Scope, milestones, cut list, demo script, submission checklist |
| ARCHITECTURE.md | Three planes, enforcement layers, data flows, invariants |
| MEMORY.md | Verified research, decisions and why, dead ends, open questions |
| SKILL.md | Binance Skills Hub package — tool contract, and how an agent should behave when denied |
| DEMO.md | Recording runbook: setup, window layout, the four beats, and how to reset between takes |
| site/ | Landing page and documentation site. See Deploying the site |
The demo shows a bypass being caught. You have no reason to believe it — every frame could be staged. So the log is checkable. A sample log ships in the repository, written by the real writer, so the verifier can be tried offline with no keys and no account:
node dist/cli.js verify examples/sample-decisions.jsonlHolding nothing but the file, that verifies the hash chain from genesis, that sequence numbers are contiguous, that every record cites the same mandate hash — and prints the exchange order ids so you can check them against Binance yourself.
Edit a record in the middle, or delete one, and it fails:
NOT VERIFIED: decision log hash chain is broken
{"lineNumber":3,"seq":2,"expectedPrevHash":"287d…","actualPrevHash":"3563…"}
The chain hash is unkeyed, so anyone can compute it. Walking the chain pins every record
against the one after it — which leaves the last record pinned by nothing. Editing
the final record, or appending a new one with a correctly computed prevHash, therefore
survives a chain walk. Both are precisely what faking a demo would look like, so the
command reports them as unchecked rather than passing them off as verified.
Closing it needs no secret, only a commitment made in advance. The head hash commits to the entire history: publish it — say it on camera, put it in a README — and pass it back:
node dist/cli.js verify examples/sample-decisions.jsonl \
--head 54aaa41cc363c850f66d81d583dbe0dbaa15587c00679e65369890b644669ee8That is the head of the sample log above, published here so the check means something. Change any byte of that file, tail included, and the command exits non-zero.
It also states what it did not check. Whether each stamped client order id is
authentic needs the HMAC secret, which is the operator's; pass --secret if you hold it.
An authentic tag covers the mandate hash and the sequence number — not the order's
symbol, side, quantity or price. And a log is a claim about what BONDED authorised,
never proof of what the exchange did.
npm run smokeStarts BONDED, connects over MCP exactly as an agent does, and asks it questions that
change nothing: the tool list, the mandate summary, and three orders run through
check_order — which evaluates the whole gate and returns the verdict without
placing an order or consuming an audit-log entry.
Run it before a take, or after changing a mandate. It is repeatable by construction,
which npm run redteam is not.
npm run redteamRuns a set of documented attacks against a live instance on Spot Testnet and prints what happened to each: oversized orders, symbols outside the allowlist, sub-lot quantities, a client id forged into BONDED's namespace, and a raw API order with no BONDED in the path at all. The last one fills — that is the point — and then the reconciler finds it, the bond burns, and every later order is refused.
It is self-contained: it starts its own BONDED, connects over MCP the way an agent
does, and drives every attack itself. Nothing to set up first, nothing to type during a
take. It prints the decision log's head at the end — publish that, and anyone can check
the run with verify --head.
Testnet only, with no override, for the same reason as scripts/bypass-order.mjs.
Stop any BONDED you already have running first. The bypass is a real order on the real account, and a separate instance is watching that same account — so it will detect the order and burn its own bond, which is the correct behaviour and ruins a take if you are mid-recording. Separate log files cannot prevent this: reconciling against the shared account is the whole mechanism.
site/ is three static pages — landing, docs and 404 — with one stylesheet and one script.
No framework, no bundler. Open site/index.html directly, or serve the directory.
It deploys to Vercel from the repository root, with no configuration in the dashboard:
npx vercel # preview
npx vercel --prod # productionvercel.json sets everything — no install step (the site has no dependencies), a build that
is only node scripts/build-site.mjs, and dist-site/ as the output.
The build exists for one reason. Open Graph requires an absolute og:image, and a
hardcoded one points at the wrong host the moment the project is renamed or a domain is
added. So the source keeps a __SITE_URL__ placeholder and the build resolves it from
VERCEL_PROJECT_PRODUCTION_URL, falling back to SITE_URL if you set it explicitly. The
build fails if it finds no placeholder, rather than shipping a card that points nowhere.
Also configured there: cleanUrls (so the docs page is /docs), and response headers —
a Content-Security-Policy with no unsafe-inline (which is why the docs script lives in
site/docs.js and not in a <script> tag), nosniff, Referrer-Policy,
Permissions-Policy and HSTS.
site/og.png is generated from scripts/og-card.html; regenerate
it by screenshotting that file at 1200×630.
Runs entirely on Binance Spot Testnet — no capital required.
export BINANCE_API_ENV=testnet
export BINANCE_SPOT_BASE_PATH=https://testnet.binance.visionTestnet API keys: https://testnet.binance.vision/
To read reference prices through Binance's own Agent OS CLI instead of BONDED's REST client — same variables, same testnet, no extra credential:
export BONDED_PRICE_SOURCE=binance-cliThe boot log names whichever source is live, so it is never a guess. Either way it fails closed: an unavailable price denies the order rather than sizing it against a guess.
Node 22 or later. npm install prints EBADENGINE warnings on Node below 22.13:
they come from inquirer, which @binance/binance-cli depends on for its interactive
mode. They are warnings, not errors — BONDED runs fine, and the default price source does
not touch the CLI at all. Upgrade to 22.13+ only before setting
BONDED_PRICE_SOURCE=binance-cli.
macOS and Linux:
npm install
npm run build
cp .env.example .env # then fill in testnet key, secret, and:
openssl rand -hex 32 # -> BONDED_HMAC_SECRET
mkdir -p data
cp examples/mandate.example.json data/mandate.json
npm startWindows PowerShell. Not a translation of the above so much as a different set of commands:
mkdir takes no -p, there is no openssl, and cp is Copy-Item, which copies files
rather than setting variables.
npm install
npm run build
Copy-Item .env.example .env
notepad .env # paste the two keys and the secret below, then save
# Generates BONDED_HMAC_SECRET, since Windows has no openssl:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
New-Item -ItemType Directory -Force data | Out-Null
Copy-Item examples/mandate.example.json data/mandate.json
npm start.env is a file you edit, not a command you run. Open it and fill in three lines:
BINANCE_API_KEY=<from testnet.binance.vision>
BINANCE_SECRET_KEY=<from testnet.binance.vision>
BONDED_HMAC_SECRET=<the 64 hex characters generated above>
Startup prints a guard banner to stderr (stdout is the MCP channel) and refuses to run if anything fails:
BONDED boot guards
[PASS] environment testnet confirmed, base URL https://testnet.binance.vision
[PASS] mandate mandate 8b80eb8e valid until 2027-09-08T23:59:00.000Z
[WARN] withdrawalPermission not verified: apiRestrictions is unavailable on Spot Testnet.
Asserted BINANCE_API_ENV=testnet instead
[PASS] decisionLog no existing log; starting at genesis
[PASS] clockSkew clock within 42 ms of exchange
[PASS] symbolGrounding 2 symbols resolved from exchangeInfo
[PASS] authorisationIndex 0 prior authorisations replayed
[PASS] auditPath order history reconciled every 15000 ms
The WARN is deliberate. That check cannot be performed on testnet, so the guard says
what it actually verified instead of reporting a pass it did not earn.
To connect an agent:
claude mcp add bonded -- node /path/to/bonded/dist/cli.jsBONDED does not replace Binance's own MCP server; it runs alongside it. Register both and the agent reads from Binance and writes through the mandate:
claude mcp add binance-mcp-server --transport http https://agent.binance.com/mcp/agentic
claude mcp add bonded -- node /path/to/bonded/dist/cli.jsGrant Binance's server the Market data scope and withhold Trade. Market data is public and needs no credentials; every order the agent then decides to place goes through BONDED. The division is the thesis: Binance's MCP path trades an Agentic sub-account over OAuth with every trade confirmed by a human and no withdrawal scope in existence — already guarded, nothing for BONDED to add. BONDED guards the other path, the one an unattended agent actually runs on.
To preview the console UI without credentials — synthetic data, not a demo of the
system — run node scripts/console-preview.mjs. The real demo is
tests/integration/bypass-detection.test.ts.
npm run check # typecheck + lint + tests
npm test # 338 tests, ~2sMIT
