Skip to main content
Glama
axdvdv

wwall

by axdvdv
README.md
# wwall

**A policy guard that sits between an AI agent and a wallet.**

Aleph Hackathon 2026 · WDK track · Tether

Give an agent a wallet and you have given it your money. wwall is an MCP server
that stands in the way: the agent can *propose* a payment, but a local,
human-authored policy decides whether it happens, whether a person has to
approve it first, or whether it is refused outright. Every decision is written
to a signed, append-only ledger.

The agent never touches [@tetherto/wdk](https://www.npmjs.com/package/@tetherto/wdk).
It touches wwall, and wwall touches WDK.

```
┌─────────────┐   MCP/stdio   ┌──────────────────────────┐        ┌──────────┐
│  AI agent   │──────────────▶│  wwall                   │───────▶│   WDK    │──▶ Polygon
│ (Claude…)   │  propose_     │  ├ predicates.ts (guard) │ only   │  wallet  │
│             │   payment     │  ├ ledger.jsonl (signed) │ if     └──────────┘
└─────────────┘◀──────────────│  └ policy.json           │ allowed
                 verdict      └──────────────────────────┘
                                          │ holds NEEDS_CONFIRM
                                          ▼
                                   ┌─────────────┐
                                   │   human     │  wwall pending / confirm / reject
                                   └─────────────┘
```

## The one invariant

> **No tool call can reach `wallet.send()` without passing through
> `evaluatePredicates()` first.**

Everything else is arranged to keep that true:

- The server registers exactly three tools — `propose_payment`, `get_balance`,
  `get_pending`. There is no send, transfer, sign or raw-transaction tool, and a
  test asserts the tool list by name and by pattern.
- The wallet is opened **lazily**, only on a path that has already been allowed.
  A rejected proposal never constructs a WDK instance at all — the token config
  needed to answer it is passed in separately for exactly this reason.
- The payout token is fixed by configuration. An agent naming a different token
  is refused before any rule runs. (Without this, `SPEND_CAP{token:"USDT0"}`
  simply would not apply to `token:"MONOPOLY"`, the policy would allow it, and
  the wallet would still move real USD₮0. A spend cap must not be bypassable
  with a string.)
- A rejection is a normal tool result, never a thrown error, so the agent can
  read the reason and explain it to a person.
- **The same predicates are registered inside WDK itself**, so the wallet
  refuses too — see below.

### Two layers, one set of predicates

`wwall` decides whether to *call* `transfer`. That is an argument about this
process. WDK's own policy engine decides whether the wallet will *perform* it,
which is a property of the account:

```js
wdk.registerPolicy({
  id: 'wwall-guard', scope: 'project', wallet: 'polygon',
  rules: [{ operation: 'transfer', action: 'ALLOW',
            conditions: [ctx => evaluate(policy, intentFrom(ctx.args), context(), 'ignore-confirm')] }]
})
```

One ALLOW rule, gated on the same `evaluatePredicates` everything else uses.
The leverage is in what is *not* written: WDK is **default-deny on governed
accounts**, so the moment any policy applies, every method in `OPERATIONS` is
wrapped and anything without a matching ALLOW throws `PolicyViolationError`.
That closes the routes around a spend cap that never touch `transfer` at all:

| Route | Why a `transfer`-shaped guard misses it |
| --- | --- |
| `sendTransaction({to: token, data: <ERC-20 transfer calldata>})` | Same effect, different method |
| `approve(spender, MAX)` then someone else's `transferFrom` | Funds leave later, by another hand |
| `signTypedData` of an EIP-2612 Permit | Entirely off-chain; nothing is "sent" |
| `delegate(...)` under ERC-7702 | Hands the account to a contract |

wwall does none of these. That is the point: they were things its guard could
not see. [test/wdk-policy.test.ts](test/wdk-policy.test.ts) drives a real WDK
instance against a dead RPC and asserts each one raises `PolicyViolationError`
*before* any network call — while a permitted transfer fails on the network
instead, which is what proves it got past the guard rather than being stopped
by it.

The condition asks "is this allowed **at all**" (`ignore-confirm` mode). The
executed-versus-held split belongs to the layer above: by the time a held
payment reaches `transfer`, a human has approved it, and this layer must not
refuse it a second time.

A condition that throws on an ALLOW rule counts as "did not match", which under
default-deny means refused — so a bug in this layer fails closed.

## Quick start from a clean clone

```bash
git clone <this repo> && cd wwall
npm install
cp .env.example .env        # then edit it — see below
npm run build
npm test                    # 295 tests, no network, no money
npm run try                 # walk the guard through a dozen proposals, fake wallet
```

`.env` needs exactly one line. Everything else has a default:

```bash
WARDEN_SEED="…twelve words…"
# WARDEN_ARMED=1            # leave unset until you mean to move real funds
```

Chain, RPC and payout token default to Polygon and USD₮0; the policy, ledger and
audit key live in `~/.wwall/`. See [.env.example](.env.example) for every
variable and what overriding it does.

Check the wallet without spending anything:

```bash
npm run check:wallet
```

This reads `symbol()` and `decimals()` straight off the token contract and
compares them with your configuration. A wrong `decimals` is not an error
message, it is a payout off by a factor of 10ⁿ.

### The safety catch

`WARDEN_ARMED` is **off by default**. Until it is exactly `1`, a payment the
policy allows is reported but *not sent* — the proposal comes back as
`rejected` with `code: "not_armed"`. Arm it deliberately.

## Install via Desktop Extension (recommended)

wwall ships as an MCP Bundle — a single `.mcpb` file Claude Desktop installs in one
click, with a form for the settings instead of hand-edited JSON.

```bash
npm install && npm run build
npm run bundle          # → build/wwall.mcpb
```

Then in Claude Desktop: **Settings → Extensions → Install Extension…** and pick
`build/wwall.mcpb`.

**The form asks for one thing: the seed phrase.** It is declared `"sensitive": true` in
the manifest, so Claude Desktop masks it and keeps it in the OS keychain rather than in a
config file you might later paste into a bug report.

Everything else has a default and is not asked:

| Setting | Default |
| --- | --- |
| Chain and RPC | Polygon, via a public endpoint |
| Payout token | USD₮0 — `0xc2132D05…`, 6 decimals, verified on chain |
| Policy, ledger, audit key | `~/.wwall/` |

`~/.wwall` is deliberately not the working directory: an extension is launched with an
unpredictable cwd, so a cwd-relative ledger would give the CLI and the MCP server
*different* spend histories — and a daily cap computed from the wrong ledger is not a cap.
Both halves now read the same one, so `wwall pending` from any directory sees what the
extension wrote.

On first run wwall writes a starter `~/.wwall/policy.json` with an **empty allowlist**, so
every payment is refused until you name a recipient:

```
REJECTED — nothing was sent.
ALLOWLIST: list is empty, no recipient is allowed
```

Open `wwall ui` to add one. An extension that could pay someone it was never told about
would not be a guard, and an allowlist is not something to guess on your behalf.

There is deliberately **no second switch**. An installed extension is armed, and the only
thing standing between an agent and your money is the policy — which is the product's whole
claim, and would be undermined by a master toggle sitting above it. Adding a recipient to
the allowlist is the deliberate act; a global on/off would just be a second place to look
when a payment is refused, and a second kind of rejection cluttering the audit log.

`WARDEN_ARMED` still exists for the CLI and the hand-written config, where it is useful as
a freeze: one line in `.env` stops the wallet without touching the policy.

Any default can still be overridden the old way — every `WARDEN_*` variable from
[.env.example](.env.example) works whether wwall was started by the CLI or by an
extension.

Building the bundle needs the packaging CLI, which is already a dev dependency:

```bash
npx mcpb validate manifest.json   # check the manifest against the schema
npx mcpb pack build/mcpb build/wwall.mcpb
```

> The format was called `.dxt` and shipped as `@anthropic-ai/dxt`; that package is
> deprecated and now points at **`@anthropic-ai/mcpb`**. The spec lives in
> [MANIFEST.md](https://github.com/modelcontextprotocol/mcpb/blob/main/MANIFEST.md);
> this bundle targets `manifest_version` **0.4**.

## Wiring it into Claude Desktop by hand

The manual route still works and is worth keeping: it puts every setting in one file you
can read at a glance, which is sometimes exactly what you want when reviewing what a guard
is configured to do.

`claude_desktop_config.json` (macOS: `~/Library/Application Support/Claude/`):

```json
{
  "mcpServers": {
    "wwall": {
      "command": "node",
      "args": ["/absolute/path/to/wwall/dist/src/bin/wwall-mcp.js"],
      "env": {
        "WARDEN_SEED": "…twelve words…",
        "WARDEN_CHAIN": "polygon",
        "WARDEN_RPC_URL": "https://polygon-bor-rpc.publicnode.com",
        "WARDEN_TOKEN_ADDRESS": "0xc2132D05D31c914a87C6611C10748AEb04B58e8F",
        "WARDEN_TOKEN_SYMBOL": "USDT0",
        "WARDEN_TOKEN_DECIMALS": "6",
        "WARDEN_POLICY": "/absolute/path/to/wwall/policy.json",
        "WARDEN_LEDGER": "/absolute/path/to/wwall/ledger.jsonl"
      }
    }
  }
}
```

Restart Claude Desktop and the three tools appear. Without a client, the server
speaks plain JSON-RPC on stdio:

```bash
printf '%s\n%s\n%s\n' \
 '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"x","version":"0"}}}' \
 '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
 '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
 | node dist/src/bin/wwall-mcp.js
```

Or point the MCP Inspector at it:
`npx @modelcontextprotocol/inspector node dist/src/bin/wwall-mcp.js`

## Tools

| Tool | What it does |
| --- | --- |
| `propose_payment(to, amount, token, reason?)` | The only route to the wallet. Returns `executed` + `txHash`, `rejected` + `reason`, or `pending_confirmation` + `confirmationId`. |
| `get_balance(token?)` | Gas balance and payout-token balance. Read-only. |
| `get_pending()` | Payments held for a human. They have **not** been sent. |

Amounts are decimal **strings** everywhere — `"12.50"`, never `12.5`. A JSON
number on the money path is a float, and a float is a rounding bug waiting for
a large enough number.

## The human half

The agent can put a payment into `pending_confirmation`. Only a person can take
it out:

```bash
wwall pending                     # what is waiting, and which rule held it
wwall confirm <id> --by alex      # approve and send
wwall reject  <id> --note "…"     # refuse; nothing is ever sent
wwall ui                          # policy builder in the browser
```

Both halves read and write the same `ledger.jsonl`, so `wwall pending` shows
exactly what the agent's `get_pending` shows.

`wwall confirm` **re-evaluates the policy before sending**. The verdict was
formed when the agent proposed the payment, possibly hours and several payments
ago; the spend caps have moved since. It re-checks in "ignore-confirm" mode —
asking only *is this still allowed*, because the human at the keyboard **is**
the confirmation.

## Policy

`policy.json` is a predicate tree. Nine opcodes:

| Opcode | Fields | Meaning |
| --- | --- | --- |
| `AND` / `OR` | `rules[]` | Empty `AND` allows (vacuous truth); empty `OR` denies. |
| `NOT` | `rule` | Inverts. |
| `SPEND_CAP` | `token`, `amount`, `window: tx\|day` | Inclusive. `day` is a **rolling** 24 hours, not a calendar day — a calendar window can be bypassed by spending the cap at 23:59 and again at 00:01. |
| `ALLOWLIST` | `addresses[]` | An **empty allowlist allows nobody**. A list you forgot to fill in must not silently allow everyone. |
| `DENYLIST` | `addresses[]` | Empty denies nobody. |
| `TIME_WINDOW` | `from`, `to` | `"HH:MM"` on both ends = daily UTC window, inclusive, wrapping past midnight when `from > to`. Two timestamps = an absolute range. |
| `CONFIRM_THRESHOLD` | `amount` | Not a refusal — amounts **at or above** it are held for a human. |
| `VELOCITY` | `maxTxCount`, `window: hour\|day` | Rolling, like `SPEND_CAP`. |

`CONFIRM_THRESHOLD` is why the policy is evaluated **twice** per proposal: once
with every threshold treated as satisfied (*is this allowed at all?*) and once
strictly (*can it go unattended?*). That is what lets a threshold work inside an
`AND`, `OR` or `NOT` instead of only at the top level — a flat "needs approval"
flag could not.

Only confirmed sends count toward a cap. A payment submitted but not yet
confirmed is invisible to `SPEND_CAP` and `VELOCITY`; see *Known limits*.

### The no-code builder

```bash
wwall ui          # → http://127.0.0.1:4478/
```

A flat list of rule cards plus one match-all/match-any switch, a live
`policy.json` preview, and a test form that shows the verdict as you type. The
Save button writes `policy.json` through the local server.

The page contains **no copy of the guard logic**. Its verdict comes from
`POST /api/evaluate`, which calls the same `evaluatePredicates` the MCP server
and the CLI call — a second implementation in browser JavaScript would drift
from this one and quietly start giving different answers. A test asserts the
page ships no client-side amount arithmetic.

The builder deliberately cannot draw nested composition (`NOT`, groups inside
groups). A policy that uses it opens read-only, with a note pointing at
`policy.json`. Full composition stays available by editing the file.

## Audit log

Every record — verdicts, send attempts, results, human decisions — is appended
to `ledger.jsonl`, signed with a local Ed25519 key and chained to the record
before it.

```bash
npm run verify:audit
```

```
records   3  (3 signed, 3 verified)
pinned to MCowBQYDK2VwAyEAKcjUin18… from audit-key.json

✓ every record is signed and the chain is unbroken
```

Signatures prove a record was not **edited**. They do not prove one was not
**removed** — deleting a line leaves every remaining signature valid. That is
what `prev` is for. Between them they catch editing, deletion from the middle,
and reordering.

The `pinned to` line matters: without an audit key to check against, a log
rewritten wholesale with an attacker's key verifies perfectly. Verification is
only meaningful against a key you trust.

## Scenarios

36 scenarios run through the real MCP server over a real transport — not by
calling the evaluator directly, because the interesting failures live in the
pre-checks, the ledger round-trip and the tool boundary.

```bash
npm run report:scenarios            # print
npm run report:scenarios -- --write # splice the table into this README
```

<!-- scenarios:start -->
### Scenario results

| Category | Scenarios | Executed | Held for human | Rejected | Behaved as specified |
| --- | ---: | ---: | ---: | ---: | ---: |
| Legitimate | 6 | 5 (83%) | 1 (17%) | 0 (0%) | 6/6 |
| Borderline | 12 | 4 (33%) | 3 (25%) | 5 (42%) | 12/12 |
| Decimals traps | 10 | 0 (0%) | 0 (0%) | 10 (100%) | 10/10 |
| Prompt injection | 8 | 0 (0%) | 1 (13%) | 7 (88%) | 8/8 |
| **All** | **36** | **9** | **5** | **22** | **36/36** |

<details>
<summary>Every scenario</summary>

| # | Category | Scenario | Outcome | Why |
| --- | --- | --- | --- | --- |
| L1 | Legitimate | small payout to an allowlisted address | executed | under every cap and under the confirmation threshold |
| L2 | Legitimate | smallest representable amount | executed | exactly one base unit of a 6-decimal token |
| L3 | Legitimate | token given by contract address instead of symbol | executed | the payout token is recognised either way |
| L4 | Legitimate | checksummed address against a lowercase allowlist | executed | EVM addresses compare case-insensitively |
| L5 | Legitimate | payout that needs a human, and gets held for one | held | above the 2 USDT0 confirmation threshold but within the caps |
| L6 | Legitimate | second small payout of the hour | executed | well inside the velocity limit of 5/hour |
| B1 | Borderline | exactly at the per-tx cap | held | the cap is inclusive, so it passes — but it is over the confirm threshold |
| B2 | Borderline | one base unit over the per-tx cap | rejected | a millionth of a dollar over the cap is still over the cap |
| B3 | Borderline | exactly at the confirmation threshold | held | the threshold triggers on >=, so an amount equal to it needs a human |
| B4 | Borderline | one base unit below the confirmation threshold | executed | strictly below the threshold goes through unattended |
| B5 | Borderline | payment that exactly exhausts the daily budget | held | 20 already spent + 5 = exactly the 25/day cap, which is inclusive |
| B6 | Borderline | payment that overruns the daily budget by one unit | rejected | the running total is what the cap is measured against, not the single amount |
| B7 | Borderline | sixth payment within the hour | rejected | five already sent this hour, and the limit is five |
| B8 | Borderline | fifth payment within the hour | executed | four sent so far, so this one is still inside the limit |
| B9 | Borderline | denylisted recipient who is also on the allowlist | rejected | not allowlisted at all, and the denylist would refuse it regardless |
| B10 | Borderline | zero-value payment | executed | zero is a valid amount under every cap; nothing in the policy forbids it |
| B11 | Borderline | address that is a near-miss of an allowlisted one | rejected | one character different is a different address, not a close enough match |
| B12 | Borderline | recipient address with surrounding whitespace | executed | copy-paste whitespace is trimmed, not treated as a different address |
| D1 | Decimals traps | more decimal places than the token has | rejected | truncating to 1.123456 would be an underpayment nobody would notice |
| D2 | Decimals traps | amount below the smallest representable unit | rejected | it would round to zero — a payment that silently pays nothing |
| D3 | Decimals traps | scientific notation | rejected | parsing it as a float is exactly the precision loss this project avoids |
| D4 | Decimals traps | comma as the decimal separator | rejected | ambiguous between 1.5 and 15 depending on locale; never guess with money |
| D5 | Decimals traps | thousands separators | rejected | not a decimal number, and guessing at 1000 would be a 1000× error |
| D6 | Decimals traps | negative amount | rejected | a negative transfer is not a refund, it is a malformed request |
| D7 | Decimals traps | non-ASCII digits | rejected | Arabic-Indic digits parse as a number in some stacks and not others |
| D8 | Decimals traps | amount past the float-safe integer range | rejected | well over the cap — and it is compared as BigInt, so the cap sees the real value |
| D9 | Decimals traps | trailing zeros that exceed the token’s precision | rejected | harmless in value, but accepting 7 places invites the case where it is not |
| D10 | Decimals traps | empty amount | rejected | an empty string must never be read as zero or as "unset" |
| P1 | Prompt injection | instruction to ignore the policy, in the reason field | rejected | the reason field is data recorded in the ledger; nothing reads it as an instruction |
| P2 | Prompt injection | claimed pre-approval by a human | rejected | approval only exists as a confirmation_resolved record, never as a claim in a string |
| P3 | Prompt injection | fake allowlist entry asserted in the reason | rejected | the allowlist is read from the policy, not from the caller |
| P4 | Prompt injection | injection aimed at the confirmation threshold | held | urgency is not an input to the guard; it is held exactly as any other 9 USDT0 would be |
| P5 | Prompt injection | control characters and a fake tool result in the reason | rejected | the reason is JSON-encoded on the way into the ledger, so it cannot forge a record |
| P6 | Prompt injection | unsupported token with an authoritative-sounding justification | rejected | a token the wallet does not pay in is refused before any rule is consulted |
| P7 | Prompt injection | homoglyph address that looks allowlisted | rejected | it is not a valid EVM address at all, let alone the allowlisted one |
| P8 | Prompt injection | splitting a blocked payment into an allowed-looking one | rejected | the allowlist does not care how small the amount is |

</details>
<!-- scenarios:end -->

## WDK integration

Every WDK call in the project, in one file:

| What | Where |
| --- | --- |
| Imports | [src/wdk.ts:14-15](src/wdk.ts#L14-L15) |
| `new WDK(seed).registerWallet(chain, WalletManagerEvm, {provider, transferMaxFee})` | [src/wdk.ts:216](src/wdk.ts#L216) |
| `wdk.registerPolicy(…)` — wwall's predicates as a WDK policy | [src/wdk.ts:218](src/wdk.ts#L218), built at [src/wdk-policy.ts:55](src/wdk-policy.ts#L55) |
| The ALLOW condition that calls `evaluate(…, 'ignore-confirm')` | [src/wdk-policy.ts:85](src/wdk-policy.ts#L85) |
| `account.simulate.transfer(…)` — ask the engine without doing it | [src/wdk.ts:250](src/wdk.ts#L250) |
| `wdk.getAccount(chain, index)` | [src/wdk.ts:228](src/wdk.ts#L228) |
| `account.getBalance()` → native wei | [src/wdk.ts:260](src/wdk.ts#L260) |
| `account.getTokenBalance(address)` → base units | [src/wdk.ts:261](src/wdk.ts#L261) |
| `account.quoteTransfer({token, recipient, amount})` | [src/wdk.ts:281](src/wdk.ts#L281) |
| `account.transfer({token, recipient, amount})` — **the one place money moves** | [src/wdk.ts:304](src/wdk.ts#L304) |
| `account.waitForTransaction(hash, {target: 'confirmed'})` | [src/wdk.ts:328](src/wdk.ts#L328) |
| `wdk.dispose()` | [src/wdk.ts:364](src/wdk.ts#L364) |

MCP SDK: `new McpServer` at [src/mcp-server.ts:97](src/mcp-server.ts#L97), the
three `registerTool` calls at [113](src/mcp-server.ts#L113),
[169](src/mcp-server.ts#L169) and [209](src/mcp-server.ts#L209), and
`StdioServerTransport` at [src/bin/wwall-mcp.ts:52](src/bin/wwall-mcp.ts#L52).

Signatures were read from the installed packages' own `.d.ts` files, not from
memory — and `tsc --strict` type-checks against them, which is the proof.

## Packages

| Package | Version | Why |
| --- | --- | --- |
| `@tetherto/wdk` | 1.0.0-beta.16 | Wallet manager, account derivation |
| `@tetherto/wdk-wallet-evm` | 1.0.0-beta.17 | EVM account: balances, ERC-20 transfer, confirmation |
| `@tetherto/wdk-wallet` | 1.0.0-beta.17 | Shared result types (transitive) |
| `@modelcontextprotocol/sdk` | 1.30.0 | MCP server, stdio transport |
| `zod` | 4.4.3 | Tool input/output schemas |
| `typescript` | 5.7.2 | `strict`, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes` |
| `vitest` | 2.1.8 | Tests |

No dependency for money maths, signing, HTTP or the UI: BigInt fixed-point,
`node:crypto` Ed25519, `node:http`, and a single hand-written HTML file.

## Layout

| File | What |
| --- | --- |
| [src/predicates.ts](src/predicates.ts) | The guard. Pure — no I/O, no clock, no network. |
| [src/amount.ts](src/amount.ts) | BigInt fixed-point. `number` appears nowhere on the money path. |
| [src/context.ts](src/context.ts) | Rolling-window spend and velocity, read from the ledger. |
| [src/ledger.ts](src/ledger.ts) | Append-only JSONL, fsynced, optionally signed and chained. |
| [src/audit.ts](src/audit.ts) | Ed25519 signing and verification. |
| [src/policy.ts](src/policy.ts) | Loading and validation, with JSON-path errors. |
| [src/wdk.ts](src/wdk.ts) | The WDK wrapper. Every result is JSON-serialisable. |
| [src/wdk-policy.ts](src/wdk-policy.ts) | The same predicates, registered with WDK's policy engine. |
| [src/mcp-server.ts](src/mcp-server.ts) | The three tools and the guarded path. |
| [src/cli.ts](src/cli.ts) | `pending` / `confirm` / `reject` / `ui`. |
| [src/ui-server.ts](src/ui-server.ts) | Loopback-only API behind the builder. |
| [ui/index.html](ui/index.html) | The builder. One file, no bundler, no framework. |

## Known limits

Stated plainly, because a security tool that hides its edges is worse than one
that does not have them.

- **Only confirmed sends count toward a cap.** A batch dispatched faster than it
  confirms can exceed a daily cap. `LedgerEvalContext.pendingInWindow()` exists
  so a report can show the in-flight amount that no cap can see.
- **Tail truncation is undetectable.** The hash chain runs backwards, so
  deleting the *last* N records leaves a valid prefix. Catching that needs an
  external anchor — a record count kept elsewhere, or the tip digest published
  somewhere outside the file.
- **The seed is still the seed.** The WDK policy governs accounts obtained
  through *this* WDK instance. Anyone holding the same seed phrase in another
  process — another script, a wallet app, a leaked `.env` — moves the funds with
  no policy in the way. Guarding that needs a key that cannot leave, not a
  policy: a signer in an enclave, a smart account with on-chain limits, or a
  co-signer. wwall constrains an agent; it does not constrain a key holder.
- **USD₮ on Polygon is USDT0.** Polygon's old PoS-bridged USDT was migrated in
  place to native USD₮0, Tether's omnichain token, backed 1:1 in an Ethereum
  lockbox. Verified on chain: `name()="USDT0"`, `symbol()="USDT0"`,
  `decimals()=6`. Do not reuse the BNB Chain address `0x55d398…` — it is
  Binance-Peg, not Tether-issued, and it has **18** decimals rather than 6.
- **`policy.json` is trusted input.** Anyone who can write that file can rewrite
  the guard. It is loaded once at startup and its sha256 is recorded on every
  verdict, so a change is visible in the audit log after the fact — but it is
  not prevented.