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.
Cargo.toml + Cargo.lock now point at https://github.com/Sulkta-Coop/pallas
for the pallas-fork patch entries. External clones from either public
mirror (github.com/Sulkta-Coop or gitlab.com/sulkta) build out of the box
— no LAN access needed.
Sulkta-internal builds short-circuit to LAN gitea via a `git config
url.X.insteadOf` rewrite on each host. Symmetric: covers both github and
gitlab → gitea. Same locked SHA either way; routing is environment-level,
not source-of-truth. See docs/internal-build-rewrites.md.
Dockerfile build-time rewrite also flipped to take public-URL inputs;
uses `gitea.sulkta.lan` instead of the bare LAN IP.
Surfaced by Track #38 code audit (2026-05-09):
1. cargo fmt --all: 217 formatting diffs across 35 files. Pure
whitespace; no semantic changes.
2. cargo clippy --fix: 30 warnings -> 10. Auto-applied:
- useless format!() (3 sites in builder/proposal_*.rs)
- needless_borrow_for_generic_args (4 sites)
- cloned_ref_to_slice_refs (1 site, builder/proposal_cosign.rs)
- derivable_impls (1 site, dao/config.rs)
- unused imports/variables (3 sites)
Remaining 10 warnings are non-trivial (too_many_arguments on a
constructor at 8 args, FromStr trait shadow, doc_lazy_continuation
on a few comment blocks). Filed as tech-debt; no action this pass.
3. cargo audit: 0 vulnerabilities. 2 unmaintained advisories on
transitive deps:
- paste 1.0.15 (RUSTSEC-2024-0436) via rmcp + pallas-traverse
- proc-macro-error 1.0.4 (RUSTSEC-2024-0370) via age->i18n-embed-fl
Both upstream; tracked but no action needed locally.
4. Test failure surfaced: builder::proposal_retract_votes::tests::
voting_ready_in_window_subtracts_vote_weight failed — cooldown
check was applied unconditionally for RemoveVoterLockOnly mode,
blocking the legitimate 'retract during voting window' path
where the proposal datum mutates (vote weight subtraction). Per
Agora's premoveLocks rule, cooldown only applies when retracting
AFTER voting closed but BEFORE Finished — not during the active
voting window. Fixed by gating cooldown on
'!proposal_datum_will_change' so the in-window retract path
bypasses cooldown the same way RemoveAllLocks does.
Test: 87/87 aldabra-dao lib tests pass post-fix (was 86/87).
Lands the high-priority fixes from the 2026-05-06 audit before any
mainnet submit of the new vote/cosign/advance/destroy txs.
## H-1: Locked→Finished gate (MCP tool)
`dao_proposal_advance_unsigned` now refuses LockedToFinished unless
`tx_lower_ms > executing_end`. During the executing period the
validator demands gstMoved=true (governor input present); builder
doesn't include the governor input, so a tx in that window would
fee-burn. The proper Locked→Finished + GAT-mint flow is Phase 4c-bis;
this gate keeps us out of the broken middle.
## H-2 + H-4: strict-boundary + tx-upper-inside-period (MCP tool)
Validator's pgetRelation is strict on PAfter (`period_end < lb`)
and demands `ub <= period_end` on PWithin. Tool now picks PWithin
only when `tx_lower_ms >= period_start && tx_upper_ms <= period_end`,
PAfter only when `tx_lower_ms > period_end` strictly, and explicit-
errors on the boundary-straddling case (when tx validity range
crosses out of the target period). Same logic mirrored for the
VotingReady→Locked + VotingReady→Finished branches.
## H-3: vote builder lower-bound preflight (MCP tool)
`dao_proposal_vote_unsigned` previously checked only validity_upper
vs voting_end_ms. Validator demands BOTH `voting_start <= lb` AND
`ub <= voting_end`. Vote-too-early would hit "too early or invalid"
script error. New preflight on tx_lower_ms vs voting_start.
## M-2: DRep deposit pulled from ProtocolParams
Hardcoded constant (500 ADA) was wrong if the protocol changes
drep_deposit OR if the DRep was originally registered at a different
deposit amount (deregistration must match registration). Added
`drep_deposit_lovelace: u64` to ProtocolParams (default 500 ADA),
governance.rs build_signed_drep_registration / deregistration now
read from params instead of the constant. Constant kept for
backward compat with a doc note pointing at the params field.
## Pallas fork bump 507fd9da → 8091abd1
M-4 from the audit landed on the fork: voting_procedures builder
debug_assert_ne!s against empty CBOR map (0xa0) and docs the
upstream NonEmptyKeyValuePairs::decode footgun.
L-1 from the audit was a false finding — the audit subagent
misread the constants. PROPOSAL_CREATE_*_EX_UNITS are already at
the post-2026-05-05-H-2 values (5M mem / 2G steps per spend, 2M / 1G
per mint). The new builders alias these correctly. No change needed.
Per rescope 2026-05-06: real code repos get SSH for git auth, no
embedded credentials in URLs at all. Companion to commit a3a8421
which dropped the embedded token; this drops the HTTP transport
in favor of pure SSH key auth.
- Cargo.toml [patch.crates-io] URLs now ssh://git@192.168.0.5:23/...
- Cargo.lock source URLs match.
- .cargo/config.toml [net] git-fetch-with-cli = true unchanged —
still required so cargo defers to system git, which uses the
configured SSH identity.
Hosts that build aldabra need:
- /root/.ssh/config alias 'gitea' → 192.168.0.5:23, IdentityFile
pointing at a key registered to the kayos Gitea account
- The corresponding private key
The ed25519 key generated for this is at
/root/.openclaw/keys/id_ed25519_kayos_gitea on the dev box. Pubkey
registered on kayos's Gitea account 2026-05-06. The crafting-table
runner on Lucy still uses an HTTP credential helper for now — that's
operational state in a kayos-controlled container, allowed under
the rescope. Will migrate to SSH later if Cobb wants full parity.
Hard rule from Cobb 2026-05-06: zero secrets hardcoded in committed
source. The [patch.crates-io] block had the kayos Gitea PAT embedded
in the URL, which cargo then duplicated into Cargo.lock's source URLs.
Fix:
- Cargo.toml [patch.crates-io] URLs are now tokenless
(http://192.168.0.5:3001/...)
- Cargo.lock source URLs scrubbed to match
- .cargo/config.toml adds [net] git-fetch-with-cli = true so cargo
defers to system git for fetches; system git authenticates via
the user's git credential helper (~/.git-credentials chmod 600).
Operators (devs + crafting-table runner) need a working git credential
helper for the LAN Gitea, configured out-of-band (NOT in this repo).
Pattern: `git config --global credential.helper store` +
`echo http://USER:TOKEN@192.168.0.5:3001 > ~/.git-credentials &&
chmod 600 ~/.git-credentials`. After Cobb rotates the kayos PAT,
update that file on every host that builds aldabra.
Phase 6, key-credentialed slice (script-DRep bridge for the DAO is the
remaining sub-arc).
## pallas-fork patch (Sulkta-Coop/pallas feat-aux-data HEAD 507fd9da)
Threads voting_procedures through StagingTransaction → conway::
build_conway_raw, mirroring the auxiliary_data + certificates patches.
- pallas-txbuilder/src/transaction/model.rs: voting_procedures field +
builder methods .voting_procedures() / .clear_voting_procedures()
- pallas-txbuilder/src/conway.rs: VotingProcedures::decode_fragment on
the way out, assigned to TransactionBody.voting_procedures
- BRANCH-NOTES.md: section 3 added documenting the new patch
- 2 new tests (round-trip + negative path) on the txbuilder side
aldabra Cargo.lock SHAs bumped to the new HEAD.
## aldabra-core/src/governance.rs
- VoteChoice enum (Yes/No/Abstain) with into_pallas() conversion
- build_signed_drep_vote_cast — assembles VotingProcedures CBOR
(NonEmptyKeyValuePairs<Voter, NonEmptyKeyValuePairs<GovActionId,
VotingProcedure>>) with this wallet's stake credential as a
Voter::DRepKey, attaches via the new pallas API, dual-witness signs.
- Optional CIP-100 anchor on the vote.
## aldabra-mcp/src/tools.rs
- wallet_drep_vote_cast tool: gov_action_tx_hash + gov_action_index +
vote (yes/no/abstain) + optional anchor.
What's still scope-of-Phase-6:
- Script-credentialed DRep voting (the DAO governor as DRep, with
redeemer-driven authorization). Needs a different signing path
since the voter is a script credential, not a key credential.
Separate builder; defer until Sulkta wants to actually bridge.
HIGH:
- HIGH-1 enforce_value_cap helper applied to wallet.send,
wallet.mint, wallet.mint.cip68_nft, wallet.script.spend. each
gained a `force` arg; cap also covers the user_lovelace+ref_lovelace
sum on cip68_nft. wallet.stake.delegate skipped (2 ada deposit is
protocol-fixed, not a transfer to a non-wallet destination).
- HIGH-2 wallet.tx_summary mcp tool — read-only decode of a conway
tx cbor → typed TxSummary (inputs, outputs+assets, fee, certs,
mint, witness count, aux-data presence). new aldabra-core::inspect
module. callers MUST run this before wallet.sign_partial /
wallet.submit_signed_tx on any cbor they didn't build themselves.
MEDIUM:
- M-1 zeroize stack-resident extended_bytes after SecretKeyExtended
consumes them. tx.rs::payment_key_to_private + sign.rs::add_witness.
- M-2 atomic 0o600 mnemonic file create via OpenOptions+
OpenOptionsExt. removes the prior toctou window between fs::write
(default umask) and chmod 600.
- M-3 prompt_or_env_passphrase + unlock_passphrase helpers wrap the
passphrase in Zeroizing<String>. ALDABRA_PASSPHRASE env still
unzeroizable in the env block itself (documented headless tradeoff).
- M-4 is_hex_64 validator on submit_tx response — koios error wrapped
in quotes can no longer round-trip as a fake tx_hash.
LOW + cleanup:
- L-1 checked_add for inner sums of checked_sub patterns in tx.rs.
remaining sites (mint.rs, stake.rs, plutus.rs) deferred — same
pattern, can't overflow with realistic cardano amounts but
defensive. picked up next.
- L-2 root key scoped to a block in main.rs — XPrv drops + wipes
after deriving payment_key + stake_key + address. saves ~96 bytes
of secret material lifetime.
- L-3 TxStatus gained a Pending variant for the mempool-but-not-yet-
confirmed case. previously rendered as Confirmed{block_height: None}
which was misleading.
- L-4 .expect("we built this key") → typed ? propagation in
tx.rs::prepare_payment.
- L-5 removed dead fns (build_and_sign, decode_hex) + unused imports.
WALLET GENERATION (audit prompted gap-find):
aldabra had only an import path. no "generate fresh wallet" tool.
- Mnemonic::generate() — bip39::Mnemonic::generate_in(English, 24)
with the rand feature. returns (Mnemonic, Zeroizing<String>) so
the caller can display the phrase once for cold backup.
- aldabra --generate-mnemonic — print fresh phrase, exit. no disk.
- aldabra --bootstrap-new — generate + display + encrypt one-shot.
- bip39 dep gains the rand feature for OsRng-backed generation.
- standard 24-word BIP-39, recoverable from any cardano wallet.
mcp tools: 16 → 17 (added wallet.tx_summary).
unit tests: 88 → 93. cargo audit clean (0 cves), cargo build clean
(0 warnings). all four cli flags smoke-tested:
--generate-mnemonic prints + exits; --bootstrap-new generates +
encrypts + derives a real preprod address; mnemonic.age has 0o600
perms confirmed atomic.
audit doc memory/spec-aldabra-audit-2026-05-04.md updated with
status markers.
stake key + reward address (4.5):
- StakeKey::stake_address(network) — bech32 (`stake1...` mainnet,
`stake_test1...` testnet) via pallas_addresses::StakeAddress::new
(added to the fork in the same commit since the upstream tuple
struct had no public constructor).
- StakeKey::xprv() — crate-internal accessor for signing.
- WalletInner now holds the stake_key alongside the payment_key.
- mcp tool wallet.stake.address surfaces the bech32.
stake delegation (4.6):
- new aldabra-core::stake module:
- parse_pool_id(bech32) → Hash<28>
- build_signed_stake_delegation(payment, stake, network, utxos,
change_addr, pool_bech32, register_first, params) → signed cbor.
- if register_first: prepends a StakeRegistration cert (consumes
a 2 ADA deposit from inputs). otherwise just delegates.
- signs with both payment_key (body witness) and stake_key (cert
witness). reuses sign::add_witness for both — same body-hash
ed25519 signing path regardless of CIP-1852 chain index.
- mcp tool wallet.stake.delegate: pool_id, register_first (defaults
true). signs + submits.
3.6 close-out — wallet.mint.unsigned mcp tool:
- exposes the existing build_unsigned_mint with caller-supplied
PolicySpec (json), so multi-sig / treasury flows can build through
this wallet without it auto-signing. round-trip with
wallet.sign_partial chain → wallet.submit_signed_tx.
depends on Sulkta-Coop/pallas@feat-aux-data which gained two more
patches in the same branch:
- StakeAddress::new public constructor.
- StagingTransaction::add_certificate / clear_certificates +
Conway::build_conway_raw decode-and-plumb for certs (filling in the
`certificates: None, // TODO` upstream).
mcp tools: 12 → 15 (wallet.stake.address, wallet.stake.delegate,
wallet.mint.unsigned).
79 → 84 unit tests. new coverage: stake address bech32 round-trip,
pool_id bech32 parse + reject-wrong-hrp, delegation tx with + without
registration (asserts cert count, witness count, cert variants).
fork tests grew: certificates_plumb_through_to_tx_body and
no_certificates_means_none.
unblocks named mints. wallet.mint now accepts an optional `metadata`
arg (json object); explorers + wallets render the asset with name/image
instead of <asset1xyz...>.
new aldabra-core::metadata module:
- json_to_metadatum: serde_json::Value → Metadatum (recursive). numbers
must fit i64 (cardano metadata Int width). strings >64 bytes split
into Array<Text> chunks at utf-8 char boundaries (CIP-25 v2
long-string convention). null is rejected.
- build_cip25_aux_data(policy_id_hex, asset_name_hex, json_value):
builds the label-721 wrapper (Map { 721: Map { policy_bytes:
Map { name_bytes: attrs }, "version": "2.0" } }), wraps in
AuxiliaryData::PostAlonzo, returns cbor bytes.
mint module:
- new build_signed_mint_with_metadata + build_unsigned_mint now take
optional cip25_metadata. backward-compat: build_signed_mint is a
thin no-metadata wrapper.
- prepare_mint + build_mint_staging plumb aux_data_cbor through.
staging.auxiliary_data(bytes) is the new fork API surface — when
set, conway::build_conway_raw decodes + computes
auxiliary_data_hash automatically.
- regression test build_signed_mint_with_metadata_produces_aux_hash:
decodes the resulting signed cbor, asserts both
body.auxiliary_data_hash is Some and tx.auxiliary_data is present.
catches the failure mode where metadata is silently dropped.
mcp wallet.mint gains a `metadata` arg field surfaced via schemars
JsonSchema. tools/list shape correctly carries the optional json
object.
depends on Sulkta-Coop/pallas@feat-aux-data — vendored via
[patch.crates-io] in the workspace Cargo.toml. PR upstream pending.
56 → 65 unit tests. 8 → 8 mcp tools (count unchanged, wallet.mint
gained an arg).
new aldabra-core::mint module:
- PolicySpec enum: SingleSig, SingleSigTimelock, NofK
- SingleSig{pkh}: ScriptPubkey native script
- SingleSigTimelock{pkh, slot}: ScriptAll[ScriptPubkey, InvalidHereafter(slot)]
- NofK{n, [pkhs]}: ScriptNOfK
- PolicySpec::single_sig(payment) + single_sig_timelock(payment, slot)
convenience constructors that derive the pkh from a PaymentKey.
- policy_id() = pallas_traverse::ComputeHash<28>::compute_hash, which
is blake2b-224 of (0x00 || cbor) — the canonical native-script hash.
- to_cbor() for callers that want the script bytes raw.
build_signed_mint / build_unsigned_mint:
- two-pass fee like the send path, plus a few extras specific to mint:
staging.mint_asset(policy, name, qty), .script(Native, cbor),
.disclosed_signer(payment_pkh) — the disclosed_signer surfaces the
required signature in the tx body so the chain knows which witness
to verify against the script.
- positive qty mints (asset goes into dest output), negative qty burns
(asset comes out of input holdings, change preserves leftover).
- token-bearing change must hold ≥ min_utxo lovelace — same guard as
the send path.
mcp tools:
- wallet.policy.create — args: invalid_after_slot? — returns
{policy_id_hex, script_cbor_hex, type}.
- wallet.mint — args: dest_address, dest_lovelace (≥ 1 ADA),
asset_name_hex, quantity (i64), invalid_after_slot? — auto-generates
a single-sig policy bound to the wallet's payment key, builds, signs,
submits.
8 → 10 mcp tools. 48 → 56 unit tests.
3.2 (CIP-25 metadata) is BLOCKED on pallas-txbuilder 0.32/0.35 — both
hardcode `auxiliary_data: None` in the conway builder. options for next
session: (a) post-build CBOR injection, (b) assemble tx via
pallas-primitives directly, (c) wait for upstream. flagged in the
spec doc.
3.3 (CIP-68) depends on 3.2. 3.6 (MAP 2-of-2) needs the multi-key
signing flow on the build side; PolicySpec::NofK variant is ready but
build_signed_mint only sign with one key today.
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.