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.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues