Drops the ~60 ticket-prefix comments (CRIT-N, HIGH-N, MED-N, LOW-N,
L-N, M-N, AUDIT-N, PLUTUS-N, "audit fix (date):", "Phase N" labels,
"Adversarial-review fix:") that had accumulated in inline + doc
comments over several audit cycles. Where the surrounding prose
still carried useful WHY context it gets kept and tightened; where
the ticket WAS the comment it gets dropped entirely.
No logic, no renames, no behavior change. Audit history lives in
commit messages and the audits/ tree where it belongs — eternal
comments don't need to mirror it.
Net 138 LOC shorter. 253 tests pass, no new clippy or fmt warnings.
Two CRIT findings from the 2026-05-12 Opus audit. Both are
mainnet-blocking against the aldabra-mainnet container.
CRIT-1 — cap-bypass via unsigned-build → sign_partial → submit chain.
Previously `wallet_send` / `wallet_mint` / `wallet_mint_cip68_nft` /
`wallet_script_spend` enforced `max_send_lovelace`, but the unsigned-
build tools + `wallet_sign_partial` + `wallet_submit_signed_tx` did
not. A prompt-injection that walked the cold-signer chain could drain
the wallet past the cap with zero policy enforcement.
Fix:
- `wallet_send_unsigned` / `wallet_mint_unsigned` /
`wallet_plutus_mint_unsigned` now enforce the cap on the user-
supplied destination lovelace, mirroring their signed equivalents.
All three gain a `force: bool` arg with `#[serde(default)]`.
- `wallet_sign_partial` and `wallet_submit_signed_tx` decode the
Conway tx CBOR, sum lovelace across every output whose address is
NOT this wallet's own primary address, and enforce the cap on that
total. Both gain `force: bool`. The chokepoint covers cold-signed
multi-sig flows and any hand-built CBOR the daemon would otherwise
blindly sign or submit.
- New free fn `sum_non_self_lovelace` is the unit-testable core of
the chokepoint logic; `enforce_cap_on_cbor` wraps it.
- The sum uses `try_fold` + `checked_add` (NOT `.sum::<u64>()`) so a
crafted CBOR that overflows `u64::MAX` fails the check instead of
wrapping silently in release builds.
CRIT-2 — path traversal via `reference_script_path` and
`policy_cbor_path`. Previously the tools called `std::fs::read_to_
string(p)` on any path the LLM passed. The MCP daemon runs as the
same user that owns `$ALDABRA_DATA/mnemonic.age` /
`$ALDABRA_DATA/root-xprv.age`. Decode-error messages included the
hex_decode position offset — a small but real information leak about
non-hex file structure.
Fix:
- New `Config::safe_reads_root` field (default `$ALDABRA_DATA/scripts/`,
override via `ALDABRA_SAFE_READS_ROOT` env or TOML).
- New `assert_inside_sandbox` helper canonicalize()s both the root and
the user-supplied path, then enforces `starts_with`. Rejects
outside-root paths, `..`-traversal, and nonexistent paths with
generic messages.
- Hardlink-rejection: post-canonicalize, stat the file and refuse if
`nlink > 1`. `canonicalize` resolves symlinks but NOT hardlinks (a
hardlink IS the file — same inode, different directory entry), so
without this check an attacker with daemon-uid write access could
plant a hardlink to the encrypted key blob inside the sandbox and
exfiltrate bytes through the read path.
- `resolve_ref_script_bytes` + `resolve_policy_cbor_bytes` + the
`resolve_validator_required` wrapper used by all 5 escrow spend
tools take `&Path` and route through the sandbox.
- Error messages on hex_decode failures no longer carry the path
string or byte-offset position — return a constant "contents are
not valid hex" instead.
- `main.rs` creates the sandbox root with 0o700 perms at startup if
missing. chmod errors are surfaced (not swallowed) so a broken
filesystem doesn't silently fall back to umask 0o755.
- README documents the new `ALDABRA_SAFE_READS_ROOT` env var alongside
`ALDABRA_MAX_SEND_LOVELACE` (also previously undocumented).
Tests (243 → 253, +10):
- 5 sandbox tests: accept-inside, reject-outside, reject-dotdot,
reject-nonexistent, reject-hardlink.
- 1 non-hex regression: constant message (no byte-offset leak).
- 3 cap tests: self-send → 0 non-self total, outbound counts,
overflow → Err (regression for the prompt-injection `u64::MAX`
wraparound attempt).
- 1 garbage-CBOR test: clean error.
No new clippy warnings, no new fmt drift, `cargo audit` unchanged
(0 CVEs, 2 transitive unmaintained warnings).
Adversarial review of the first draft (3 Opus reviewers) caught the
u64 overflow, the hardlink bypass, and the swallowed chmod error.
Per Sulkta 2026-05-09 directive: after the audit + preprod E2E green-light
(6/6 builders, 9 successful txs, 0 failures), drop the compile-time gate
and integrate escrow as a default-on feature. The "not third-party
audited" framing becomes a runtime notice carried by escrow_open_unsigned
rather than a Cargo feature.
Changes:
- aldabra-dao/Cargo.toml: drop [features] block + escrow_wip = []
- aldabra-dao/src/agora/mod.rs: pub mod escrow (no cfg gate)
- aldabra-dao/src/builder/mod.rs: 6 escrow_* modules unconditional
- aldabra-mcp/Cargo.toml: drop features = ["escrow_wip"] from dao dep
- aldabra-mcp/src/tools.rs:
- Drop "WIP — UNAUDITED:" prefix from all 6 escrow tool descriptions
- Drop "wip_warning" JSON field from all 6 spend-tool responses
- Add "audit_notice" field on escrow_open_unsigned response only
(per Sulkta's framing — once-per-escrow-conversation, not repeated
on every subsequent tool)
- Update section header comment to reflect post-WIP status
- 7 escrow source files (1 agora + 6 builder): replace
"WIP / UNAUDITED. Feature-gated behind escrow_wip" docstring with
"Not third-party audited — preprod-only" + audit doc reference
Verified: 133 dao tests pass (was 132 under --features escrow_wip;
+1 from the rejects_no_initial_contributor test that's now always
compiled). aldabra-mcp release build clean.
The runtime audit_notice on escrow_open_unsigned reads:
"This escrow validator has had an internal review and a 9-tx preprod
E2E pass, but has NOT been audited by an external third party. Use
at your own risk. If the user is opening this with anything beyond
test-net or low-value funds, pass this notice along and confirm they
accept the risk. Validator hash: a8081acef26935d9b5f44b92052178e17301b6d6e6808c91c5b56f5d."
This carries the same caveat the WIP framing did, but in a form the
calling agent can surface inline to the user opening the escrow.
Five new MCP tools wrapping the four Plutus V3 spend builders shipped
earlier in this branch:
- escrow_deposit_unsigned → Deposit redeemer (continuing-output state)
- escrow_agree_unsigned → Agree redeemer (Open → Agreed{at=upper}, both sign)
- escrow_veto_unsigned → Veto redeemer (Agreed → multi-output refund)
- escrow_settle_unsigned → Settle redeemer (Agreed → recipient payout)
- escrow_refund_timeout_unsigned → Refund redeemer (Open after open_deadline → multi-output refund)
Each takes the existing escrow UTxO ref + lovelace + datum_cbor_hex
(caller pulls via chain_address_info), the V3 validator UPLC
(inline hex OR file-path to dodge the >4500-char MCP transport bug),
the redeemer-specific args, and fee_lovelace. Validity windows
default to 30 min from chain tip; agree's window auto-clamps to
open_deadline_ms when needed.
Helper additions:
- EscrowDatum::from_cbor_hex on aldabra-dao keeps pallas-codec /
pallas-primitives direct deps OUT of aldabra-mcp.
- decode_pkh28, resolve_validator_required, build_escrow_spend_in,
fetch_tip_slot_ms in tools.rs — small helpers shared by all 5
spend tools.
Drops the [features] section on aldabra-mcp's Cargo.toml. rmcp 0.1.5's
#[tool(tool_box)] macro scans the impl AST and references every
#[tool]-annotated method's generated wrapper regardless of cfg
eligibility — cfg-on-method gating fails to compile when the feature
is off because the macro emits unresolved symbol references. Pivot:
always-pull aldabra-dao/escrow_wip via the dep itself. The runtime
gate is the "WIP — UNAUDITED:" prefix in every tool description plus
the "wip_warning" field in JSON responses; the dao crate's escrow_wip
feature still gates downstream Rust consumers that want source-level
opt-out.
Verified: aldabra-mcp builds clean (default + release). 132 aldabra-
dao tests pass under --features escrow_wip including all 35 escrow
builder tests. Release binary produced.
Adds the first MCP tool surface for the escrow validator: takes
party_a/b/recipient pkh hex + open_deadline + lock_period + optional
initial_contributor + initial_lovelace, calls
build_unsigned_escrow_open, returns CBOR-hex of the unsigned tx.
Tool description prefixed with "WIP — UNAUDITED:" per the handoff
discipline. Body includes a wip_warning field reminding callers this
is preprod-only.
Also propagates the escrow_wip feature from aldabra-mcp to
aldabra-dao so building the MCP binary with --features escrow_wip
correctly enables the dao crate's escrow surface.
Cleaned up unused-import warnings (ESCROW_SPEND_EX_UNITS pulled to
test-only scope in agree/settle/veto/refund_timeout — the impl bodies
take ex_units explicitly via args, only tests reference the constant).
35/35 escrow builder tests still pass; aldabra-mcp builds clean with
--features escrow_wip.
The remaining 4 unsigned-write tools (deposit/agree/veto/settle/
refund_timeout) follow the same pattern: pull wallet utxos, optionally
discover the escrow UTxO from chain at script_address, decode datum,
call the corresponding builder, return CBOR-hex JSON.
Tools added to WalletService:
DAO management (filesystem-only, no chain calls):
- dao_register — save a DaoConfig under \$ALDABRA_DATA/daos/<name>.json
- dao_list — show all registered DAO names + active marker
- dao_use — set active DAO; subsequent dao_* calls without
explicit `dao` arg target this one
- dao_remove — delete config; clears active if it was the active one
- dao_show — render full DaoConfig JSON for audit
DAO live-state reads (Koios-backed, decoded into typed Rust):
- dao_governor_state — singleton governor UTxO + thresholds + timing
+ nextProposalId + per-stake proposal cap
- dao_stake_list — all stakes for the DAO (filtered to gov-token
policy so the shared MLabs stakes addr doesn't
leak other DAOs into output). Renders pkh,
amount, locks, delegation per stake.
- dao_my_stake — filters dao_stake_list to just THIS wallet's
stake (matches wallet pkh against StakeDatum.owner).
Empty array if not staked yet.
Plumbing:
- WalletService::new gains data_dir param (for DaoStore root)
- WalletInner gains dao_store + dao_reader fields
- wallet_pkh() helper extracts the wallet's payment-credential hash from
bech32 for owner-match in dao_my_stake
- get_info() instructions advertise the new dao_* surface
- aldabra-mcp/Cargo.toml: aldabra-dao path dep + hex + pallas-addresses
end-to-end working wallet: paste 24-word mnemonic, age-encrypt at rest,
on unlock derive root + payment + stake keys, build cip-19 base address,
serve four tools over mcp stdio (wallet.address, wallet.network,
wallet.balance, wallet.utxos).
deps added: ed25519-bip32 0.4 (pallas only ships raw ed25519, not the
cardano variant of bip32 hd derivation), cryptoxide 0.4 for pbkdf2-hmac-sha512,
age 0.10 for at-rest mnemonic encryption, rpassword 7 for tty-only passphrase
prompts, toml 0.9 for config.toml.
new modules:
- crates/aldabra-core/src/derive.rs — payment + stake key derivation, hash
- crates/aldabra-chain/src/koios.rs — real reqwest impl, asset aggregation
- crates/aldabra-mcp/src/{bootstrap,config,tools}.rs
caught one bug pre-flight: get_balance was clobbering same-asset
quantities across utxos instead of summing. fixed + regression test.
headless support via ALDABRA_PASSPHRASE env (mcp clients own stdin so
the rpassword prompt path can't run). docker secret / systemd
EnvironmentFile sources it in production.
dockerfile: multi-stage rust:1.95-bookworm → debian:bookworm-slim, tini
as pid1, non-root aldabra user, /var/lib/aldabra owned 700.
29 unit tests + 1 ignored live-koios test. preprod smoke test exercised
initialize → tools/list → tools/call wallet.address end-to-end via
piped json-rpc; correct preprod address came back from canonical
abandon-art mnemonic.
phase 2 (send) is next.