Files
hermes-agent/wallet/approval.py
Shannon Sands 253c7abbe9 fix(wallet): harden keystore fallback, persist policy/history, wire gateway injection
Addresses review findings:
- Remove insecure automatic encrypted-file credential-store fallback.
   now only uses real OS/keyctl-backed stores,
  or remains unavailable. Headless users must use explicit
  HERMES_KEYSTORE_PASSPHRASE if desired.
- Add shared wallet runtime so tools/CLI/approval use the same configured
  providers and persisted policy state.
- Inject keystore-backed secrets into gateway/headless startup too, so
  migrated .env stubs don't break messaging deployments.
- Persist wallet policy state (freeze, daily totals, rate-limit timestamps,
  cooldown timestamps) across invocations.
- Persist transaction history to disk across invocations.
- Make owner-approved sends execute through the same runtime/policy path and
  record policy state after successful approved sends.
- Fix wallet export by allowing explicit CLI export reads of sealed keys via
  dedicated requester path () instead of generic CLI reads.
- Make CLI wallet sends evaluate policy before execution and honor freeze.
- Align docs with actual crypto primitive (XSalsa20-Poly1305 via SecretBox)
  and current policy-config scope.

Validation:
- 129 tests passing
- freeze persistence verified manually
- wallet export verified manually
2026-03-29 08:38:29 +10:00

133 lines
4.1 KiB
Python

"""Wallet transaction approval — pending state and execution.
Mirrors the dangerous-command approval pattern in tools/approval.py
but for wallet transactions. When wallet_send hits a ``require_approval``
policy verdict, the transaction details are stashed here. The CLI or
gateway then prompts the user and calls ``execute_approved()`` to
actually send it.
Thread-safe: all state is guarded by a lock.
"""
import json
import logging
import threading
import time
from dataclasses import asdict, dataclass
from decimal import Decimal
from typing import Optional
logger = logging.getLogger(__name__)
_lock = threading.Lock()
_pending: dict[str, dict] = {} # session_key → tx details
@dataclass
class PendingWalletTx:
"""A wallet transaction awaiting owner approval."""
wallet_id: str
chain: str
from_address: str
to_address: str
amount: str # Decimal as string
symbol: str
wallet_label: str
wallet_type: str
timestamp: float = 0.0
def to_dict(self) -> dict:
return asdict(self)
def summary(self) -> str:
return f"Send {self.amount} {self.symbol}{self.to_address} on {self.chain}"
def submit_pending(session_key: str, tx: PendingWalletTx) -> None:
"""Stash a transaction for user approval."""
tx.timestamp = time.time()
with _lock:
_pending[session_key] = tx.to_dict()
logger.info("Wallet tx pending approval [%s]: %s", session_key, tx.summary())
def pop_pending(session_key: str) -> Optional[dict]:
"""Retrieve and remove a pending wallet transaction."""
with _lock:
return _pending.pop(session_key, None)
def has_pending(session_key: str) -> bool:
"""Check if a session has a pending wallet transaction."""
with _lock:
return session_key in _pending
def execute_approved(session_key: str, pending: dict) -> str:
"""Execute an approved wallet transaction.
Uses the shared wallet runtime so approvals go through the same provider
configuration and persisted policy state as normal tool execution.
"""
try:
from wallet.runtime import get_runtime
from wallet.policy import TxRequest, PolicyVerdict
mgr, policy = get_runtime()
if mgr is None:
return json.dumps({"error": "Keystore is locked"})
wallet_id = pending["wallet_id"]
to_address = pending["to_address"]
amount = Decimal(pending["amount"])
tx_req = TxRequest(
wallet_id=wallet_id,
wallet_type=pending.get("wallet_type", "user"),
chain=pending["chain"],
to_address=to_address,
amount=amount,
symbol=pending["symbol"],
)
# Re-evaluate policies at execution time so freeze/cumulative limits
# still apply. Approval only overrides the require_approval verdict.
eval_result = policy.evaluate(tx_req)
if eval_result.verdict == PolicyVerdict.BLOCK:
return json.dumps({
"status": "blocked",
"error": eval_result.reason,
"policy": eval_result.failed,
})
result = mgr.send(
wallet_id,
to_address,
amount,
decided_by="owner_approved",
policy_result=json.dumps({
"verdict": eval_result.verdict.value,
"checked": eval_result.checked,
"failed": eval_result.failed,
"approved_via": "owner",
}),
)
if result.status == "failed":
return json.dumps({"status": "failed", "error": result.error})
policy.record_transaction(tx_req)
return json.dumps({
"status": "submitted",
"tx_hash": result.tx_hash,
"explorer_url": result.explorer_url,
"chain": result.chain,
"amount": pending["amount"],
"symbol": pending["symbol"],
"to": to_address,
})
except Exception as e:
logger.error("Failed to execute approved wallet tx: %s", e)
return json.dumps({"error": f"Transaction execution failed: {e}"})