Context
The Agentic AQUA launch post is candid about a real gap:
Agents make autonomous decisions, and an unchecked agent could spend your bitcoin in unexpected ways. Use it for small operational amounts.
Today this is enforced socially ("use small amounts") rather than mechanically. Following a recent conversation with Samson on this exact gap, opening this issue to propose a minimal, local-first mechanism before sending a PR.
Proposal
A local spending policy loaded from ~/.aqua/policy.json (mirrors the existing ~/.aqua/config.json pattern if I've read the layout correctly, happy to follow your convention), enforced by a single check_policy() call invoked before the password unlock / key derivation step on every send path.
Plain JSON, edited by the human owner. No new MCP tools, the agent does not issue or modify its own policy. Also: file read/write for ~/.aqua/policy.json should not be exposed through any MCP tool surface, otherwise the agent can rewrite its own constraints by going through a generic filesystem tool. Worth being explicit about this in code review.
The schema is intentionally designed to be wrappable in a signed credential later (see "Future direction" below) without breaking back-compat.
Example policy
{
"version": 1,
"limits": {
"per_transaction": {
"btc_sats": 50000,
"lbtc_sats": 50000,
"usdt_atomic": 25000000
},
"per_24h_rolling": {
"btc_sats": 200000,
"lbtc_sats": 200000,
"usdt_atomic": 100000000
}
},
"allowlist": {
"btc_addresses": [],
"liquid_addresses": [],
"lightning_nodes": []
},
"denylist": {
"btc_addresses": [],
"liquid_addresses": [],
"lightning_nodes": []
},
"require_confirmation_above": {
"btc_sats": 10000,
"lbtc_sats": 10000,
"usdt_atomic": 5000000
},
"expires_at": "2026-12-31T23:59:59Z"
}
Notes on schema choices:
usdt_atomic uses the raw 6-decimal token unit (25,000,000 = 25.00 USDt) to avoid float rounding. Matches the convention btc_sats and lbtc_sats already follow.
per_24h_rolling is explicit that the window slides continuously rather than resetting at UTC midnight. Avoids the "I spent at 11:50 PM, the limit reset at midnight" surprise.
require_confirmation_above mirrors limits shape so all three assets have an explicit threshold (no surprises for missing-asset cases).
- When
expires_at has passed, check_policy() returns deny(reason="policy expired") for every request. The human owner has to edit the file (or remove it, which returns to silent-allow per the default behavior below).
- Allowlist / denylist precedence: denylist wins. If an address appears in both (probably a config mistake), the transaction is denied. Explicit rather than implicit.
version: 1 is the schema version. A future loader can branch on this to support migration; v1 readers reject unknown versions.
Enforcement points
A new aqua.policy module exporting check_policy(tx_request) -> PolicyDecision, called at the entry of:
btc_send
lw_send
lw_send_asset
lightning_send (destination decoded from BOLT11)
Exact insertion point is pending a closer read of src/aqua/. If there's an existing shared pre-send path, the hook belongs there rather than duplicated. Open to direction here.
Decision returns one of:
allow: request proceeds unchanged
deny(reason): request halts with structured reason returned through the existing error path
require_confirmation(reason): request halts pending explicit human acknowledgment. The structured reason flows back to the MCP caller; if the existing conversational flow surfaces structured errors cleanly, no new surface area needed
Spend history
Per-window limits require lightweight local accounting. ~/.aqua/policy.db (SQLite, stdlib) with a single table:
CREATE TABLE policy_spend_log (
timestamp_utc TEXT NOT NULL,
asset TEXT NOT NULL, -- 'btc' | 'lbtc' | 'usdt'
amount INTEGER NOT NULL,
txid TEXT NOT NULL,
PRIMARY KEY (asset, txid)
);
Writes happen on broadcast confirmation, not on check_policy() call, which avoids double-counting failed broadcasts. Rolling-window queries scan WHERE timestamp_utc > datetime('now', '-1 day') AND asset = ?.
No network calls, no external dependencies beyond Python stdlib.
Default behavior
When no policy.json exists: silent allow (preserves existing behavior; opt-in mechanism). CLI emits a one-time warning on first send to surface the feature's existence and link to the docs page.
CLI
aqua policy show
aqua policy set --per-tx-btc 50000 --per-24h-btc 200000
aqua policy add-allow --liquid-address lq1...
aqua policy add-deny --btc-address bc1q...
aqua policy clear
Tests
- Per-transaction limit denial (BTC, L-BTC, USDt, Lightning)
- Per-24h rolling-window correctness (boundary case: spend at T-23h59m and T-1m both counted; spend at T-24h01m not counted)
- Allowlist precedence over denylist (allowlist is checked first; if not present and address is on denylist, deny)
- Expired policy returns
deny for every request
- Missing policy file preserves current behavior (silent allow + one-time warning)
require_confirmation decision surfaces correctly through the MCP response surface
- Spend log persists across process restarts (round-trip the SQLite file)
Scope
This proposal lands the mechanism. Out of scope:
- Signed / credentialed policies
- Multi-agent delegation chains (one agent issuing narrower constraints to a sub-agent)
- Remote policy revocation / propagation across devices
- Receiver-side counterparty attestation (verifying the recipient of a payment, not just whether the sender is within policy)
- Replace-By-Fee accounting edge cases (a fee-bumped tx replacing an original, counted once or twice against the 24h limit? Defer the decision until v2 once real usage patterns are visible)
All deliberately deferred to keep the first change set focused and reviewable.
Future direction (non-blocking)
The policy schema is designed so the same JSON can later be wrapped in a W3C Verifiable Credential, signed by the wallet owner's DID, presented by the agent at send time, and verified by check_policy() before enforcement. That unlocks:
- Policies that travel across devices without trusting the local filesystem
- Sub-delegation (agent A grants a narrower policy to agent B; the wallet verifies the full chain)
- Instant revocation via credential status lists
This is the direction we're building in Observer Protocol / AIP Delegation Credentials. The schema in this proposal is intentionally a subset of what a signed delegation credential would carry. Reference implementation is at wdk-protocol-trust if you want to compare schemas. Happy to discuss as a separate follow-up if there's interest. Explicitly not part of this proposal. The local JSON policy is fully useful standalone.
Open questions
- Hook insertion point: single shared pre-send path or per-tool? Need to read
src/aqua/ more closely before the PR.
- Default when no policy file exists: silent allow (current draft) vs. require explicit opt-out (e.g.,
aqua policy init --permissive)?
lightning_send policy on decoded node pubkey vs. amount only? Argues for both: node pubkey is the equivalent of an address for routing-target denylisting, amount applies regardless.
- Any conflicts with the
AQUA_PASSWORD env-var flow I should account for? Specifically: if the password unlock happens before check_policy(), the policy guard runs on already-unlocked key material, which is the wrong order. Want the order to be: parse request, check policy, unlock, sign, broadcast, log.
- Preferred location for
policy.json: ~/.aqua/ per existing config convention, or somewhere else by your preference?
Next step
Targeting one focused PR (~300-500 LoC including tests) once direction is confirmed. Will hold off on opening it for a few days to give space for feedback on scope and hook location.
Context
The Agentic AQUA launch post is candid about a real gap:
Today this is enforced socially ("use small amounts") rather than mechanically. Following a recent conversation with Samson on this exact gap, opening this issue to propose a minimal, local-first mechanism before sending a PR.
Proposal
A local spending policy loaded from
~/.aqua/policy.json(mirrors the existing~/.aqua/config.jsonpattern if I've read the layout correctly, happy to follow your convention), enforced by a singlecheck_policy()call invoked before the password unlock / key derivation step on every send path.Plain JSON, edited by the human owner. No new MCP tools, the agent does not issue or modify its own policy. Also: file read/write for
~/.aqua/policy.jsonshould not be exposed through any MCP tool surface, otherwise the agent can rewrite its own constraints by going through a generic filesystem tool. Worth being explicit about this in code review.The schema is intentionally designed to be wrappable in a signed credential later (see "Future direction" below) without breaking back-compat.
Example policy
{ "version": 1, "limits": { "per_transaction": { "btc_sats": 50000, "lbtc_sats": 50000, "usdt_atomic": 25000000 }, "per_24h_rolling": { "btc_sats": 200000, "lbtc_sats": 200000, "usdt_atomic": 100000000 } }, "allowlist": { "btc_addresses": [], "liquid_addresses": [], "lightning_nodes": [] }, "denylist": { "btc_addresses": [], "liquid_addresses": [], "lightning_nodes": [] }, "require_confirmation_above": { "btc_sats": 10000, "lbtc_sats": 10000, "usdt_atomic": 5000000 }, "expires_at": "2026-12-31T23:59:59Z" }Notes on schema choices:
usdt_atomicuses the raw 6-decimal token unit (25,000,000 = 25.00 USDt) to avoid float rounding. Matches the conventionbtc_satsandlbtc_satsalready follow.per_24h_rollingis explicit that the window slides continuously rather than resetting at UTC midnight. Avoids the "I spent at 11:50 PM, the limit reset at midnight" surprise.require_confirmation_abovemirrorslimitsshape so all three assets have an explicit threshold (no surprises for missing-asset cases).expires_athas passed,check_policy()returnsdeny(reason="policy expired")for every request. The human owner has to edit the file (or remove it, which returns to silent-allow per the default behavior below).version: 1is the schema version. A future loader can branch on this to support migration; v1 readers reject unknown versions.Enforcement points
A new
aqua.policymodule exportingcheck_policy(tx_request) -> PolicyDecision, called at the entry of:btc_sendlw_sendlw_send_assetlightning_send(destination decoded from BOLT11)Exact insertion point is pending a closer read of
src/aqua/. If there's an existing shared pre-send path, the hook belongs there rather than duplicated. Open to direction here.Decision returns one of:
allow: request proceeds unchangeddeny(reason): request halts with structured reason returned through the existing error pathrequire_confirmation(reason): request halts pending explicit human acknowledgment. The structured reason flows back to the MCP caller; if the existing conversational flow surfaces structured errors cleanly, no new surface area neededSpend history
Per-window limits require lightweight local accounting.
~/.aqua/policy.db(SQLite, stdlib) with a single table:Writes happen on broadcast confirmation, not on
check_policy()call, which avoids double-counting failed broadcasts. Rolling-window queries scanWHERE timestamp_utc > datetime('now', '-1 day') AND asset = ?.No network calls, no external dependencies beyond Python stdlib.
Default behavior
When no
policy.jsonexists: silent allow (preserves existing behavior; opt-in mechanism). CLI emits a one-time warning on first send to surface the feature's existence and link to the docs page.CLI
Tests
denyfor every requestrequire_confirmationdecision surfaces correctly through the MCP response surfaceScope
This proposal lands the mechanism. Out of scope:
All deliberately deferred to keep the first change set focused and reviewable.
Future direction (non-blocking)
The policy schema is designed so the same JSON can later be wrapped in a W3C Verifiable Credential, signed by the wallet owner's DID, presented by the agent at send time, and verified by
check_policy()before enforcement. That unlocks:This is the direction we're building in Observer Protocol / AIP Delegation Credentials. The schema in this proposal is intentionally a subset of what a signed delegation credential would carry. Reference implementation is at wdk-protocol-trust if you want to compare schemas. Happy to discuss as a separate follow-up if there's interest. Explicitly not part of this proposal. The local JSON policy is fully useful standalone.
Open questions
src/aqua/more closely before the PR.aqua policy init --permissive)?lightning_sendpolicy on decoded node pubkey vs. amount only? Argues for both: node pubkey is the equivalent of an address for routing-target denylisting, amount applies regardless.AQUA_PASSWORDenv-var flow I should account for? Specifically: if the password unlock happens beforecheck_policy(), the policy guard runs on already-unlocked key material, which is the wrong order. Want the order to be: parse request, check policy, unlock, sign, broadcast, log.policy.json:~/.aqua/per existing config convention, or somewhere else by your preference?Next step
Targeting one focused PR (~300-500 LoC including tests) once direction is confirmed. Will hold off on opening it for a few days to give space for feedback on scope and hook location.