aldabra/crates/aldabra-mcp/src/main.rs
Sulkta 4e4becd0bb security: enforce max_send_lovelace + sandbox *_path args
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.
2026-05-12 12:45:03 -07:00

178 lines
6.6 KiB
Rust

//! aldabra — MCP server entry point.
//!
//! Speaks MCP over stdio. Any MCP client (e.g. Claude Code)
//! launches this as a subprocess and gets a wallet's worth of tools.
//!
//! ## Phase 1 tools (target — server wiring lands in 1.7)
//!
//! - `wallet.address` — derived CIP-1852 base address
//! - `wallet.balance` — ADA + native-asset balance via chain backend
//! - `wallet.utxos` — list UTXOs at the wallet address
//! - `wallet.network` — configured network selector
//!
//! ## Phase 2-4 tools
//!
//! See `ROADMAP.md` at the repo root.
//!
//! ## Logging
//!
//! Stderr only — stdout is the MCP transport, must stay clean.
mod bootstrap;
mod config;
mod tools;
use std::process::ExitCode;
use anyhow::Result;
use rmcp::{transport::stdio, ServiceExt};
use tracing_subscriber::EnvFilter;
use crate::config::Config;
use crate::tools::WalletService;
#[tokio::main]
async fn main() -> ExitCode {
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
.init();
match run().await {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
tracing::error!("{e:#}");
ExitCode::FAILURE
}
}
}
async fn run() -> Result<()> {
let cfg = Config::load()?;
tracing::info!(
network = ?cfg.network,
koios = %cfg.koios_base,
koios_bearer_set = cfg.koios_bearer.is_some(),
account = cfg.account,
index = cfg.index,
data_dir = %cfg.data_dir.display(),
safe_reads_root = %cfg.safe_reads_root.display(),
"aldabra starting"
);
// CRIT-2 audit fix (2026-05-12): make sure the sandbox root exists
// and is daemon-only readable. Tools use canonicalize() against
// this dir to validate `*_path` args, which requires the dir to
// be a real directory on disk. Idempotent: create_dir_all is a
// no-op when the dir already exists.
//
// Adversarial-review fix (2026-05-12): chmod is `?`'d not swallowed.
// If the filesystem refuses chmod (noexec, selinux, broken mount),
// we'd otherwise silently fall back to the umask default (commonly
// 0o755) — making the security comment "daemon-only readable" a
// lie. Fail loudly instead so the operator sees + investigates.
if !cfg.safe_reads_root.exists() {
std::fs::create_dir_all(&cfg.safe_reads_root).map_err(|e| {
anyhow::anyhow!(
"create safe_reads_root {}: {e}",
cfg.safe_reads_root.display()
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&cfg.safe_reads_root, std::fs::Permissions::from_mode(0o700))
.map_err(|e| {
anyhow::anyhow!(
"chmod 0o700 safe_reads_root {}: {e}",
cfg.safe_reads_root.display()
)
})?;
}
}
// CLI mode flags — all out-of-band, before the MCP transport
// takes over stdio.
//
// `--generate-mnemonic` — print a fresh phrase, exit. No disk write.
// `--bootstrap` — paste an existing phrase, encrypt, derive.
// `--bootstrap-new` — generate, display, encrypt, derive (one shot).
// `--bootstrap-from-xprv` — paste a root_xsk1... bech32 (cnode root.prv,
// cardano-address output), encrypt, derive.
// Power-user import path for keys that came
// from outside the BIP-39 mnemonic flow.
// (none) — load existing, start MCP server.
let args: Vec<String> = std::env::args().collect();
let generate_only = args.iter().any(|a| a == "--generate-mnemonic");
let bootstrap_only = args.iter().any(|a| a == "--bootstrap");
let bootstrap_new = args.iter().any(|a| a == "--bootstrap-new");
let bootstrap_from_xprv = args.iter().any(|a| a == "--bootstrap-from-xprv");
if generate_only {
bootstrap::print_fresh_mnemonic()?;
return Ok(());
}
let mnemonic_path = bootstrap::mnemonic_path(&cfg.data_dir);
let xprv_path = bootstrap::root_xprv_path(&cfg.data_dir);
let any_key_exists = mnemonic_path.exists() || xprv_path.exists();
// L-2 audit fix: scope `root` to a block so its XPrv drops + wipes
// as soon as we've extracted the keys we need.
let (payment_key, stake_key, address) = {
let root = if bootstrap_new {
bootstrap::generate_and_save_root_key(&cfg.data_dir)?
} else if bootstrap_from_xprv {
bootstrap::import_root_xprv(&cfg.data_dir)?
} else if any_key_exists || bootstrap_only {
// Loads existing (or runs the mnemonic-paste flow on
// first-run with --bootstrap). load_or_create_root_key
// itself picks between mnemonic.age and root-xprv.age.
bootstrap::load_or_create_root_key(&cfg.data_dir)?
} else {
anyhow::bail!(
"no key at {}. run one of:\n \
`aldabra --bootstrap` (paste existing 24-word phrase)\n \
`aldabra --bootstrap-new` (generate a fresh wallet)\n \
`aldabra --bootstrap-from-xprv` (paste a root_xsk1... bech32)",
cfg.data_dir.display()
);
};
let address =
aldabra_core::derive_base_address(&root, cfg.network, cfg.account, cfg.index)?;
let payment_key = aldabra_core::derive_payment_key(&root, cfg.account, cfg.index);
let stake_key = aldabra_core::derive_stake_key(&root, cfg.account);
(payment_key, stake_key, address)
// root drops here — XPrv::Drop wipes the 96 bytes
};
tracing::info!(%address, "derived base address");
if bootstrap_only || bootstrap_new {
eprintln!("aldabra: bootstrap complete. address = {address}");
return Ok(());
}
// Hand off to the MCP server. From this point on stdin/stdout
// belong to the JSON-RPC transport — no more eprintln-prompting.
let service = WalletService::new(
cfg.network,
address,
cfg.koios_base,
cfg.koios_bearer,
payment_key,
stake_key,
cfg.max_send_lovelace,
cfg.data_dir.clone(),
cfg.safe_reads_root.clone(),
);
let server = service
.serve(stdio())
.await
.map_err(|e| anyhow::anyhow!("rmcp serve failed: {e}"))?;
server
.waiting()
.await
.map_err(|e| anyhow::anyhow!("rmcp wait failed: {e}"))?;
Ok(())
}