Skip to main content
Glama
README.md
# CajaGuard

A local spend gate for shopping agents. CajaGuard is an MCP proxy that sits in front of a commerce MCP server speaking the [Universal Commerce Protocol](https://github.com/Universal-Commerce-Protocol/ucp) (UCP) shopping tools, the same tool names Shopify documents for its Cart, Checkout and Global Catalog MCP servers. Every tool passes through, except `complete_checkout`. That one is checked against per-currency spend caps, a rolling window, a merchant allowlist and, above a threshold, a signed, single-use, expiring human approval. In test mode it also refuses anything that looks like a real card number or a live token before the shop sees it.

[![CI](https://github.com/SkilledDragonCorp/cajaguard/actions/workflows/ci.yml/badge.svg)](https://github.com/SkilledDragonCorp/cajaguard/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](./LICENSE)

## The problem

Shopping MCP servers can now create checkouts and place orders, so an agent that can browse can also pay. Teams wiring this up need proof, before any live payment credential is in play, that a human signs off above a limit, that splitting a basket does not get around it, and that a test setup never carries a real card. CajaGuard is a small, local, keyless way to get that proof against a fixture shop with the real tool shapes.

## What the real tools look like (verified 2026-10-21)

| Source | What it says | URL |
| --- | --- | --- |
| UCP spec, tag `v2026-08-25` (commit `cd78fb3`, Apache-2.0) | MCP methods `create_checkout(meta, checkout)`, `get_checkout(meta, id)`, `update_checkout(meta, id, checkout)`, `complete_checkout(meta, id, checkout)`, `cancel_checkout`, `create_cart`/`get_cart`/`update_cart`/`cancel_cart`, `search_catalog`, `lookup_catalog`, `get_product`, `get_order`. `complete_checkout` requires `meta["ucp-agent"]` and `meta["idempotency-key"]`. | [mcp.openrpc.json](https://github.com/Universal-Commerce-Protocol/ucp/blob/v2026-08-25/source/services/shopping/mcp.openrpc.json) (vendored in `spec/ucp/`) |
| UCP `checkout.json` | `currency`, `totals`, `status` and item prices are business-owned (`ucp_request: "omit"`); `payment` is required on complete. Status `ready_for_complete` before completion. | [checkout.json](https://github.com/Universal-Commerce-Protocol/ucp/blob/v2026-08-25/source/schemas/shopping/checkout.json) (vendored) |
| UCP `amount.json` | Amounts are integers in ISO 4217 minor units (exponent 2 for USD, 0 for JPY, 3 for KWD). | [amount.json](https://github.com/Universal-Commerce-Protocol/ucp/blob/v2026-08-25/source/schemas/common/types/amount.json) |
| UCP credentials | `payment.instruments[]` items have `id`, `handler_id`, `type`, `credential`; credential types include `token` (`{type, token}`), `pan` and `network_token`. UCP's checkout docs show Shop Pay as handler `com.shopify.shop_pay` with instrument type `shop_pay`. | [token_credential.json](https://github.com/Universal-Commerce-Protocol/ucp/blob/v2026-08-25/source/schemas/common/types/token_credential.json), [checkout REST doc](https://github.com/Universal-Commerce-Protocol/ucp/blob/v2026-08-25/docs/specification/shopping/checkout/rest.md) |
| Shopify Checkout MCP | `POST https://{shop-domain}/api/ucp/mcp`, Bearer token auth, tools `create_checkout`, `get_checkout`, `update_checkout`, `complete_checkout`, `cancel_checkout`. On `complete_checkout`: "Use this tool when the checkout is ready and the buyer has authorized payment." | [shopify.dev Checkout MCP](https://shopify.dev/docs/agents/carts-and-checkout/checkout-mcp) |
| Shopify Cart MCP | Same endpoint, tools `create_cart`, `get_cart`, `update_cart`, `cancel_cart`. | [shopify.dev Cart MCP](https://shopify.dev/docs/agents/carts-and-checkout/cart-mcp) |
| Shopify Global Catalog MCP | `https://catalog.shopify.com/api/ucp/mcp`, tools `search_catalog`, `lookup_catalog`, `get_product`. Read-only; returns checkout links. | [shopify.dev Catalog MCP](https://shopify.dev/docs/agents/catalog/catalog-mcp) |
| Shopify Storefront MCP | The older cart and catalog tools on `https://{shop}/api/mcp` were removed in favour of `/api/ucp/mcp`. | [shopify.dev Storefront MCP](https://shopify.dev/docs/apps/build/storefront-mcp/servers/storefront) |

So `complete_checkout` is the one call that uses a payment credential and places an order. That is what CajaGuard gates.

## How it works

```mermaid
flowchart LR
  agent[Agent] -->|UCP tools| cg[cajaguard mcp]
  cg -->|search / cart / create / update checkout| shop[commerce MCP server<br/>fixture in this repo]
  cg -->|complete_checkout| scan{test mode:<br/>real-looking credential?}
  scan -->|yes| deny[denied]
  scan -->|no| read[get_checkout from the shop<br/>business-owned total + currency]
  read --> rules{units, merchant, currency,<br/>per-transaction cap, rolling window}
  rules -->|fail| deny
  rules -->|over threshold or live mode| sig{valid signed approval<br/>for this exact payment?}
  rules -->|under threshold| shop
  sig -->|yes, once| shop
  sig -->|no| blocked[blocked + request id]
  human[Human] -->|cajaguard approve id| file[(Ed25519 approval)]
  file --> sig
```

1. **Read, don't trust.** On `complete_checkout`, CajaGuard calls the shop's own `get_checkout` and takes the total, currency, status and merchant from it. If the agent sends `totals`, `currency` or `line_items` on complete, the call is refused, because UCP marks those fields omit.
2. **Units.** Every amount must be a safe non-negative integer in minor units. A shop that reports `49.99` instead of `4999` is refused (fail closed). Caps are configured as strings in major units (`"100.00"`) and converted with the ISO 4217 exponent; `"50.005"` USD is a config error. A currency without caps is refused and never converted.
3. **Caps.** A per-transaction cap and a rolling-window cap per currency are hard limits that no approval can lift. Completed spend, including approved spend, counts toward the window, so four USD 45.00 checkouts cannot get under a USD 50.00 threshold and past a USD 150.00 window.
4. **Merchant allowlist.** The merchant is the host of the shop-authored `continue_url` (else the first `links[]` URL).
5. **Approval.** Above `approval_above` (or always, in `mode: "live"`), the gate writes a pending request whose id comes from a SHA-256 digest of merchant, checkout id, currency, total, line items and payment handlers. A human runs `cajaguard approve <id>`. The CLI re-reads the checkout from the shop, refuses if it changed, shows it, asks `y/N` and writes an Ed25519-signed approval with an expiry. The gate accepts it once: the approval is spent before the payment call, so a decline cannot be replayed.
6. **Test-mode tripwire.** In `mode: "test"`, every tool call's arguments are scanned. Instruments must use an allowlisted test handler, tokens must match the test pattern, raw `pan`/`network_token`/`card` credentials are refused, Luhn-valid card numbers anywhere (including free text) are refused, and secret-like fields with a `live` segment are refused. None of it reaches the shop.

## Quickstart (keyless demo)

Needs Bun 1.4.3 (Node 24 in `.nvmrc`). Everything runs against the fixture shop in `fixtures/`, whose merchants use the reserved `.example` domain.

```bash
bun install --frozen-lockfile
bun run cajaguard keygen                                   # approver key in ~/.config/cajaguard (0600)
bun run demo buy "Trail socks" 2                           # USD 24.00: allowed, order placed
bun run demo buy "Day pack"                                # USD 89.00: blocked, prints cg-<id> and the checkout id
bun run cajaguard approve cg-<id> --config examples/cajaguard.demo.json   # re-reads the checkout, asks y/N, signs
bun run demo pay gid://fixture/Checkout/<n>                # allow_approved, order placed
bun run demo pay gid://fixture/Checkout/<n>                # denied: checkout already completed
bun run demo buy "Rain shell" --token "4111 1111 1111 1111"   # denied: test_mode_credential
bun run cajaguard status --config examples/cajaguard.demo.json
```

State (requests, approvals, spend ledger, audit log, fixture checkouts) lives in `.cajaguard/`, which is gitignored. Run the full local gate with `bun run check`.

## MCP server

`bun run cajaguard mcp --config examples/cajaguard.demo.json` starts the stdio proxy (client config in `examples/mcp-servers.json`). It spawns the downstream server named in the config and lists its UCP tools plus:

| Tool | Effect |
| --- | --- |
| UCP tools (`search_catalog` ... `get_order`) | Forwarded unchanged (after the test-mode scan) |
| `complete_checkout` | Gated as above. Refusals return `isError: true` with `{ "cajaguard": { verdict, reason, detail, request_id?, next? } }` |
| `cajaguard_status` | Read-only: mode, merchants, caps, window spend, pending requests |
| `cajaguard_approval_status` | Read-only: `pending`, `approved`, `used`, `denied` or `invalid` |

Any other downstream tool is hidden and refused (`tool_not_allowed`). No tool can approve.

Verdicts: `allow` (`within_caps`), `allow_approved` (`signed_approval`), `blocked` (`pending_approval`, `approval_expired`, `approval_untrusted_key`, `approval_bad_signature`, `approval_already_used`, ...), `denied` (`over_per_transaction_cap`, `over_window_cap`, `merchant_not_allowed`, `currency_not_allowed`, `amount_not_minor_units`, `request_field_not_allowed`, `checkout_not_ready`, `test_mode_credential`, `approval_denied`, `bad_request`, `tool_not_allowed`).

## Config

```json
{
  "mode": "test",
  "home": "../.cajaguard",
  "downstream": { "command": "bun", "args": ["fixtures/commerce-server.ts"], "cwd": "..", "env": {} },
  "merchants": ["demo-outfitters.example", "tokyo-tea.example"],
  "caps": {
    "USD": { "approval_above": "50.00", "per_transaction": "100.00", "window": "150.00" },
    "JPY": { "approval_above": "5000", "per_transaction": "12000", "window": "20000" }
  },
  "window_hours": 24,
  "approvals": { "trusted_keys": ["~/.config/cajaguard/approver.pub.pem"], "max_ttl_minutes": 15 },
  "test_mode": { "handler_ids": ["fixture_test_handler"], "token_pattern": "^cg_test_[A-Za-z0-9_]{8,}$" }
}
```

`mode: "live"` drops the test-token rules and requires a signed approval for every `complete_checkout`, whatever the amount. It is tested only against the fixture shop (see Limits).

## CLI

| Command | Exit codes |
| --- | --- |
| `keygen [--out DIR] [--force]` | 0 |
| `mcp --config FILE` | stdio server |
| `status`, `pending`, `show <id>` | 0 / 3 |
| `verify <id>` | 0 approved, 3 pending/invalid/used, 4 denied |
| `approve <id> [--ttl 15m] [--note T]`, `deny <id>` | needs a TTY unless `--yes`; 3 if the checkout changed |

## Measured

Produced by `bun run report` (`scripts/report.ts`) from a real keyless run: real Ed25519 keys and signatures, the real file store, the fixture shop in-process for the scenarios and over stdio for the MCP timing. Source: [reports/cajaguard.md](./reports/cajaguard.md), [reports/cajaguard.json](./reports/cajaguard.json). "Reached shop" means the `complete_checkout` call was forwarded to the shop.

<!-- report:start -->
Generated by `bun run report` on 2026-10-21 (Bun 1.4.3, @modelcontextprotocol/sdk 1.32.1, UCP spec v2026-08-25). Fixture shop only, real Ed25519 keys.

Caps: USD approval above 50.00, per transaction 100.00, window 150.00 / 24h; JPY approval above 5000, per transaction 12000, window 20000 / 24h.

| Scenario | Total | Expected | Verdict | Reason | Reached shop |
| --- | --- | --- | --- | --- | --- |
| 2 x Trail socks, test token | USD 24.00 | allow | allow | within_caps | yes |
| Down parka (over per-transaction cap) | USD 219.00 | denied | denied | over_per_transaction_cap | no |
| Day pack, no approval yet | USD 89.00 | blocked | blocked | pending_approval | no |
| Day pack + trusted signed approval | USD 89.00 | allow_approved | allow_approved | signed_approval | yes |
| next basket after that approval was used | USD 60.00 | blocked | blocked | pending_approval | no |
| approved, shop declines the test token | USD 89.00 | allow_approved | allow_approved | signed_approval | yes |
| retry with the same approval (replay) | USD 89.00 | blocked | blocked | approval_already_used | no |
| approval signed by an untrusted key | USD 89.00 | blocked | blocked | approval_untrusted_key | no |
| real approval with edited expires_at | USD 89.00 | blocked | blocked | approval_bad_signature | no |
| approval 6 min after a 5 min TTL | USD 89.00 | blocked | blocked | approval_expired | no |
| human signed a deny | USD 89.00 | denied | denied | approval_denied | no |
| basket grew 5 -> 8 socks after approval | USD 96.00 | blocked | blocked | pending_approval | no |
| merchant reports 49.99 (major units) | - | denied | denied | amount_not_minor_units | no |
| agent sends totals: 120 for a USD 219.00 basket | - | denied | denied | request_field_not_allowed | no |
| JPY 9800 checkout, only USD caps | JPY 9800 | denied | denied | currency_not_allowed | no |
| JPY 1800 checkout, JPY caps | JPY 1800 | allow | allow | within_caps | yes |
| split purchase 1/4: Rain shell | USD 45.00 | allow | allow | within_caps | yes |
| split purchase 2/4: Rain shell | USD 45.00 | allow | allow | within_caps | yes |
| split purchase 3/4: Rain shell | USD 45.00 | allow | allow | within_caps | yes |
| split purchase 4/4: Rain shell | USD 45.00 | denied | denied | over_window_cap | no |
| merchant not on allowlist | USD 79.00 | denied | denied | merchant_not_allowed | no |
| Shop Pay handler in test mode | - | denied | denied | test_mode_credential | no |
| raw PAN credential in test mode | - | denied | denied | test_mode_credential | no |
| live-looking token in test mode | - | denied | denied | test_mode_credential | no |
| live mode, USD 12.00 | USD 12.00 | blocked | blocked | pending_approval | no |

Scenarios as expected: 25/25.

| Timing | n | p50 ms | p95 ms |
| --- | ---: | ---: | ---: |
| guard complete_checkout, allow (get_checkout + complete + ledger) | 200 | 0.454 | 0.735 |
| guard complete_checkout, blocked pending approval | 200 | 0.423 | 0.726 |
| guard complete_checkout, approval rejected (Ed25519 verify) | 200 | 0.46 | 0.711 |
| guard complete_checkout, test-mode tripwire deny | 200 | 0.125 | 0.194 |
| MCP stdio complete_checkout via proxy -> fixture (over cap), denied 20/20 | 20 | 1.694 | 2.926 |

MCP proxy spawn + initialize + tools/list (also spawns the fixture shop): 321.4 ms.
<!-- report:end -->

## Keys and environment

No API keys. Nothing here talks to a real store, a payment provider or the network; CI runs the same keyless gate. No `.env` is needed (`.env.example` lists the one optional variable).

| Variable | Required | Use |
| --- | --- | --- |
| `CAJAGUARD_KEY_DIR` | no | Approver key directory (default `~/.config/cajaguard`) |

## Limits

- **Fixture shop, not Shopify.** Tool names and argument shapes follow the pinned UCP spec and Shopify's docs, and tests check the fixture's required params against `mcp.openrpc.json`. The fixture's catalog responses are simplified, and its totals only have `subtotal` and `total` (no tax, shipping or discounts). Nothing was run against a real Shopify store, Checkout MCP's Bearer-token auth, or a real payment handler.
- **Shop Pay tokens are not recognized as such.** The idea talks about "live Shop Pay tokens". We could not verify a Shop Pay credential or token format in the UCP spec or Shopify's docs. CajaGuard blocks them indirectly: in test mode only allowlisted test handlers and `cg_test_...` tokens pass, so a `shop_pay` instrument is refused by handler id. In live mode every payment needs a human signature.
- **The tripwire is pattern based.** Luhn card numbers, `live` segments in secret-like fields, raw card credential types and non-matching tokens. A real token with no recognizable shape, sent with an allowlisted test handler id, would pass the scan if it matched your token pattern. Keep the pattern strict.
- **The approver key and `.cajaguard/` are the trust root.** An agent that can read the key, or write the config, spend ledger or `consumed.jsonl`, can get around the gate. See [SECURITY.md](./SECURITY.md).
- **Only calls through the proxy are gated.** An agent with the shop endpoint, a browser or a stored card has other ways to pay.
- **The shop's numbers are trusted.** Price and currency come from `get_checkout`. If a shop charges more than it reported, CajaGuard records the higher total and logs a mismatch after the fact; it cannot undo a charge.
- **Window is per currency, across merchants, local to one machine.** There is no FX conversion and no shared ledger across agents or hosts.
- **Approval is a terminal prompt.** No phone or Slack approval flow, and MCP elicitation is not used since a client could answer it without a human. `--yes` exists for scripted demos.
- **Default caps are examples**, not a recommended policy.

## Credits

- [Universal Commerce Protocol](https://github.com/Universal-Commerce-Protocol/ucp) (Apache-2.0): spec files vendored in `spec/ucp/`
- [Model Context Protocol TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk) (MIT)
- [gethelio/helio](https://github.com/gethelio/helio) (Apache-2.0): the MCP governance proxy with spend budgets and approvals that inspired this lab; not bundled or called
- Building blocks from the SkilledDragon labs `blastconfirm-gate` and `agent-flight-recorder` (see [NOTICE](./NOTICE))

Not affiliated with Shopify, the Universal Commerce Protocol project, Helio, Anthropic or the Model Context Protocol project.

## License

Apache-2.0. See [LICENSE](./LICENSE) and [NOTICE](./NOTICE). Contact: hello@skilleddragon.com.

Built by SkilledDragon LLC, [skilleddragon.com](https://skilleddragon.com)