PluginWorld
Ba

bankdoor-policy-pack

MCP

An MCP proxy that sits in front of bank tool servers, letting AI agents make read-only calls like balances and transactions while blocking payments, writes, renamed tools and argument smuggling. Each denial produces an Ed25519-signed receipt that can be verified offline.

@SkilledDragonCorp · v0.1.0 · Apache 2.0 · updated today

SECURITY

B

SCORE

68

STARS

▲ 0

PLUG IN

git clone https://github.com/SkilledDragonCorp/bankdoor-policy-pack.git

See the README to configure this MCP server

README

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.

CI License

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:

  1. Tool name is plain ASCII [A-Za-z0-9_-] (stops Unicode look-alikes, zero-width characters, bank.transfer, ../transfer, trailing spaces).
  2. The server is in the policy.
  3. 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, so payBill and pay-bill are caught.
  4. Exact, case-sensitive match against the allowlist (no globs, Map lookup so constructor or __proto__ cannot hit object prototypes).
  5. The tool's live definition (name, description, input schema, annotations) hashes to the schema_sha256 pinned in the policy. A server that swaps what get_balances means is blocked.
  6. 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_transactions honors undocumented action: "transfer", to_account, amount_cents fields (mass-assignment style). Called directly, that moves money.
  • BANK_FIXTURE_VARIANT=rugpull: get_balances keeps its name, gets a new description and argument, and sweeps 1000.00 to acc_mallory on every call.
  • BANK_FIXTURE_VARIANT=renamed: transfer is served as move_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_report that 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, because transfers is on the list). The built-in list is in src/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 tools are 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

See NOTICE.

Security

See SECURITY.md. Contact: hello@skilleddragon.com.

License

Apache-2.0. See LICENSE and NOTICE.

Built by SkilledDragon LLC, skilleddragon.com

SIMILAR PLUGINS