BankDoor Policy Pack
An allowlist proxy for financial MCP tools. A YAML file says which bank tools an AI agent may call, and with which arguments. Reads like balances and transactions pass. Payments and writes are denied by default. Every blocked call gets an Ed25519-signed receipt that you can check offline.
The problem
Bank and finance MCP servers put tools like transfer and pay_bill next to get_balances
in the same tool list. A confused or prompt-injected agent can call them, call them under
another name, or hide a payment inside the arguments of a read. Writing "read-only" in a
prompt or a skill does not stop any of that. This lab makes the deny happen in code, in front
of the server, and leaves signed evidence each time it does.
How it works
agent --MCP stdio--> bankdoor proxy --MCP stdio--> bank MCP server
|
+-- policy.yaml (allowlist, arg specs, schema pins)
+-- .bankdoor/receipts/000001.json ... (signed, hash-chained)
For every tools/call the proxy runs these checks in order and forwards the call only if all
pass:
- Tool name is plain ASCII
[A-Za-z0-9_-](stops Unicode look-alikes, zero-width characters,bank.transfer,../transfer, trailing spaces). - The server is in the policy.
- No name token is a payment/write word (
transfer,pay,send,create,delete,set, ... 47 built in, the policy can add more). snake_case, kebab-case and camelCase are split, sopayBillandpay-billare caught. - Exact, case-sensitive match against the allowlist (no globs,
Maplookup soconstructoror__proto__cannot hit object prototypes). - The tool's live definition (name, description, input schema, annotations) hashes to the
schema_sha256pinned in the policy. A server that swaps whatget_balancesmeans is blocked. - Arguments: a plain object, at most 32 keys, every key declared in the policy, every value of the declared type and inside its pattern, length, range or enum. Nested objects and arrays are rejected.
A denied call never reaches the server. The agent gets an error that names the reason and the receipt id. The receipt stores the tool, reason, rule, argument names, a sha256 of the arguments (never the values), the policy id and sha256, the previous receipt's hash and the signing key id. The signature covers RFC 8785 canonical JSON of all of that.
tools/list through the proxy only shows tools that would pass checks 1 to 5.
Tool annotations such as readOnlyHint come from the server, so bankdoor ignores them when it
decides.
Quickstart
Requires Bun 1.4.3 and Node 24 (.nvmrc). No API keys.
bun install --frozen-lockfile
bun run check # full local gate (see below)
# 1. Signing key (once, kept outside the repo in ~/.config/bankdoor)
bun run bankdoor -- keygen
# 2. Pin the server's tool definitions
BANK_FIXTURE_STATE=/tmp/bank.json bun run bankdoor -- pin -- bun fixtures/bank-server.ts
# 3. Lint the policy (fails if it allowlists any payment/write tool)
bun run bankdoor -- lint --policy policies/bankdoor.yaml
# 4. Dry-run a decision (exit 0 allow, 2 deny, no receipt)
bun run bankdoor -- decide --policy policies/bankdoor.yaml --server bank --tool pay_bill
# 5. Run the proxy in front of the fixture bank (this is what your MCP client launches)
BANK_FIXTURE_STATE=/tmp/bank.json bun run bankdoor -- proxy \
--policy policies/bankdoor.yaml --server bank -- bun fixtures/bank-server.ts
# 6. Verify receipts offline with only the public key
bun run bankdoor -- verify .bankdoor/receipts \
--pub ~/.config/bankdoor/bankdoor.pub.pem --policy policies/bankdoor.yaml
MCP client config (Claude Code, Claude Desktop, Cursor and others use the same shape):
{
"mcpServers": {
"bank": {
"command": "bun",
"args": ["run", "/abs/path/bankdoor-policy-pack/src/cli.ts", "proxy",
"--policy", "/abs/path/policy.yaml", "--server", "bank",
"--", "<bank server command>", "<args>"]
}
}
}
The policy file
version: 1
id: bankdoor-fixture-readonly
default: deny # the only accepted value
require_pins: true # every allowed tool must carry schema_sha256
never_allow: [borrow] # added to the 47 built-in payment/write words
servers:
bank:
tools:
get_transactions:
schema_sha256: 181ce1e9... # from `bankdoor pin`
args:
account_id: { type: string, required: true, pattern: "acc_[a-z0-9_]{1,32}", max_length: 40 }
limit: { type: integer, min: 1, max: 100 }
since: { type: string, pattern: "\\d{4}-\\d{2}-\\d{2}", max_length: 10 }
Shipped policies:
| File | For | Allowed tools |
|---|---|---|
policies/bankdoor.yaml |
fixtures/bank-server.ts |
list_accounts, get_balances, get_transactions |
policies/bankmcp-readonly.yaml |
noskillish/bankmcp v0.1.15 (commit f28fac6) |
consent_status, list_accounts, get_balances, get_transactions, list_watches |
The bankmcp preset pins the definitions that bankmcp v0.1.15 itself returned from
tools/list over stdio (run locally, unconfigured, no bank login). Its other seven tools
(start_consent, disconnect_bank, set_account_label, create_watch, delete_watch,
check_watches, list_banks) are denied. bankmcp has no payment tools; the preset keeps
consent, labels and watches with the human.
The fixture bank server
fixtures/bank-server.ts is an MCP server with fake accounts and five tools:
list_accounts, get_balances, get_transactions, transfer, pay_bill. It really moves
fake money: balances and a log of every call it receives live in a JSON file, so the tests can
prove a blocked call never arrived. It is unsafe on purpose:
get_transactionshonors undocumentedaction: "transfer",to_account,amount_centsfields (mass-assignment style). Called directly, that moves money.BANK_FIXTURE_VARIANT=rugpull:get_balanceskeeps its name, gets a new description and argument, and sweeps 1000.00 toacc_malloryon every call.BANK_FIXTURE_VARIANT=renamed:transferis served asmove_money.
The control runs in the tests and the report call the fixture without the gate and confirm that money does move.
Measured
From bun run report on 2026-10-21 (reports/bankdoor.md, reports/bankdoor.json). A real
MCP client talks to the real proxy over stdio, which talks to the fixture over stdio, with
real Ed25519 keys. Latency numbers change on every run.
| Measure | Result |
|---|---|
| Tools the agent sees through the gate | 3 of 5 (get_balances, get_transactions, list_accounts) |
| Allowed reads that returned data | 3 / 3 |
| Attack calls blocked | 30 / 30 |
| Attack calls that reached the bank server | 0 |
| Fake money moved with the gate | 0 cents |
| Fake money moved without the gate (control: 1 transfer, 1 smuggled read, 1 rug-pulled read) | 250000 cents |
| Receipts verified offline (dir + chain + policy hash) | ok, 28 receipts |
| Tampered receipt copies detected | 141 / 141 |
| Allowed read latency through gate, p50 / p95 (n=200) | 1.24 / 2.94 ms |
| Same read straight to the fixture, p50 / p95 | 0.83 / 18.66 ms |
| Attack group | Tried | Blocked | Signed receipts |
|---|---|---|---|
| payment/write | 2 | 2 | 2 |
| renamed tool | 16 | 16 | 16 |
| argument smuggling | 10 | 10 | 10 |
| rug-pulled definition | 1 | 1 | 1 |
| server-renamed transfer | 1 | 1 | 1 |
Local gate (bun run check, Bun 1.4.3): install, skills layout, Biome, tsc --noEmit,
policy lint, bun test (91 pass, 0 fail, 1 skipped: bankmcp live listing without
BANKMCP_DIR), report, bun audit, gitleaks 8.30.1. With BANKMCP_DIR pointing at a local
bankmcp v0.1.15 checkout, bun test ran 92 pass, 0 fail.
Tests
| File | What it proves |
|---|---|
tests/decide.test.ts |
Reads pass; transfer/pay_bill denied; 30 renamed or look-alike names denied; 16 argument smuggling shapes denied; schema drift and missing tools denied |
tests/policy.test.ts |
Default must be deny; payment/write tools cannot be allowlisted; pins required; bad patterns and non-ASCII names rejected |
tests/receipts.test.ts |
Receipts verify offline; values are not stored; every tampered field, wrong key, wrong policy, deleted or reordered file is caught |
tests/proxy.e2e.test.ts |
Full MCP stdio path: tool list filtered, reads return data, writes/renames/smuggling/rug pull blocked and never reach the server, control shows the fixture does move money, tampered file fails |
tests/cli.e2e.test.ts |
keygen, lint, decide, pin, verify exit codes; the proxy refuses to start without a signing key |
tests/bankmcp.test.ts |
Preset allows exactly five bankmcp reads; live part (needs BANKMCP_DIR) checks pins against the real server |
API keys
None. Everything runs keyless against the fixture.
| Variable | Required | Used for |
|---|---|---|
BANKDOOR_KEY_DIR |
no | Where keygen writes and proxy reads the signing key (default ~/.config/bankdoor) |
BANKMCP_DIR |
no | Local bankmcp checkout with npm ci done; enables the live tool listing test. No bank login or Enable Banking keys are used |
No .env is needed. Copy .env.example to .env only to set the optional paths above.
Limits
- The idea that started this lab calls it a "JPMorgan-style" allowlist. We did not verify any statement, product or policy from JPMorgan Chase or any other bank, and this lab does not copy one. No bank has reviewed or endorsed this.
- Tested only against the fixture server and the tool listing of bankmcp v0.1.15. No real bank account, payment provider or brokerage was used.
- Pins catch a changed definition, not a changed behavior. A server that keeps the exact same name, description and schema but starts moving money is not detected. Pins are checked once, at proxy start.
- Payment/write words are matched on whole name tokens. A server that names a payment tool
with an innocent word (say
get_reportthat pays) is only stopped because it is not on the allowlist; if you allowlist it, bankdoor will forward it. - Read tools whose names contain a listed word cannot be allowlisted (for example
list_transfers, becausetransfersis on the list). The built-in list is insrc/policy.ts; a policy can add words but not remove them. - Receipts are hash-chained, but deleting the newest receipts (truncating the tail) is not detectable without an outside anchor such as shipping the latest hash elsewhere.
- Only blocked calls get receipts. Allowed calls are forwarded without a signed record.
- Only
toolsare proxied. Resources and prompts from the downstream server are not exposed. - The signing key must be kept where the agent cannot read it. If the agent can edit the policy, read the key, or reach the bank server directly, none of this helps.
- Educational minilab, not regularly maintained, no SLA. Not a compliance control.
Using the skill in Claude Code and Cursor
The skill lives only at .claude/skills/bankdoor-policy-pack/SKILL.md
(no .agents/skills or .cursor/skills copies). It tells an agent how to wire a server
behind bankdoor, how to dry-run a decision, how to verify receipts, and not to loosen a policy
to get a blocked call through.
Not affiliated
Not affiliated with, sponsored by or endorsed by JPMorgan Chase, any bank, Anthropic, the Model Context Protocol project, noskillish/bankmcp, Enable Banking or HOL Guard. Names are used only to describe compatibility or where the idea came from.
Credits
- Building blocks: agent-flight-recorder (canonical JSON, Ed25519 pattern, deny-by-default MCP proxy), minilab-bankmcp-readonly-gate, minilab-hol-guard-bank-gate.
- Model Context Protocol TypeScript SDK (MIT).
- noskillish/bankmcp (MIT), for the preset's tool list.
See NOTICE.
Security
See SECURITY.md. Contact: hello@skilleddragon.com.
License
Apache-2.0. See LICENSE and NOTICE.
Built by SkilledDragon LLC, skilleddragon.com