Compare commits

...

17 Commits

Author SHA1 Message Date
Shannon Sands
b2bb11ab4a fix(keystore): reorder unlock priority — interactive prompt before env var
The env var HERMES_KEYSTORE_PASSPHRASE is now correctly positioned as a
last-resort fallback for headless/Docker/systemd deployments, not as the
second-choice unlock method.

New unlock priority:
1. OS credential store (hermes keystore remember)
2. Interactive passphrase prompt (when TTY available)
3. HERMES_KEYSTORE_PASSPHRASE env var (headless fallback only)

Updated docs and code comments to clearly communicate this is a conscious
security tradeoff for unattended operation, not the recommended path.
2026-03-29 08:38:29 +10:00
Shannon Sands
24852c6789 fix(wallet): auto-unlock keystore in wallet runtime for CLI/headless use
get_runtime() now calls ensure_unlocked(interactive=False) when the
keystore is initialized but locked, so HERMES_KEYSTORE_PASSPHRASE and
credential-store-cached passphrases work for wallet CLI commands without
requiring a separate unlock step.

Found during Linux sandbox testing where 'hermes wallet status' failed
with KeystoreLocked despite the env var being set.
2026-03-29 08:38:29 +10:00
Shannon Sands
4f419585b1 docs(scope): narrow gateway refresh comments to current .env-backed behavior 2026-03-29 08:38:29 +10:00
Shannon Sands
22aadaa56f fix(gateway): source external precedence from refresh inputs, not value equality
Reworks the refresh path to use explicit external-managed names supplied by
gateway orchestration, instead of trying to infer ownership transitions from
env var value equality.

Changes:
- KeystoreClient.inject_env() now accepts external_managed_names for force
  refreshes.
- Gateway refresh computes external-managed names from .env for the current
  cycle and passes them into keystore injection.
- Revocation now clears deleted keystore-backed vars only when they are not
  externally managed this cycle.

Regression coverage added for:
- external replacement with different value surviving delete+refresh
- external replacement with the SAME value surviving delete+refresh
- deleted keystore secret being revoked when no external source replaces it

Validation: 140 targeted tests passing
2026-03-29 08:38:29 +10:00
Shannon Sands
79d7cec37a fix(gateway): preserve external replacements on keystore secret revocation
Track the last keystore-injected value for each owned env var. During force
refresh, revoke a deleted keystore-backed env var only if the current process
env still matches the last injected value. If an external source has supplied
its own replacement in the meantime, preserve that replacement instead of
unsetting it.

Adds a regression test covering deletion of a keystore-backed secret after an
external replacement value has been loaded into the long-lived gateway process.
2026-03-29 08:38:29 +10:00
Shannon Sands
712bdfb949 fix(gateway): revoke deleted keystore-backed env vars on refresh
Force-refresh now also clears env vars that were previously injected by the
keystore but no longer exist in the current injectable secret set. This lets
credential deletion/revocation propagate in long-lived gateway processes
without restart, while still preserving external env precedence.

Adds a regression test covering deletion of a keystore-backed OPENAI_API_KEY
followed by gateway refresh.
2026-03-29 08:38:29 +10:00
Shannon Sands
5b16fa8621 fix(gateway): preserve external env precedence during keystore refresh
Refines force-refresh semantics so rotated keystore secrets only overwrite
variables that were previously injected by the keystore. Externally supplied
env vars (shell/Docker/systemd) remain authoritative across the life of the
process, matching startup precedence.

Also adds a mixed-precedence regression test covering the case where an
external OPENAI_API_KEY is present alongside an initialized keystore.
2026-03-29 08:38:29 +10:00
Shannon Sands
fe325c1b40 fix(gateway): overwrite stale env vars on keystore-backed refresh
The gateway refresh path now calls keystore injection with force=True so
rotated secrets replace stale in-process env vars without requiring a
restart. Startup paths still keep the default non-overwriting behavior so
shell exports and explicitly supplied env vars win on boot.

Also tighten the regression test to require the rotated keystore secret to
replace a stale env value during refresh, instead of accepting either old
or new values.
2026-03-29 08:38:29 +10:00
Shannon Sands
d83ea4883b fix(gateway): inject keystore secrets without config.yaml and on refresh
Addresses final gateway keystore gap:
- move keystore injection outside the config.yaml existence branch so
  gateway/headless installs with only a keystore (and a stubbed .env)
  still receive credentials on import/startup
- re-run keystore injection in the long-lived gateway credential refresh
  path so rotated keystore secrets can take effect without restart
- fix keystore store methods to use short-lived sqlite connections instead
  of a persistent connection, avoiding database-locked failures during
  injectable secret reads from fresh processes
- add gateway regression tests for startup without config.yaml and refresh-
  path reinjection of keystore-backed secrets

Validation: targeted suite now 136 passing
2026-03-29 08:38:29 +10:00
Shannon Sands
07808ca7f5 fix(wallet): resolve review issues around persistence, policy ordering, and duplicate wallets
Addresses follow-up review findings:
- Cross-process persistence now uses locked read/modify/write helpers
  (wallet/file_state.py) instead of load-once/overwrite-whole-file writes.
  Wallet tx history and policy state refresh from disk and merge updates
  across CLI/gateway processes.
- Hard-block policies now run before require_approval. User wallets can no
  longer bypass spending limits, blocklists, daily caps, or cooldowns just
  by requesting owner approval.
- Duplicate wallets for the same chain/address are rejected on create/import.
  delete_wallet() now removes key material only when no remaining metadata
  references that address.
- Wallet export remains explicit via cli_export requester.
- Keystore docs/code now consistently describe SecretBox as XSalsa20-Poly1305.

Regression coverage added for:
- no insecure credential-store fallback
- tx history merge across manager instances
- policy state merge across engine instances
- user-wallet hard-block precedence over require_approval
- duplicate-wallet rejection and shared-key deletion safety

Validation: 134 targeted tests passing
2026-03-29 08:38:29 +10:00
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
Shannon Sands
3fef2fd3ee docs: wallet & keystore documentation
- README.md: Crypto Wallet section with quick start, design highlights,
  and link to full docs. Added wallet row to documentation table.
- website/docs/user-guide/features/wallet.md: Full Docusaurus page covering
  installation, setup, agent tools, CLI commands, keystore commands,
  security model, policy engine, approval flow, supported networks,
  migration, and configuration.
- docs/wallet.md: Concise local reference with all CLI commands, agent
  tools, security summary, and supported chains.
2026-03-29 08:38:29 +10:00
Shannon Sands
7e1a05b475 feat: wallet approval flow, export command, improved create/import UX
Approval system:
- wallet/approval.py: PendingWalletTx stash, submit_pending(), pop_pending(),
  execute_approved() — mirrors the dangerous-command approval pattern
- tools/wallet_tool.py: wallet_send now stashes pending txs when policy
  returns require_approval (using task_id as session key)
- cli.py: Post-agent-loop check for pending wallet approvals, invokes
  wallet_approval_callback for interactive TUI prompt, executes on approve
- hermes_cli/callbacks.py: wallet_approval_callback — TUI prompt showing
  tx details with approve/deny choices (matches approval_callback pattern)
- gateway/run.py: Picks up pending wallet txs after agent response, shows
  approval hint with /approve /deny. /approve handler dispatches wallet tx
  execution via execute_approved().

Export/Import:
- wallet/manager.py: export_private_key() — CLI-only, never agent-exposed
- wallet/cli.py: 'hermes wallet export' with passphrase re-entry confirmation,
  safety warnings, import instructions. Import updated with --type flag and
  migration-focused messaging.

UX improvements:
- Create messaging emphasizes fresh wallets + funding over personal wallet import
- Import framed as migration tool, not personal wallet onboarding

Tested: approval stash/execute path confirmed on Solana mainnet
2026-03-29 08:38:29 +10:00
Shannon Sands
53acc4c238 feat: add wallet_address + wallet_networks tools, config.yaml RPC overrides
New agent-facing tools:
- wallet_address: Get a wallet's deposit address for receiving funds
- wallet_networks: List all supported chains (mainnet + testnet) and
  which ones have active wallets

Improvements:
- RPC endpoint overrides from config.yaml (wallet.rpc_endpoints section)
- Better module docstring with full tool inventory
- Toolset updated with all 7 tools

Tested end-to-end with Hermes agent on Solana mainnet:
- Agent correctly discovers and uses all 7 wallet tools
- Policy engine properly gates user wallet sends (require_approval)
- Balance checks, address sharing, network listing all working
2026-03-29 08:38:29 +10:00
Shannon Sands
182ee2e08e chore: update uv.lock with keystore + wallet dependencies 2026-03-29 08:38:29 +10:00
Shannon Sands
ffefd57719 feat: add wallet module — manager, policy engine, chain providers, tools, CLI
Phase 2 of the wallet architecture — crypto wallet functionality built
on top of the keystore.

Core components:
- wallet/manager.py: Wallet CRUD, balance checks, transaction execution.
  Private keys stored as sealed keystore secrets — only the manager reads
  them, and only to pass to chain providers for signing.
- wallet/policy.py: Transaction policy engine with spending limits, daily
  limits, rate limits, cooldown, recipient allow/blocklists, approval
  thresholds, and a kill switch (freeze/unfreeze).
- wallet/chains/: Abstract ChainProvider interface + EVM and Solana impls.
  EVM supports Ethereum, Base, Polygon, Arbitrum, Optimism + testnets.
  Solana supports mainnet + devnet.

Agent integration:
- tools/wallet_tool.py: 5 agent-facing tools (wallet_list, wallet_balance,
  wallet_send, wallet_history, wallet_estimate_gas). All return JSON,
  none expose private keys. wallet_send goes through the policy engine.
- toolsets.py: New 'wallet' toolset
- model_tools.py: wallet_tool added to discovery list

CLI:
- wallet/cli.py: Full CLI — create, create-agent, import, list, balance,
  send (with interactive confirmation), fund, history, freeze, unfreeze, status
- hermes_cli/main.py: 'hermes wallet' subcommand registered

Policy defaults:
- Agent wallets: 1.0 native/tx max, 5.0/day, 5 txns/hour, 30s cooldown,
  approval required above 0.5 native
- User wallets: owner approval required for all transactions

Tests: 100 passing (28 wallet + 72 keystore)
2026-03-29 08:38:21 +10:00
Shannon Sands
8fd434037e feat: add encrypted keystore for secret management
Phase 1 of the wallet architecture — a general-purpose encrypted
secret store that replaces plaintext .env for sensitive values.

Core components:
- keystore/store.py: Encrypted SQLite store (Argon2id KDF + XChaCha20-Poly1305 AEAD)
- keystore/credential_store.py: Cross-platform passphrase caching
  (macOS Keychain, Windows Credential Locker, Linux kernel keyctl,
  encrypted file fallback — runtime detection, no hard OS dependency)
- keystore/client.py: High-level API with unlock flow, env injection, migration
- keystore/categories.py: Secret access categories (injectable/gated/sealed/user_only)
- keystore/cli.py: Full CLI (hermes keystore init/list/set/show/delete/migrate/remember/forget/audit)

Integration:
- hermes_cli/main.py: Auto-inject keystore secrets before CLI startup
- pyproject.toml: keystore/wallet/wallet-solana optional dependency groups
- AGENTS.md: Updated project structure docs

Security model:
- Master key derived from passphrase via Argon2id (64MB memory-hard)
- Per-secret encryption with XChaCha20-Poly1305 (random nonce per write)
- Category-based access control (sealed secrets never exposed to agent)
- Full access audit log
- Backward compatible — graceful fallback to .env when keystore not initialized

Tests: 72 passing (store, client, credential_store, categories)
2026-03-29 08:38:21 +10:00
42 changed files with 8666 additions and 4 deletions

5
.gitignore vendored
View File

@@ -58,3 +58,8 @@ mini-swe-agent/
# Nix
.direnv/
result
# Keystore (encrypted secrets — never commit)
keystore/secrets.db
keystore/.credential
*.db-journal

File diff suppressed because it is too large Load Diff

View File

@@ -57,13 +57,19 @@ hermes-agent/
│ ├── session.py # SessionStore — conversation persistence
│ └── platforms/ # Adapters: telegram, discord, slack, whatsapp, homeassistant, signal
├── acp_adapter/ # ACP server (VS Code / Zed / JetBrains integration)
├── keystore/ # Encrypted secret store (optional: pip install .[keystore])
│ ├── store.py # Core encrypted SQLite store (Argon2id + XChaCha20-Poly1305)
│ ├── credential_store.py # Cross-platform passphrase caching (Keychain/DPAPI/keyctl/file)
│ ├── client.py # High-level API (unlock, inject_env, migrate)
│ ├── categories.py # Secret access categories (injectable/gated/sealed/user_only)
│ └── cli.py # `hermes keystore` subcommands
├── cron/ # Scheduler (jobs.py, scheduler.py)
├── environments/ # RL training environments (Atropos)
├── tests/ # Pytest suite (~3000 tests)
└── batch_runner.py # Parallel batch processing
```
**User config:** `~/.hermes/config.yaml` (settings), `~/.hermes/.env` (API keys)
**User config:** `~/.hermes/config.yaml` (settings), `~/.hermes/.env` (API keys — or encrypted keystore if enabled)
## File Dependency Chain

View File

@@ -99,6 +99,7 @@ All documentation lives at **[hermes-agent.nousresearch.com/docs](https://hermes
| [MCP Integration](https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp) | Connect any MCP server for extended capabilities |
| [Cron Scheduling](https://hermes-agent.nousresearch.com/docs/user-guide/features/cron) | Scheduled tasks with platform delivery |
| [Context Files](https://hermes-agent.nousresearch.com/docs/user-guide/features/context-files) | Project context that shapes every conversation |
| [Wallet](https://hermes-agent.nousresearch.com/docs/user-guide/features/wallet) | Crypto wallet — keystore, transactions, policies, approval flow |
| [Architecture](https://hermes-agent.nousresearch.com/docs/developer-guide/architecture) | Project structure, agent loop, key classes |
| [Contributing](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) | Development setup, PR process, code style |
| [CLI Reference](https://hermes-agent.nousresearch.com/docs/reference/cli-commands) | All commands and flags |
@@ -106,6 +107,38 @@ All documentation lives at **[hermes-agent.nousresearch.com/docs](https://hermes
---
## Crypto Wallet
Give your agent its own wallet. Hermes can hold funds, check balances, and send transactions on Solana and EVM chains — with encrypted key storage and policy-controlled spending limits.
```bash
pip install 'hermes-agent[wallet]' # EVM (Ethereum, Base, Polygon, etc.)
pip install 'hermes-agent[wallet-solana]' # + Solana support
```
**Quick start:**
```bash
hermes keystore init # Set a master passphrase (one-time)
hermes wallet create --chain solana # Create a fresh wallet
hermes wallet fund # Shows the deposit address
# Send some tokens to the address, then:
hermes wallet balance # Check it arrived
```
Add `wallet` to your toolsets in `config.yaml` (or `hermes chat -t hermes-cli,wallet`), and the agent gets 7 tools: `wallet_list`, `wallet_balance`, `wallet_address`, `wallet_send`, `wallet_history`, `wallet_estimate_gas`, `wallet_networks`.
**Key design:**
- 🔐 Private keys are encrypted at rest (Argon2id + XSalsa20-Poly1305 via libsodium SecretBox) and never exposed to the agent
- 📋 Policy engine enforces spending limits, rate limits, and approval thresholds
-**User wallets** require owner approval for every transaction
- 🤖 **Agent wallets** (`hermes wallet create-agent`) auto-approve within configurable limits
- 🔒 Kill switch: `hermes wallet freeze` blocks everything instantly
- 📦 Migration: `hermes wallet export` / `hermes wallet import` to move between machines
See the [full wallet documentation](https://hermes-agent.nousresearch.com/docs/user-guide/features/wallet) for details.
---
## Migrating from OpenClaw
If you're coming from OpenClaw, Hermes can automatically import your settings, memories, skills, and API keys.

31
cli.py
View File

@@ -5704,6 +5704,37 @@ class HermesCLI:
except Exception:
pass
# Check for pending wallet transaction approvals (like dangerous cmd approval)
try:
from wallet.approval import pop_pending as pop_wallet_pending
wallet_pending = pop_wallet_pending(self.session_id)
if wallet_pending:
from hermes_cli.callbacks import wallet_approval_callback
decision = wallet_approval_callback(self, wallet_pending)
if decision == "approve":
from wallet.approval import execute_approved
tx_result_json = execute_approved(self.session_id, wallet_pending)
import json as _json
tx_result = _json.loads(tx_result_json)
if tx_result.get("status") == "submitted":
amt = tx_result.get("amount", "?")
sym = tx_result.get("symbol", "?")
tx_hash = tx_result.get("tx_hash", "")
explorer = tx_result.get("explorer_url", "")
_cprint(f"\n{_DIM} ✅ Transaction sent: {amt} {sym}{_RST}")
if tx_hash:
_cprint(f"{_DIM} TX: {tx_hash}{_RST}")
if explorer:
_cprint(f"{_DIM} {explorer}{_RST}")
else:
_cprint(f"\n{_DIM} ❌ Transaction failed: {tx_result.get('error', '?')}{_RST}")
else:
_cprint(f"\n{_DIM} ❌ Transaction denied{_RST}")
except ImportError:
pass # wallet not installed
except Exception as e:
logging.debug("Wallet approval check failed: %s", e)
# Flush any remaining streamed text and close the box
self._flush_stream()

83
docs/wallet.md Normal file
View File

@@ -0,0 +1,83 @@
# Wallet & Keystore
## Overview
Hermes Agent includes an optional crypto wallet with an encrypted keystore. The agent can hold funds, check balances, and send native tokens on Solana and EVM chains — with policy-controlled spending limits and owner approval for transactions.
## Install
```bash
pip install 'hermes-agent[wallet]' # EVM chains
pip install 'hermes-agent[wallet-solana]' # + Solana
```
## Quick Start
```bash
hermes keystore init # Set master passphrase
hermes wallet create --chain solana # Create wallet
hermes wallet fund # Show deposit address
hermes wallet balance # Check balance
```
Enable the `wallet` toolset in `config.yaml` or via `hermes chat -t hermes-cli,wallet`.
## Wallet CLI
| Command | Description |
|---------|-------------|
| `hermes wallet create --chain <chain>` | Create a fresh user wallet |
| `hermes wallet create-agent --chain <chain>` | Create agent wallet (auto-approve within limits) |
| `hermes wallet import --chain <chain>` | Import from exported private key |
| `hermes wallet export` | Export private key for migration |
| `hermes wallet list` | List wallets + balances |
| `hermes wallet balance` | Check balance |
| `hermes wallet send <to> <amount>` | Send tokens (interactive confirmation) |
| `hermes wallet fund` | Show deposit address |
| `hermes wallet history` | Transaction history |
| `hermes wallet freeze` | Kill switch — block everything |
| `hermes wallet unfreeze` | Resume after freeze |
| `hermes wallet status` | Wallet overview |
## Keystore CLI
| Command | Description |
|---------|-------------|
| `hermes keystore init` | Create encrypted keystore |
| `hermes keystore list` | List secrets (names only) |
| `hermes keystore set <name>` | Add/update a secret |
| `hermes keystore show <name>` | Decrypt and display |
| `hermes keystore delete <name>` | Remove a secret |
| `hermes keystore migrate` | Import from `.env` |
| `hermes keystore remember` | Cache passphrase in OS credential store (no insecure file fallback) |
| `hermes keystore forget` | Remove cached passphrase |
| `hermes keystore change-passphrase` | Re-encrypt everything |
| `hermes keystore audit` | Access log |
## Agent Tools
| Tool | Description |
|------|-------------|
| `wallet_list` | List wallets + balances |
| `wallet_balance` | Check specific balance |
| `wallet_address` | Get deposit address |
| `wallet_send` | Send tokens (policy-gated) |
| `wallet_estimate_gas` | Fee estimation |
| `wallet_history` | Transaction log |
| `wallet_networks` | Supported chains |
## Security
- **Encryption:** Argon2id KDF + XSalsa20-Poly1305 per-secret AEAD (libsodium SecretBox)
- **Agent never sees keys:** Private keys are `sealed` — the agent uses tools, not keys
- **Policies:** Spending limits, rate limits, daily caps, approval thresholds, recipient lists
- **User wallets:** Every transaction requires owner approval
- **Agent wallets:** Auto-approve within limits, escalate above threshold
- **Kill switch:** `hermes wallet freeze` — instant, no exceptions
## Supported Chains
**Mainnet:** Ethereum, Base, Polygon, Arbitrum, Optimism, Solana
**Testnet:** Ethereum Sepolia, Base Sepolia, Solana Devnet
Custom RPC endpoints via `wallet.rpc_endpoints` in `config.yaml`.

View File

@@ -81,11 +81,44 @@ _hermes_home = get_hermes_home()
# Load environment variables from ~/.hermes/.env first.
# User-managed env files should override stale shell exports on restart.
from dotenv import load_dotenv # backward-compat for tests that monkeypatch this symbol
from dotenv import load_dotenv, dotenv_values # backward-compat for tests that monkeypatch this symbol
from hermes_cli.env_loader import load_hermes_dotenv
_env_path = _hermes_home / '.env'
load_hermes_dotenv(hermes_home=_hermes_home, project_env=Path(__file__).resolve().parents[1] / '.env')
def _external_env_names_from_dotenv(path: Path) -> set[str]:
try:
vals = dotenv_values(path)
return {str(k) for k, v in (vals or {}).items() if k and v not in (None, "")}
except Exception:
return set()
def _inject_keystore_env(force: bool = False, external_managed_names: set[str] | None = None) -> None:
"""Inject keystore-backed secrets into os.environ when available.
Args:
force: If True, refresh previously keystore-injected values.
external_managed_names: Names sourced externally during the current
gateway refresh cycle (currently `.env`-tracked names passed by
gateway orchestration). These remain authoritative even during
forced refresh.
Runs independently of config.yaml so gateway/headless deployments using
only a keystore (with stubbed .env) still receive credentials.
"""
try:
from keystore.client import get_keystore
_ks = get_keystore()
if _ks.is_initialized and _ks.ensure_unlocked(interactive=False):
_ks.inject_env(force=force, external_managed_names=external_managed_names)
except ImportError:
pass
except Exception as _e:
logging.getLogger(__name__).debug("Gateway keystore injection skipped: %s", _e)
# Bridge config.yaml values into the environment so os.getenv() picks them up.
# config.yaml is authoritative for terminal settings — overrides .env.
_config_path = _hermes_home / 'config.yaml'
@@ -136,6 +169,7 @@ if _config_path.exists():
os.environ[_env_var] = str(_val)
# Compression config is read directly from config.yaml by run_agent.py
# and auxiliary_client.py — no env var bridging needed.
# Auxiliary model/direct-endpoint overrides (vision, web_extract).
# Each task has provider/model/base_url/api_key; bridge non-default values to env vars.
_auxiliary_cfg = _cfg.get("auxiliary", {})
@@ -194,6 +228,11 @@ if _config_path.exists():
except Exception:
pass # Non-fatal; gateway can still run with .env values
# Inject keystore-backed secrets regardless of whether config.yaml exists.
# This lets headless / gateway-only installs run with a stubbed .env after
# secrets are migrated into the encrypted keystore.
_inject_keystore_env()
# Gateway runs in quiet mode - suppress debug output and use cwd directly (no temp dirs)
os.environ["HERMES_QUIET"] = "1"
@@ -2602,6 +2641,32 @@ class GatewayRunner:
response = (response or "") + approval_hint
except Exception as e:
logger.debug("Failed to check pending approvals: %s", e)
# Check for pending wallet transaction approvals
try:
from wallet.approval import pop_pending as pop_wallet_pending
import time as _wtime
wallet_pending = pop_wallet_pending(session_key)
if wallet_pending:
wallet_pending["timestamp"] = _wtime.time()
wallet_pending["_type"] = "wallet_tx"
self._pending_approvals[session_key] = wallet_pending
amt = wallet_pending.get("amount", "?")
sym = wallet_pending.get("symbol", "?")
to_addr = wallet_pending.get("to_address", "?")
chain = wallet_pending.get("chain", "?")
from_label = wallet_pending.get("wallet_label", "?")
approval_hint = (
f"\n\n💰 **Wallet transaction requires approval:**\n"
f"Send **{amt} {sym}** → `{to_addr}`\n"
f"From: {from_label} on {chain}\n\n"
f"Reply `/approve` to execute or `/deny` to cancel."
)
response = (response or "") + approval_hint
except ImportError:
pass # wallet not installed
except Exception as e:
logger.debug("Failed to check wallet pending approvals: %s", e)
# Save the full conversation to the transcript, including tool calls.
# This preserves the complete agent loop (tool_calls, tool results,
@@ -4358,6 +4423,32 @@ class GatewayRunner:
return "⚠️ Approval expired (timed out after 5 minutes). Ask the agent to try again."
self._pending_approvals.pop(session_key)
# Wallet transaction approval — different dispatch from command approval
if approval.get("_type") == "wallet_tx":
try:
from wallet.approval import execute_approved
result_json = execute_approved(session_key, approval)
import json as _json
result = _json.loads(result_json)
if result.get("status") == "submitted":
tx_hash = result.get("tx_hash", "")
explorer = result.get("explorer_url", "")
amt = result.get("amount", "?")
sym = result.get("symbol", "?")
msg = f"✅ Transaction approved and sent!\n\n"
msg += f"**{amt} {sym}** → `{result.get('to', '')}`\n"
if tx_hash:
msg += f"TX: `{tx_hash}`\n"
if explorer:
msg += f"[View on explorer]({explorer})"
return msg
else:
return f"❌ Transaction failed: {result.get('error', 'unknown error')}"
except Exception as e:
logger.error("Wallet approval execution failed: %s", e)
return f"❌ Transaction execution failed: {e}"
cmd = approval["command"]
pattern_keys = approval.get("pattern_keys", [])
if not pattern_keys:
@@ -5173,6 +5264,16 @@ class GatewayRunner:
except Exception:
pass
# Re-inject keystore secrets too so rotated values take effect
# without requiring a gateway restart. Names explicitly sourced
# from .env during this refresh remain authoritative even when a
# previously keystore-owned secret has the same string value.
try:
_external_names = _external_env_names_from_dotenv(_env_path)
_inject_keystore_env(force=True, external_managed_names=_external_names)
except Exception:
pass
model = _resolve_gateway_model(user_config)
try:

View File

@@ -277,3 +277,68 @@ def approval_callback(cli, command: str, description: str) -> str:
cli._app.invalidate()
cprint(f"\n{_DIM} ⏱ Timeout — denying command{_RST}")
return "deny"
def wallet_approval_callback(cli, tx_details: dict) -> str:
"""Prompt for wallet transaction approval through the TUI.
Shows transaction details and choices: approve / deny.
Mirrors approval_callback() for dangerous commands.
Returns "approve" or "deny".
"""
lock = getattr(cli, "_approval_lock", None)
if lock is None:
import threading
cli._approval_lock = threading.Lock()
lock = cli._approval_lock
with lock:
timeout = 120
response_queue = queue.Queue()
choices = ["approve", "deny"]
amt = tx_details.get("amount", "?")
sym = tx_details.get("symbol", "?")
to_addr = tx_details.get("to_address", "?")
chain = tx_details.get("chain", "?")
wallet_label = tx_details.get("wallet_label", "?")
description = (
f"Send {amt} {sym}{to_addr}\n"
f" From: {wallet_label} on {chain}"
)
cli._approval_state = {
"command": f"💰 Wallet Transaction: {amt} {sym}",
"description": description,
"choices": choices,
"selected": 0,
"response_queue": response_queue,
}
cli._approval_deadline = _time.monotonic() + timeout
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
while True:
try:
result = response_queue.get(timeout=1)
cli._approval_state = None
cli._approval_deadline = 0
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
return result
except queue.Empty:
remaining = cli._approval_deadline - _time.monotonic()
if remaining <= 0:
break
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
cli._approval_state = None
cli._approval_deadline = 0
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
cprint(f"\n{_DIM} ⏱ Timeout — denying transaction{_RST}")
return "deny"

View File

@@ -517,6 +517,25 @@ def cmd_chat(args):
if getattr(args, "source", None):
os.environ["HERMES_SESSION_SOURCE"] = args.source
# Keystore: inject encrypted secrets into os.environ before CLI startup.
# This replaces plaintext .env for secret resolution. Falls back
# gracefully if keystore deps aren't installed or store isn't initialized.
try:
from keystore.client import get_keystore
ks = get_keystore()
if ks.is_initialized:
if ks.ensure_unlocked(interactive=True):
injected = ks.inject_env()
count = sum(1 for v in injected.values() if v)
if count:
logger.debug("Keystore: injected %d secrets", count)
except ImportError:
pass # keystore extras not installed — use .env as before
except KeyboardInterrupt:
sys.exit(0)
except Exception as e:
logger.debug("Keystore unlock skipped: %s", e)
# Import and run the CLI
from cli import main as cli_main
@@ -3117,6 +3136,7 @@ def _coalesce_session_name_args(argv: list) -> list:
"chat", "model", "gateway", "setup", "whatsapp", "login", "logout",
"status", "cron", "doctor", "config", "pairing", "skills", "tools",
"mcp", "sessions", "insights", "version", "update", "uninstall",
"keystore", "wallet",
}
_SESSION_FLAGS = {"-c", "--continue", "-r", "--resume"}
@@ -4317,6 +4337,33 @@ For more help on a command:
sys.exit(1)
acp_parser.set_defaults(func=cmd_acp)
# =========================================================================
# keystore command
# =========================================================================
try:
from keystore.cli import register_subparser as register_keystore
register_keystore(subparsers)
except ImportError:
# keystore deps not installed — register a stub that prints install instructions
_ks_parser = subparsers.add_parser("keystore", help="Manage encrypted secret store (requires keystore extras)")
def _cmd_keystore_stub(args):
print("\n Keystore dependencies not installed.")
print(" Install with: pip install 'hermes-agent[keystore]'\n")
_ks_parser.set_defaults(func=_cmd_keystore_stub)
# =========================================================================
# wallet command
# =========================================================================
try:
from wallet.cli import register_subparser as register_wallet
register_wallet(subparsers)
except ImportError:
_w_parser = subparsers.add_parser("wallet", help="Manage crypto wallets (requires wallet extras)")
def _cmd_wallet_stub(args):
print("\n Wallet dependencies not installed.")
print(" Install with: pip install 'hermes-agent[wallet]'\n")
_w_parser.set_defaults(func=_cmd_wallet_stub)
# =========================================================================
# Parse and execute

15
keystore/__init__.py Normal file
View File

@@ -0,0 +1,15 @@
"""hermes-keystore — encrypted secret store for Hermes Agent.
Provides an encrypted SQLite-backed secret store with per-secret
AEAD encryption (XChaCha20-Poly1305), a master key derived from
a user passphrase via Argon2id, cross-platform credential caching,
and secret categorisation (injectable / gated / sealed / user_only).
Architecture:
keystore/store.py — core encrypted store
keystore/credential_store.py — cross-platform passphrase caching
keystore/client.py — high-level API (unlock, inject, get)
keystore/categories.py — secret category definitions
keystore/migrations.py — DB schema migrations
keystore/cli.py — `hermes keystore` subcommands
"""

111
keystore/categories.py Normal file
View File

@@ -0,0 +1,111 @@
"""Secret categories — access control classifications.
Every secret in the keystore belongs to one of four categories that
determine how and whether the agent process can access it.
"""
from enum import Enum
from typing import Dict
class SecretCategory(str, Enum):
"""Access control classification for keystore secrets."""
INJECTABLE = "injectable"
"""Auto-injected into os.environ at agent startup.
The agent code reads these via os.getenv() as before.
No plaintext file on disk — the daemon populates env vars
in the child process.
Examples: OPENROUTER_API_KEY, FAL_KEY, PARALLEL_API_KEY
"""
GATED = "gated"
"""Available on request through the daemon, with logging.
The agent can ask for these via the keystore client, but every
access is logged. Optionally requires user approval per-access.
Examples: GITHUB_TOKEN, SSH private keys
"""
SEALED = "sealed"
"""Never exposed to the agent process in any form.
The daemon uses these internally (e.g., wallet private keys)
and the agent interacts through session tokens or tool results.
Examples: wallet private keys, master passwords
"""
USER_ONLY = "user_only"
"""Accessible only via the CLI, never by the agent or gateway.
These are secrets the user manages directly and the agent
should never see, even through gated access.
Examples: SUDO_PASSWORD, backup encryption keys
"""
# Default category assignments for known env var names.
# Anything not listed defaults to INJECTABLE for backward compatibility.
DEFAULT_CATEGORIES: Dict[str, SecretCategory] = {
# Provider API keys — injectable (agent needs them for LLM calls)
"OPENROUTER_API_KEY": SecretCategory.INJECTABLE,
"ANTHROPIC_API_KEY": SecretCategory.INJECTABLE,
"OPENAI_API_KEY": SecretCategory.INJECTABLE,
"GLM_API_KEY": SecretCategory.INJECTABLE,
"ZAI_API_KEY": SecretCategory.INJECTABLE,
"Z_AI_API_KEY": SecretCategory.INJECTABLE,
"KIMI_API_KEY": SecretCategory.INJECTABLE,
"MINIMAX_API_KEY": SecretCategory.INJECTABLE,
"MINIMAX_CN_API_KEY": SecretCategory.INJECTABLE,
"OPENCODE_ZEN_API_KEY": SecretCategory.INJECTABLE,
"OPENCODE_GO_API_KEY": SecretCategory.INJECTABLE,
"DASHSCOPE_API_KEY": SecretCategory.INJECTABLE,
"COPILOT_API_KEY": SecretCategory.INJECTABLE,
# Tool API keys — injectable
"PARALLEL_API_KEY": SecretCategory.INJECTABLE,
"FIRECRAWL_API_KEY": SecretCategory.INJECTABLE,
"FAL_KEY": SecretCategory.INJECTABLE,
"BROWSERBASE_API_KEY": SecretCategory.INJECTABLE,
"HONCHO_API_KEY": SecretCategory.INJECTABLE,
# Messaging platform tokens — injectable (gateway needs them)
"TELEGRAM_BOT_TOKEN": SecretCategory.INJECTABLE,
"DISCORD_BOT_TOKEN": SecretCategory.INJECTABLE,
"SLACK_BOT_TOKEN": SecretCategory.INJECTABLE,
"SLACK_APP_TOKEN": SecretCategory.INJECTABLE,
"WHATSAPP_API_TOKEN": SecretCategory.INJECTABLE,
"SIGNAL_HTTP_URL": SecretCategory.INJECTABLE,
"MATTERMOST_TOKEN": SecretCategory.INJECTABLE,
"MATRIX_PASSWORD": SecretCategory.INJECTABLE,
"DINGTALK_CLIENT_ID": SecretCategory.INJECTABLE,
"DINGTALK_CLIENT_SECRET": SecretCategory.INJECTABLE,
"TWILIO_ACCOUNT_SID": SecretCategory.INJECTABLE,
"TWILIO_AUTH_TOKEN": SecretCategory.INJECTABLE,
# Gated — logged access, optional approval
"GITHUB_TOKEN": SecretCategory.GATED,
# User-only — never exposed to agent
"SUDO_PASSWORD": SecretCategory.USER_ONLY,
# Sealed — wallet keys use a different naming convention
# (wallet:chain:address) and are always sealed.
}
def default_category(secret_name: str) -> SecretCategory:
"""Return the default category for a secret name.
Wallet keys (prefixed with ``wallet:``) are always SEALED.
Known env vars use the mapping above.
Everything else defaults to INJECTABLE for backward compatibility.
"""
if secret_name.startswith("wallet:"):
return SecretCategory.SEALED
return DEFAULT_CATEGORIES.get(secret_name, SecretCategory.INJECTABLE)

507
keystore/cli.py Normal file
View File

@@ -0,0 +1,507 @@
"""CLI subcommands for ``hermes keystore``.
Provides:
hermes keystore init — Create a new keystore
hermes keystore list — List stored secrets (no values)
hermes keystore set <name> — Add or update a secret
hermes keystore show <name> — Decrypt and display a secret
hermes keystore delete <name> — Remove a secret
hermes keystore set-category — Change a secret's access category
hermes keystore migrate — Import from .env
hermes keystore remember — Cache passphrase in OS credential store
hermes keystore forget — Remove cached passphrase
hermes keystore change-passphrase — Re-encrypt with a new passphrase
hermes keystore audit — Show access log
hermes keystore status — Show keystore status
"""
import argparse
import getpass
import os
import shutil
import sys
from datetime import datetime
from pathlib import Path
from typing import Optional
try:
from rich.console import Console
from rich.table import Table
_RICH = True
except ImportError:
_RICH = False
def _cprint(msg: str, style: str = "") -> None:
"""Print with optional Rich styling, falling back to plain."""
if _RICH:
Console().print(msg, style=style)
else:
print(msg)
def _get_client():
"""Import and return the keystore client (lazy to avoid import errors
when keystore deps aren't installed)."""
try:
from keystore.client import get_keystore
return get_keystore()
except ImportError as e:
_cprint(
f"\n ✗ Keystore dependencies not installed: {e}\n"
f" Install with: pip install 'hermes-agent[keystore]'\n",
style="bold red",
)
sys.exit(1)
def _require_unlocked(ks, interactive: bool = True) -> None:
"""Ensure the keystore is unlocked or exit."""
from keystore.store import PassphraseMismatch, KeystoreLocked
try:
if not ks.ensure_unlocked(interactive=interactive):
_cprint("\n Keystore not initialized. Run: hermes keystore init\n", style="yellow")
sys.exit(1)
except PassphraseMismatch:
_cprint("\n ✗ Incorrect passphrase\n", style="bold red")
sys.exit(1)
except KeystoreLocked as e:
_cprint(f"\n{e}\n", style="bold red")
sys.exit(1)
# =========================================================================
# Subcommand handlers
# =========================================================================
def cmd_keystore_init(args: argparse.Namespace) -> None:
"""Create a new encrypted keystore."""
from keystore.store import KeystoreError
ks = _get_client()
if ks.is_initialized:
_cprint("\n Keystore already initialized.", style="yellow")
count = ks.secret_count()
_cprint(f" {count} secrets stored.\n")
return
_cprint("\n 🔐 Secure Keystore Setup\n")
_cprint(" Your API keys and secrets will be encrypted with a master passphrase.")
_cprint(" Choose something memorable — you'll need it each time you start Hermes.\n")
passphrase = getpass.getpass(" Passphrase: ")
if not passphrase:
_cprint("\n ✗ Passphrase cannot be empty\n", style="bold red")
sys.exit(1)
confirm = getpass.getpass(" Confirm: ")
if passphrase != confirm:
_cprint("\n ✗ Passphrases don't match\n", style="bold red")
sys.exit(1)
try:
ks.initialize(passphrase)
except KeystoreError as e:
_cprint(f"\n{e}\n", style="bold red")
sys.exit(1)
from keystore.client import _default_db_path
_cprint(f"\n ✓ Keystore created at {_default_db_path()}", style="green")
_cprint("")
_cprint(" 💡 Tip: Run 'hermes keystore remember' to cache your passphrase")
_cprint(" so you don't have to type it every time.\n")
def cmd_keystore_list(args: argparse.Namespace) -> None:
"""List all stored secrets (names and categories, no values)."""
ks = _get_client()
_require_unlocked(ks)
secrets = ks.list_secrets()
if not secrets:
_cprint("\n No secrets stored. Use 'hermes keystore set <name>' to add one.\n")
return
if _RICH:
console = Console()
table = Table(title="Keystore Secrets", show_lines=False)
table.add_column("Name", style="cyan", no_wrap=True)
table.add_column("Category", style="magenta")
table.add_column("Description")
table.add_column("Last Accessed", style="dim")
table.add_column("Accesses", justify="right", style="dim")
_cat_style = {
"injectable": "green",
"gated": "yellow",
"sealed": "red",
"user_only": "blue",
}
for s in secrets:
cat_style = _cat_style.get(s.category, "white")
last = s.last_accessed_at[:10] if s.last_accessed_at else "never"
table.add_row(
s.name,
f"[{cat_style}]{s.category}[/{cat_style}]",
s.description or "",
last,
str(s.access_count),
)
console.print()
console.print(table)
console.print()
else:
print(f"\n {'Name':<35} {'Category':<12} {'Description'}")
print(f" {''*35} {''*12} {''*30}")
for s in secrets:
print(f" {s.name:<35} {s.category:<12} {s.description or ''}")
print()
def cmd_keystore_set(args: argparse.Namespace) -> None:
"""Add or update a secret."""
ks = _get_client()
_require_unlocked(ks)
name = args.name.upper()
value = getpass.getpass(f" Value for {name} (hidden): ")
if not value:
_cprint("\n ✗ Value cannot be empty\n", style="bold red")
sys.exit(1)
category = args.category
description = args.description or ""
ks.set_secret(name, value, category=category, description=description)
_cprint(f"\n ✓ Secret '{name}' stored (category: {category or 'auto'})\n", style="green")
def cmd_keystore_show(args: argparse.Namespace) -> None:
"""Decrypt and display a secret (requires passphrase re-entry)."""
ks = _get_client()
_require_unlocked(ks)
name = args.name.upper()
# Re-verify identity for sealed/user_only secrets
value = ks.get_secret(name, requester="cli")
if value is None:
_cprint(f"\n ✗ Secret '{name}' not found or access denied\n", style="bold red")
sys.exit(1)
_cprint(f"\n {name} = {value}\n")
def cmd_keystore_delete(args: argparse.Namespace) -> None:
"""Remove a secret."""
ks = _get_client()
_require_unlocked(ks)
name = args.name.upper()
if ks.delete_secret(name):
_cprint(f"\n ✓ Secret '{name}' deleted\n", style="green")
else:
_cprint(f"\n ✗ Secret '{name}' not found\n", style="bold red")
def cmd_keystore_set_category(args: argparse.Namespace) -> None:
"""Change a secret's access category."""
from keystore.store import KeystoreError
ks = _get_client()
_require_unlocked(ks)
name = args.name.upper()
category = args.category
try:
if ks.set_category(name, category):
_cprint(f"\n{name}{category}\n", style="green")
else:
_cprint(f"\n ✗ Secret '{name}' not found\n", style="bold red")
except KeystoreError as e:
_cprint(f"\n{e}\n", style="bold red")
def cmd_keystore_migrate(args: argparse.Namespace) -> None:
"""Migrate secrets from .env to the keystore."""
ks = _get_client()
# Initialize if needed
if not ks.is_initialized:
_cprint("\n 🔐 Keystore not initialized — setting up now.\n")
passphrase = getpass.getpass(" Choose a passphrase: ")
if not passphrase:
_cprint("\n ✗ Passphrase cannot be empty\n", style="bold red")
sys.exit(1)
confirm = getpass.getpass(" Confirm: ")
if passphrase != confirm:
_cprint("\n ✗ Passphrases don't match\n", style="bold red")
sys.exit(1)
ks.initialize(passphrase)
_cprint(" ✓ Keystore created\n", style="green")
else:
_require_unlocked(ks)
from keystore.client import _env_file_path
env_path = _env_file_path()
if not env_path.exists():
_cprint(f"\n No .env file found at {env_path}\n", style="yellow")
return
migrated = ks.migrate_from_env(env_path)
if not migrated:
_cprint("\n No secrets found in .env to migrate.\n", style="yellow")
return
_cprint(f"\n 📦 Migrated {len(migrated)} secrets:\n")
for name, category in sorted(migrated.items()):
_cprint(f" {name:<35}{category}")
# Backup and replace .env
if not args.keep_env:
backup_path = env_path.with_suffix(
f".bak.{datetime.now().strftime('%Y%m%d_%H%M%S')}"
)
shutil.copy2(env_path, backup_path)
_cprint(f"\n ✓ Original .env backed up to {backup_path.name}", style="green")
# Write stub
with open(env_path, "w") as f:
f.write(
"# Secrets are now managed by the Hermes encrypted keystore.\n"
"# Run 'hermes keystore list' to see stored secrets.\n"
"# Run 'hermes keystore set <NAME>' to add/update a secret.\n"
"#\n"
"# You can still set env vars here for non-secret config,\n"
"# or export secrets in your shell for CI/Docker environments.\n"
"# Shell exports always take priority over the keystore.\n"
)
_cprint(" ✓ .env replaced with stub (keystore handles secrets now)", style="green")
_cprint(f"\n ✓ Migration complete\n", style="bold green")
_cprint(" Review categories with: hermes keystore list")
_cprint(" Change a category: hermes keystore set-category <NAME> <CATEGORY>\n")
def cmd_keystore_remember(args: argparse.Namespace) -> None:
"""Cache the passphrase in the OS credential store."""
from keystore import credential_store
ks = _get_client()
backend = credential_store.backend_name()
if backend:
_cprint(f"\n Detected: {backend}\n")
else:
_cprint("\n ⚠️ No credential store backend available.\n", style="yellow")
_cprint(" Options:")
_cprint(" • Set HERMES_KEYSTORE_PASSPHRASE env var for headless/Docker")
_cprint(" • Install keyring: pip install keyring")
if sys.platform == "linux":
_cprint(" • Install keyctl: apt install keyutils")
_cprint(" • Type your passphrase each time (most secure)\n")
return
passphrase = getpass.getpass(" Keystore passphrase: ")
if not passphrase:
_cprint("\n ✗ Cancelled\n", style="yellow")
return
success, msg = ks.remember_passphrase(passphrase)
if success:
_cprint(f"\n ✓ Passphrase saved to {msg}", style="green")
_cprint(" To remove: hermes keystore forget\n")
# Backend-specific notes
if "Kernel Keyring" in msg:
_cprint(
" ⚠️ Note: kernel keyring may expire after inactivity.\n"
" For always-on gateway deployments, consider\n"
" HERMES_KEYSTORE_PASSPHRASE env var instead.\n",
style="dim",
)
elif "Encrypted File" in msg:
_cprint(
" ⚠️ This uses machine-derived encryption.\n"
" Less secure than a system keychain, but works everywhere.\n",
style="dim",
)
else:
_cprint(f"\n{msg}\n", style="bold red")
def cmd_keystore_forget(args: argparse.Namespace) -> None:
"""Remove the cached passphrase."""
ks = _get_client()
success, msg = ks.forget_passphrase()
if success:
_cprint(f"\n ✓ Passphrase removed from {msg}\n", style="green")
else:
_cprint(f"\n{msg}\n", style="yellow")
def cmd_keystore_change_passphrase(args: argparse.Namespace) -> None:
"""Change the master passphrase."""
from keystore.store import PassphraseMismatch
ks = _get_client()
if not ks.is_initialized:
_cprint("\n Keystore not initialized. Run: hermes keystore init\n", style="yellow")
return
old = getpass.getpass(" Current passphrase: ")
new = getpass.getpass(" New passphrase: ")
if not new:
_cprint("\n ✗ Passphrase cannot be empty\n", style="bold red")
return
confirm = getpass.getpass(" Confirm new: ")
if new != confirm:
_cprint("\n ✗ Passphrases don't match\n", style="bold red")
return
try:
ks.change_passphrase(old, new)
_cprint("\n ✓ Passphrase changed successfully\n", style="green")
_cprint(" 💡 If you used 'hermes keystore remember', run it again to update.\n")
except PassphraseMismatch:
_cprint("\n ✗ Current passphrase is incorrect\n", style="bold red")
def cmd_keystore_audit(args: argparse.Namespace) -> None:
"""Show the access log."""
ks = _get_client()
_require_unlocked(ks)
entries = ks.get_access_log(limit=args.limit)
if not entries:
_cprint("\n No access log entries.\n")
return
if _RICH:
console = Console()
table = Table(title="Keystore Access Log", show_lines=False)
table.add_column("Time", style="dim", no_wrap=True)
table.add_column("Secret", style="cyan")
table.add_column("Action")
table.add_column("Requester", style="magenta")
_action_style = {
"read": "green",
"write": "blue",
"inject": "green",
"denied": "bold red",
"delete": "yellow",
}
for e in entries:
ts = e["timestamp"][:19].replace("T", " ")
action = e["action"]
style = _action_style.get(action, "white")
table.add_row(ts, e["secret_name"], f"[{style}]{action}[/{style}]", e["requester"] or "")
console.print()
console.print(table)
console.print()
else:
print(f"\n {'Time':<20} {'Secret':<35} {'Action':<8} {'Requester'}")
print(f" {''*20} {''*35} {''*8} {''*12}")
for e in entries:
ts = e["timestamp"][:19].replace("T", " ")
print(f" {ts:<20} {e['secret_name']:<35} {e['action']:<8} {e['requester'] or ''}")
print()
def cmd_keystore_status(args: argparse.Namespace) -> None:
"""Show keystore status."""
from keystore import credential_store
ks = _get_client()
_cprint("\n 🔐 Keystore Status\n")
if not ks.is_initialized:
_cprint(" Status: Not initialized", style="yellow")
_cprint(" Run: hermes keystore init\n")
return
count = ks.secret_count()
_cprint(f" Status: {'Unlocked' if ks.is_unlocked else 'Locked'}")
_cprint(f" Secrets: {count}")
from keystore.client import _default_db_path
db_path = _default_db_path()
if db_path.exists():
size_kb = db_path.stat().st_size / 1024
_cprint(f" DB path: {db_path}")
_cprint(f" DB size: {size_kb:.1f} KB")
backend = credential_store.backend_name()
cached = credential_store.retrieve_passphrase() is not None if backend else False
_cprint(f" Cred store: {backend or 'None available'}")
if backend:
_cprint(f" Passphrase: {'Cached' if cached else 'Not cached'}")
_cprint("")
# =========================================================================
# Argparse registration (called from hermes_cli/main.py)
# =========================================================================
def register_subparser(subparsers: argparse._SubParsersAction) -> None:
"""Register the ``hermes keystore`` subcommand tree."""
keystore_parser = subparsers.add_parser(
"keystore",
help="Manage the encrypted secret store",
description="Encrypted keystore for API keys, tokens, and wallet secrets.",
)
keystore_parser.set_defaults(func=cmd_keystore_status)
ks_sub = keystore_parser.add_subparsers(dest="keystore_command")
# init
ks_sub.add_parser("init", help="Create a new keystore").set_defaults(func=cmd_keystore_init)
# list
ks_sub.add_parser("list", aliases=["ls"], help="List stored secrets").set_defaults(func=cmd_keystore_list)
# set
set_p = ks_sub.add_parser("set", aliases=["add"], help="Add or update a secret")
set_p.add_argument("name", help="Secret name (e.g. OPENROUTER_API_KEY)")
set_p.add_argument("--category", "-c", default=None,
choices=["injectable", "gated", "sealed", "user_only"],
help="Access category (default: auto-detected)")
set_p.add_argument("--description", "-d", default="", help="Human-readable description")
set_p.set_defaults(func=cmd_keystore_set)
# show
show_p = ks_sub.add_parser("show", aliases=["get"], help="Decrypt and display a secret")
show_p.add_argument("name", help="Secret name")
show_p.set_defaults(func=cmd_keystore_show)
# delete
del_p = ks_sub.add_parser("delete", aliases=["rm", "remove"], help="Remove a secret")
del_p.add_argument("name", help="Secret name")
del_p.set_defaults(func=cmd_keystore_delete)
# set-category
cat_p = ks_sub.add_parser("set-category", help="Change a secret's access category")
cat_p.add_argument("name", help="Secret name")
cat_p.add_argument("category", choices=["injectable", "gated", "sealed", "user_only"])
cat_p.set_defaults(func=cmd_keystore_set_category)
# migrate
mig_p = ks_sub.add_parser("migrate", help="Import secrets from .env")
mig_p.add_argument("--keep-env", action="store_true",
help="Don't replace .env with a stub after migration")
mig_p.set_defaults(func=cmd_keystore_migrate)
# remember / forget
ks_sub.add_parser("remember", help="Cache passphrase in OS credential store").set_defaults(func=cmd_keystore_remember)
ks_sub.add_parser("forget", help="Remove cached passphrase").set_defaults(func=cmd_keystore_forget)
# change-passphrase
ks_sub.add_parser("change-passphrase", help="Change master passphrase").set_defaults(func=cmd_keystore_change_passphrase)
# audit
audit_p = ks_sub.add_parser("audit", aliases=["log"], help="Show access log")
audit_p.add_argument("--limit", "-n", type=int, default=50, help="Number of entries (default: 50)")
audit_p.set_defaults(func=cmd_keystore_audit)
# status
ks_sub.add_parser("status", help="Show keystore status").set_defaults(func=cmd_keystore_status)

439
keystore/client.py Normal file
View File

@@ -0,0 +1,439 @@
"""High-level keystore client for CLI and agent integration.
This is the main entry point for all keystore consumers. It wraps
EncryptedStore with:
- Automatic path resolution (``~/.hermes/keystore/secrets.db``)
- Unlock flow (credential store → env var → interactive prompt)
- Injectable secret injection into ``os.environ``
- .env migration helper
- Singleton pattern (one client per process)
Usage in CLI startup::
from keystore.client import get_keystore
ks = get_keystore()
ks.ensure_unlocked() # prompts if needed
ks.inject_env() # populates os.environ with injectable secrets
Usage in gateway startup::
ks = get_keystore()
ks.ensure_unlocked(interactive=False) # raises if can't auto-unlock
ks.inject_env()
"""
import getpass
import logging
import os
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from keystore.store import (
EncryptedStore,
KeystoreError,
KeystoreLocked,
PassphraseMismatch,
SecretEntry,
)
from keystore import credential_store
from keystore.categories import SecretCategory, default_category
logger = logging.getLogger(__name__)
def _hermes_home() -> Path:
return Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
def _default_db_path() -> Path:
return _hermes_home() / "keystore" / "secrets.db"
def _env_file_path() -> Path:
return _hermes_home() / ".env"
def _owned_env_names() -> set[str]:
raw = os.getenv("HERMES_KEYSTORE_OWNED_VARS", "")
return {x for x in raw.split(",") if x}
def _set_owned_env_names(names: set[str]) -> None:
os.environ["HERMES_KEYSTORE_OWNED_VARS"] = ",".join(sorted(names))
def _owned_env_values() -> dict[str, str]:
raw = os.getenv("HERMES_KEYSTORE_OWNED_VALUES_JSON", "")
if not raw:
return {}
try:
import json as _json
data = _json.loads(raw)
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _set_owned_env_values(values: dict[str, str]) -> None:
import json as _json
os.environ["HERMES_KEYSTORE_OWNED_VALUES_JSON"] = _json.dumps(values, sort_keys=True)
class KeystoreClient:
"""High-level keystore interface for CLI, gateway, and agent startup."""
def __init__(self, db_path: Optional[str | Path] = None):
path = Path(db_path) if db_path else _default_db_path()
self._store = EncryptedStore(path)
self._injected: Dict[str, bool] = {}
@property
def is_initialized(self) -> bool:
return self._store.is_initialized
@property
def is_unlocked(self) -> bool:
return self._store.is_unlocked
def initialize(self, passphrase: str) -> None:
"""Initialize a new keystore with the given passphrase."""
self._store.initialize(passphrase)
def unlock(self, passphrase: str) -> None:
"""Unlock with a known passphrase."""
self._store.unlock(passphrase)
def lock(self) -> None:
"""Lock the keystore."""
self._store.lock()
def ensure_unlocked(self, interactive: bool = True) -> bool:
"""Ensure the keystore is unlocked, trying all available methods.
Unlock priority:
1. Already unlocked → no-op
2. OS credential store (if ``hermes keystore remember`` was used)
3. Interactive passphrase prompt (if ``interactive=True``)
4. ``HERMES_KEYSTORE_PASSPHRASE`` env var (headless/Docker fallback only)
Returns True if unlocked, False if not initialized (caller should
set up the keystore), raises PassphraseMismatch on wrong passphrase.
When ``interactive=False`` (gateway/headless), raises KeystoreLocked
if no automatic unlock method succeeds.
"""
if self._store.is_unlocked:
return True
if not self._store.is_initialized:
return False
# 1. Try credential store
passphrase = credential_store.retrieve_passphrase()
if passphrase:
try:
self._store.unlock(passphrase)
logger.debug("Unlocked via credential store (%s)", credential_store.backend_name())
return True
except PassphraseMismatch:
logger.warning(
"Stored passphrase is stale (credential store: %s). "
"Run 'hermes keystore remember' to update it.",
credential_store.backend_name(),
)
# 2. Interactive prompt (preferred over env var when TTY is available)
if interactive:
max_attempts = 3
for attempt in range(max_attempts):
try:
passphrase = getpass.getpass("🔐 Keystore passphrase: ")
if not passphrase:
continue
self._store.unlock(passphrase)
return True
except PassphraseMismatch:
remaining = max_attempts - attempt - 1
if remaining > 0:
print(f" ✗ Incorrect passphrase ({remaining} attempts remaining)")
else:
print(" ✗ Incorrect passphrase")
raise PassphraseMismatch("Too many incorrect passphrase attempts")
# 3. Env var — last resort for headless/Docker/systemd deployments
# where no TTY or credential store is available. The passphrase is
# visible in the process environment, so this is a conscious security
# tradeoff for unattended operation.
env_passphrase = os.getenv("HERMES_KEYSTORE_PASSPHRASE")
if env_passphrase:
try:
self._store.unlock(env_passphrase)
logger.debug("Unlocked via HERMES_KEYSTORE_PASSPHRASE env var (headless fallback)")
return True
except PassphraseMismatch:
logger.warning("HERMES_KEYSTORE_PASSPHRASE env var has wrong passphrase")
raise KeystoreLocked(
"Keystore is locked and no automatic unlock method succeeded. "
"Run 'hermes keystore remember' to cache the passphrase, or "
"set HERMES_KEYSTORE_PASSPHRASE env var for headless deployments."
)
def inject_env(self, force: bool = False, external_managed_names: Optional[set[str]] = None) -> Dict[str, bool]:
"""Inject all ``injectable`` secrets into ``os.environ``.
Args:
force: Refresh mode for long-lived processes. When ``False``
(default), existing env vars are preserved so shell/Docker env
wins over keystore values at startup. When ``True``, only env
vars that were previously injected by this client instance are
refreshed.
external_managed_names: Optional set of env-var names that were
supplied by non-keystore sources during the current refresh
cycle and explicitly passed in by the caller. In the current
gateway implementation this is used for `.env`-tracked names.
This lets long-lived processes distinguish a stale injected
value from an external replacement even when the replacement
uses the same credential string.
Returns:
Dict of ``{secret_name: injected_or_overwritten}``.
"""
secrets = self._store.get_injectable_secrets()
previous = dict(self._injected)
owned = _owned_env_names()
owned_values = _owned_env_values()
external_managed_names = set(external_managed_names or set())
injected = {}
current_names = set(secrets.keys())
# Force-refresh also acts as revocation for previously keystore-owned
# env vars that have been deleted from the keystore or are no longer
# injectable. Only revoke names that are still keystore-owned AND not
# externally managed in this refresh cycle.
if force:
removed = owned - current_names
for name in removed:
if name not in external_managed_names:
os.environ.pop(name, None)
owned.discard(name)
owned_values.pop(name, None)
for name, value in secrets.items():
should_write = False
if name not in os.environ:
should_write = True
elif name in external_managed_names:
# Current refresh explicitly sourced this name externally.
should_write = False
elif force and (previous.get(name) is True or name in owned):
# Only refresh vars we previously injected ourselves.
should_write = True
if should_write:
os.environ[name] = value
injected[name] = True
owned.add(name)
owned_values[name] = value
else:
injected[name] = False
if name in external_managed_names and not (previous.get(name) is True):
# External source owns it in this process.
owned.discard(name)
owned_values.pop(name, None)
self._injected = injected
_set_owned_env_names(owned)
_set_owned_env_values(owned_values)
count_written = sum(1 for v in injected.values() if v)
count_skipped = sum(1 for v in injected.values() if not v)
logger.info(
"Keystore: %s %d secrets (%d skipped)",
"refreshed" if force else "injected",
count_written,
count_skipped,
)
return injected
# ------------------------------------------------------------------
# Secret management
# ------------------------------------------------------------------
def set_secret(
self,
name: str,
value: str,
category: Optional[str] = None,
description: str = "",
tags: Optional[List[str]] = None,
) -> None:
"""Store a secret. Category defaults based on the name."""
cat = category or default_category(name).value
self._store.set(name, value, category=cat, description=description, tags=tags)
def get_secret(self, name: str, requester: str = "cli") -> Optional[str]:
"""Retrieve a secret."""
return self._store.get(name, requester=requester)
def delete_secret(self, name: str) -> bool:
"""Delete a secret."""
return self._store.delete(name)
def list_secrets(self) -> List[SecretEntry]:
"""List all secrets (metadata only)."""
return self._store.list_secrets()
def set_category(self, name: str, category: str) -> bool:
"""Change a secret's access category."""
# Validate
try:
SecretCategory(category)
except ValueError:
raise KeystoreError(
f"Invalid category '{category}'. "
f"Must be one of: {', '.join(c.value for c in SecretCategory)}"
)
return self._store.set_category(name, category)
def get_access_log(self, limit: int = 50) -> List[dict]:
"""Return recent access log entries."""
return self._store.get_access_log(limit)
def change_passphrase(self, old_passphrase: str, new_passphrase: str) -> None:
"""Change the master passphrase."""
self._store.change_passphrase(old_passphrase, new_passphrase)
def secret_count(self) -> int:
"""Return the number of stored secrets."""
return self._store.secret_count()
# ------------------------------------------------------------------
# Credential store (passphrase caching)
# ------------------------------------------------------------------
def remember_passphrase(self, passphrase: str) -> Tuple[bool, str]:
"""Store the passphrase in the OS credential store.
Returns (success, backend_name_or_error_message).
"""
backend = credential_store.backend_name()
if not credential_store.is_available():
return False, (
"No credential store backend available.\n\n"
"Options:\n"
" • Set HERMES_KEYSTORE_PASSPHRASE env var for headless/Docker\n"
" • Install keyring: pip install keyring\n"
" • Install keyctl: apt install keyutils (Linux)\n"
" • Type your passphrase each time (most secure)"
)
# Verify the passphrase is correct first
try:
self._store.unlock(passphrase)
except PassphraseMismatch:
return False, "Incorrect passphrase"
if credential_store.store_passphrase(passphrase):
return True, backend
return False, f"Failed to store passphrase in {backend}"
def forget_passphrase(self) -> Tuple[bool, str]:
"""Remove the passphrase from the OS credential store."""
backend = credential_store.backend_name()
if credential_store.delete_passphrase():
return True, backend or "credential store"
return False, "No stored passphrase found"
# ------------------------------------------------------------------
# Migration from .env
# ------------------------------------------------------------------
def migrate_from_env(self, env_path: Optional[Path] = None) -> Dict[str, str]:
"""Import secrets from a .env file into the keystore.
Returns a dict of {secret_name: category} for each migrated secret.
Skips blank values and comments. Does NOT delete the .env file
(the caller should handle backup/stub creation).
"""
path = env_path or _env_file_path()
if not path.exists():
return {}
migrated = {}
with open(path, encoding="utf-8", errors="replace") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip()
# Strip surrounding quotes
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
value = value[1:-1]
if not value:
continue
# Skip non-secret config values
if not _looks_like_secret(key, value):
continue
category = default_category(key).value
self._store.set(
key, value,
category=category,
description=f"Migrated from .env",
tags=["migrated"],
)
migrated[key] = category
logger.info("Migrated %d secrets from %s", len(migrated), path)
return migrated
def _looks_like_secret(key: str, value: str) -> bool:
"""Heuristic: does this .env entry look like a secret?"""
secret_indicators = (
"KEY", "TOKEN", "SECRET", "PASSWORD", "PASSWD",
"AUTH", "CREDENTIAL", "API_KEY",
)
key_upper = key.upper()
for indicator in secret_indicators:
if indicator in key_upper:
return True
# Long random-looking values are probably secrets
if len(value) >= 20 and not value.startswith("/") and not value.startswith("http"):
return True
return False
# =========================================================================
# Singleton
# =========================================================================
_instance: Optional[KeystoreClient] = None
def get_keystore(db_path: Optional[str | Path] = None) -> KeystoreClient:
"""Get the global keystore client (singleton per process)."""
global _instance
if _instance is None:
_instance = KeystoreClient(db_path)
return _instance
def reset_keystore() -> None:
"""Reset the global singleton (for testing)."""
global _instance
if _instance is not None:
try:
_instance.lock()
except Exception:
pass
_instance = None

View File

@@ -0,0 +1,367 @@
"""Cross-platform credential store for keystore passphrase caching.
Detects the best available backend at runtime. No hard dependency
on any OS-specific service — every backend is probed and the first
working one is used.
Backend priority:
macOS → Keychain Services (via keyring library)
Windows → Credential Locker / DPAPI (via keyring library)
Linux → Secret Service D-Bus > kernel keyctl
Fallback → None
Security note:
We intentionally DO NOT provide an automatic encrypted-file fallback.
In Hermes' current same-user execution model, any fallback whose key is
derivable from local machine/user state would be reachable by the agent
itself via file reads and local code execution, collapsing the security
boundary around sealed secrets. If no real OS/keyctl-backed credential
store exists, users must either:
- type the keystore passphrase at startup (recommended), or
- set HERMES_KEYSTORE_PASSPHRASE env var for headless/Docker/systemd
deployments (conscious security tradeoff for unattended operation)
"""
import hashlib
import logging
import os
import platform
import subprocess
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
_SERVICE_NAME = "hermes-keystore"
_ACCOUNT_NAME = "master-passphrase"
# =========================================================================
# Backend ABC
# =========================================================================
class _Backend:
"""Abstract credential store backend."""
name: str = "Unknown"
def store(self, passphrase: str) -> bool:
raise NotImplementedError
def retrieve(self) -> Optional[str]:
raise NotImplementedError
def delete(self) -> bool:
raise NotImplementedError
# =========================================================================
# Backend: keyring (macOS Keychain, Windows Credential Locker, Secret Service)
# =========================================================================
class _KeyringBackend(_Backend):
"""Cross-platform backend via the ``keyring`` library.
Covers macOS Keychain, Windows Credential Locker, and Linux
Secret Service (GNOME Keyring / KDE Wallet) if available.
"""
def __init__(self, kr_module):
self._kr = kr_module
backend_obj = kr_module.get_keyring()
raw_name = type(backend_obj).__name__
_friendly = {
"Keyring": "macOS Keychain",
"KeyringBackend": "macOS Keychain",
"WinVaultKeyring": "Windows Credential Locker",
"SecretServiceKeyring": "Secret Service (GNOME/KDE)",
}
self.name = _friendly.get(raw_name, raw_name)
def store(self, passphrase: str) -> bool:
try:
self._kr.set_password(_SERVICE_NAME, _ACCOUNT_NAME, passphrase)
return True
except Exception as e:
logger.warning("keyring store failed: %s", e)
return False
def retrieve(self) -> Optional[str]:
try:
return self._kr.get_password(_SERVICE_NAME, _ACCOUNT_NAME)
except Exception:
return None
def delete(self) -> bool:
try:
self._kr.delete_password(_SERVICE_NAME, _ACCOUNT_NAME)
return True
except Exception:
return False
# =========================================================================
# Backend: Linux kernel keyring (keyctl)
# =========================================================================
class _KeyctlBackend(_Backend):
"""Linux kernel keyring via the ``keyctl`` userspace tool.
Uses the per-UID *user* keyring (``@u``) which persists as long as
the UID has running processes. On systemd systems this means the
passphrase survives across gateway restarts.
The persistent keyring (``@us``) would survive logout but has a
configurable idle expiry (default 3 days). We use ``@u`` because
gateway/cron services are long-running.
"""
name = "Linux Kernel Keyring"
_KEY_DESC = "hermes:keystore:passphrase"
def store(self, passphrase: str) -> bool:
try:
result = subprocess.run(
["keyctl", "add", "user", self._KEY_DESC, passphrase, "@u"],
capture_output=True, text=True, timeout=5,
)
return result.returncode == 0
except (OSError, subprocess.TimeoutExpired):
return False
def retrieve(self) -> Optional[str]:
try:
result = subprocess.run(
["keyctl", "search", "@u", "user", self._KEY_DESC],
capture_output=True, text=True, timeout=5,
)
if result.returncode != 0:
return None
key_id = result.stdout.strip()
result = subprocess.run(
["keyctl", "pipe", key_id],
capture_output=True, timeout=5,
)
if result.returncode == 0 and result.stdout:
return result.stdout.decode("utf-8")
return None
except (OSError, subprocess.TimeoutExpired):
return None
def delete(self) -> bool:
try:
result = subprocess.run(
["keyctl", "search", "@u", "user", self._KEY_DESC],
capture_output=True, text=True, timeout=5,
)
if result.returncode != 0:
return False
key_id = result.stdout.strip()
subprocess.run(
["keyctl", "revoke", key_id],
capture_output=True, timeout=5,
)
return True
except (OSError, subprocess.TimeoutExpired):
return False
# =========================================================================
# Backend: Encrypted file (universal fallback)
# =========================================================================
class _EncryptedFileBackend(_Backend):
"""Encrypted file fallback — works everywhere, requires pynacl.
Derives an encryption key from machine-id + UID + static salt via
SHA-256 (simplified HKDF). Security assumption: same user on same
machine is trusted (equivalent to DPAPI on Windows).
"""
name = "Encrypted File"
def _derive_key(self) -> bytes:
machine_id = _get_machine_id()
uid = str(os.getuid()) if hasattr(os, "getuid") else os.getlogin()
ikm = f"{machine_id}:{uid}:hermes-keystore-credential-v1".encode()
return hashlib.sha256(ikm).digest()
def _path(self) -> Path:
hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
return hermes_home / "keystore" / ".credential"
def store(self, passphrase: str) -> bool:
try:
import nacl.secret
import nacl.utils
key = self._derive_key()
box = nacl.secret.SecretBox(key)
encrypted = box.encrypt(passphrase.encode("utf-8"))
path = self._path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(bytes(encrypted))
os.chmod(str(path), 0o600)
return True
except Exception as e:
logger.warning("Encrypted file store failed: %s", e)
return False
def retrieve(self) -> Optional[str]:
try:
import nacl.secret
key = self._derive_key()
box = nacl.secret.SecretBox(key)
encrypted = self._path().read_bytes()
return box.decrypt(encrypted).decode("utf-8")
except Exception:
return None
def delete(self) -> bool:
try:
self._path().unlink()
return True
except OSError:
return False
# =========================================================================
# Machine ID helper
# =========================================================================
def _get_machine_id() -> str:
"""Get a stable machine identifier. Best-effort, never raises."""
# Linux
for path in ("/etc/machine-id", "/var/lib/dbus/machine-id"):
try:
with open(path) as f:
mid = f.read().strip()
if mid:
return mid
except OSError:
continue
# macOS — IOPlatformUUID
if platform.system() == "Darwin":
try:
r = subprocess.run(
["ioreg", "-rd1", "-c", "IOPlatformExpertDevice"],
capture_output=True, text=True, timeout=5,
)
for line in r.stdout.splitlines():
if "IOPlatformUUID" in line:
return line.split('"')[-2]
except (OSError, subprocess.TimeoutExpired, IndexError):
pass
# Windows — WMI CSProduct UUID
if platform.system() == "Windows":
try:
r = subprocess.run(
["wmic", "csproduct", "get", "UUID"],
capture_output=True, text=True, timeout=5,
)
lines = [l.strip() for l in r.stdout.splitlines()
if l.strip() and l.strip() != "UUID"]
if lines:
return lines[0]
except (OSError, subprocess.TimeoutExpired):
pass
# Last resort: hostname (stable-ish)
return platform.node()
# =========================================================================
# Backend detection
# =========================================================================
def _detect_backend() -> Optional[_Backend]:
"""Detect the best available credential store backend."""
# 1. keyring library (macOS Keychain, Windows Credential Locker,
# or Linux Secret Service via D-Bus)
try:
import keyring
from keyring.backends import fail as fail_backend
backend_obj = keyring.get_keyring()
if isinstance(backend_obj, fail_backend.Keyring):
raise ValueError("only fail backend available")
# Chainer with only fail backends
if hasattr(backend_obj, "backends"):
real = [b for b in backend_obj.backends
if not isinstance(b, fail_backend.Keyring)]
if not real:
raise ValueError("chainer has no real backends")
return _KeyringBackend(keyring)
except (ImportError, ValueError, Exception) as e:
logger.debug("keyring unavailable: %s", e)
# 2. Linux kernel keyctl
if platform.system() == "Linux":
try:
result = subprocess.run(
["keyctl", "--version"],
capture_output=True, timeout=5,
)
if result.returncode == 0:
return _KeyctlBackend()
except (OSError, subprocess.TimeoutExpired):
pass
# No insecure fallback. If no real backend is available, return None.
return None
# Module-level cached backend. ``False`` = not yet detected.
_cached_backend: Optional[_Backend] = None
_detection_done: bool = False
def _get_backend() -> Optional[_Backend]:
global _cached_backend, _detection_done
if not _detection_done:
_cached_backend = _detect_backend()
_detection_done = True
if _cached_backend:
logger.debug("Credential store backend: %s", _cached_backend.name)
else:
logger.debug("No credential store backend available")
return _cached_backend
# =========================================================================
# Public API
# =========================================================================
def is_available() -> bool:
"""Return True if any credential store backend is available."""
return _get_backend() is not None
def backend_name() -> Optional[str]:
"""Return human-readable name of the detected backend, or None."""
b = _get_backend()
return b.name if b else None
def store_passphrase(passphrase: str) -> bool:
"""Store the keystore passphrase. Returns True on success."""
b = _get_backend()
if b is None:
return False
return b.store(passphrase)
def retrieve_passphrase() -> Optional[str]:
"""Retrieve the stored passphrase, or None if unavailable."""
b = _get_backend()
if b is None:
return None
return b.retrieve()
def delete_passphrase() -> bool:
"""Delete the stored passphrase. Returns True on success."""
b = _get_backend()
if b is None:
return False
return b.delete()

672
keystore/store.py Normal file
View File

@@ -0,0 +1,672 @@
"""Encrypted secret store backed by SQLite.
Secrets are encrypted at the field level using XSalsa20-Poly1305 (AEAD)
via ``nacl.secret.SecretBox``. The master encryption key is derived from a
user passphrase via Argon2id.
The master key is held in memory only — never written to disk.
The encrypted DB can be freely copied/backed up; it's useless without
the passphrase.
Thread safety: all public methods are serialized by a threading lock.
The store is designed to be used from a single daemon process, but
concurrent tool calls within that process are safe.
"""
import json
import logging
import os
import sqlite3
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Crypto imports — pynacl SecretBox (XSalsa20-Poly1305), argon2-cffi for KDF
# ---------------------------------------------------------------------------
try:
import nacl.secret
import nacl.utils
import nacl.pwhash
import nacl.exceptions
_NACL_AVAILABLE = True
except ImportError:
_NACL_AVAILABLE = False
try:
from argon2 import PasswordHasher
from argon2.low_level import hash_secret_raw, Type
_ARGON2_AVAILABLE = True
except ImportError:
_ARGON2_AVAILABLE = False
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_SCHEMA_VERSION = 1
_KDF_TIME_COST = 3
_KDF_MEMORY_COST = 65536 # 64 MB
_KDF_PARALLELISM = 4
_KDF_HASH_LEN = 32 # 256 bits — matches SecretBox key size
_SALT_LEN = 16
# ---------------------------------------------------------------------------
# Data structures
# ---------------------------------------------------------------------------
@dataclass
class SecretEntry:
"""A single secret stored in the keystore."""
name: str
category: str
description: str = ""
tags: List[str] = field(default_factory=list)
created_at: str = ""
updated_at: str = ""
last_accessed_at: Optional[str] = None
access_count: int = 0
class KeystoreError(Exception):
"""Base exception for keystore operations."""
class KeystoreLocked(KeystoreError):
"""Raised when an operation requires the keystore to be unlocked."""
class KeystoreCorrupted(KeystoreError):
"""Raised when the keystore DB is corrupted or tampered with."""
class PassphraseMismatch(KeystoreError):
"""Raised when the provided passphrase is wrong."""
# ---------------------------------------------------------------------------
# Core Store
# ---------------------------------------------------------------------------
class EncryptedStore:
"""SQLite-backed encrypted secret store.
Usage::
store = EncryptedStore("~/.hermes/keystore/secrets.db")
# First time: initialize with a passphrase
store.initialize("my-passphrase")
# Later: unlock with the same passphrase
store.unlock("my-passphrase")
# Store and retrieve secrets
store.set("OPENROUTER_API_KEY", "sk-...", category="injectable")
value = store.get("OPENROUTER_API_KEY")
# Lock when done
store.lock()
"""
def __init__(self, db_path: str | Path):
if not _NACL_AVAILABLE:
raise ImportError(
"pynacl is required for the keystore. "
"Install with: pip install 'hermes-agent[keystore]'"
)
if not _ARGON2_AVAILABLE:
raise ImportError(
"argon2-cffi is required for the keystore. "
"Install with: pip install 'hermes-agent[keystore]'"
)
self._db_path = Path(db_path).expanduser().resolve()
self._master_key: Optional[bytes] = None # In-memory only
self._lock = threading.Lock()
@property
def is_initialized(self) -> bool:
"""True if the keystore DB exists and has been initialized."""
if not self._db_path.exists():
return False
try:
conn = self._open_db()
cursor = conn.execute(
"SELECT value FROM metadata WHERE key = 'schema_version'"
)
row = cursor.fetchone()
conn.close()
return row is not None
except (sqlite3.Error, Exception):
return False
@property
def is_unlocked(self) -> bool:
"""True if the store is unlocked (master key in memory)."""
return self._master_key is not None
def initialize(self, passphrase: str) -> None:
"""Create a new keystore with the given passphrase.
Creates the DB file, directory structure, KDF salt, and a
verification token that lets us check the passphrase later.
Raises KeystoreError if already initialized.
"""
with self._lock:
if self.is_initialized:
raise KeystoreError(
"Keystore already initialized. Use change_passphrase() "
"to change the passphrase, or delete the DB to start over."
)
# Create directory with strict permissions
self._db_path.parent.mkdir(parents=True, exist_ok=True)
os.chmod(str(self._db_path.parent), 0o700)
# Generate KDF salt
salt = nacl.utils.random(_SALT_LEN)
# Derive master key
master_key = self._derive_key(passphrase, salt)
# Create DB and schema
conn = self._open_db()
try:
self._create_schema(conn)
# Store KDF params
kdf_params = json.dumps({
"algorithm": "argon2id",
"time_cost": _KDF_TIME_COST,
"memory_cost": _KDF_MEMORY_COST,
"parallelism": _KDF_PARALLELISM,
"hash_len": _KDF_HASH_LEN,
"salt_len": _SALT_LEN,
}).encode()
conn.execute(
"INSERT INTO metadata (key, value) VALUES (?, ?)",
("kdf_params", kdf_params),
)
conn.execute(
"INSERT INTO metadata (key, value) VALUES (?, ?)",
("kdf_salt", salt),
)
conn.execute(
"INSERT INTO metadata (key, value) VALUES (?, ?)",
("schema_version", str(_SCHEMA_VERSION).encode()),
)
conn.execute(
"INSERT INTO metadata (key, value) VALUES (?, ?)",
("created_at", _now().encode()),
)
# Store a verification token — encrypt a known value so we
# can test the passphrase on unlock without storing it
verification = self._encrypt(master_key, b"hermes-keystore-ok")
conn.execute(
"INSERT INTO metadata (key, value) VALUES (?, ?)",
("verification_token", verification),
)
conn.commit()
except Exception:
conn.close()
# Clean up on failure
try:
self._db_path.unlink()
except OSError:
pass
raise
finally:
conn.close()
# Set file permissions
os.chmod(str(self._db_path), 0o600)
# Unlock immediately after initialization
self._master_key = master_key
logger.info("Keystore initialized at %s", self._db_path)
def unlock(self, passphrase: str) -> None:
"""Unlock the keystore with the user's passphrase.
Derives the master key and verifies it against the stored token.
Raises PassphraseMismatch if wrong, KeystoreError if not initialized.
"""
with self._lock:
if not self.is_initialized:
raise KeystoreError("Keystore not initialized. Run 'hermes keystore init'.")
conn = self._open_db()
try:
# Read salt
salt = self._get_metadata(conn, "kdf_salt")
if salt is None:
raise KeystoreCorrupted("Missing KDF salt in keystore DB")
# Read verification token
verification = self._get_metadata(conn, "verification_token")
if verification is None:
raise KeystoreCorrupted("Missing verification token in keystore DB")
finally:
conn.close()
# Derive key and verify
master_key = self._derive_key(passphrase, salt)
try:
plaintext = self._decrypt(master_key, verification)
if plaintext != b"hermes-keystore-ok":
raise PassphraseMismatch("Incorrect passphrase")
except nacl.exceptions.CryptoError:
raise PassphraseMismatch("Incorrect passphrase")
self._master_key = master_key
logger.info("Keystore unlocked")
def lock(self) -> None:
"""Lock the keystore — wipe the master key from memory."""
with self._lock:
if self._master_key is not None:
# Best-effort memory wipe (Python doesn't guarantee this,
# but it's better than leaving it around)
self._master_key = None
logger.info("Keystore locked")
def set(
self,
name: str,
value: str,
category: str = "injectable",
description: str = "",
tags: Optional[List[str]] = None,
) -> None:
"""Store or update a secret.
Args:
name: Secret name (e.g. "OPENROUTER_API_KEY")
value: Secret value (will be encrypted)
category: Access category (injectable/gated/sealed/user_only)
description: Human-readable description
tags: Optional tags for grouping
"""
with self._lock:
self._require_unlocked()
now = _now()
encrypted_value = self._encrypt(self._master_key, value.encode("utf-8"))
tags_json = json.dumps(tags or [])
conn = self._open_db()
try:
conn.execute(
"""INSERT INTO secrets (name, category, encrypted_value, description, tags, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(name) DO UPDATE SET
encrypted_value = excluded.encrypted_value,
category = excluded.category,
description = excluded.description,
tags = excluded.tags,
updated_at = excluded.updated_at
""",
(name, category, encrypted_value, description, tags_json, now, now),
)
self._log_access(conn, name, "write", "cli")
conn.commit()
finally:
conn.close()
def get(self, name: str, requester: str = "cli") -> Optional[str]:
"""Retrieve and decrypt a secret value.
Args:
name: Secret name
requester: Who is requesting (for audit log)
Returns:
Decrypted value, or None if not found.
Raises:
KeystoreLocked: If the store is locked.
"""
with self._lock:
self._require_unlocked()
conn = self._open_db()
try:
cursor = conn.execute(
"SELECT encrypted_value, category FROM secrets WHERE name = ?",
(name,),
)
row = cursor.fetchone()
if row is None:
return None
encrypted_value, category = row
# Enforce category access control
if category == "user_only" and requester not in ("cli", "migration"):
self._log_access(conn, name, "denied", requester)
conn.commit()
return None
if category == "sealed" and requester not in ("daemon", "wallet", "migration", "cli_export"):
self._log_access(conn, name, "denied", requester)
conn.commit()
return None
try:
value = self._decrypt(self._master_key, encrypted_value).decode("utf-8")
except nacl.exceptions.CryptoError:
raise KeystoreCorrupted(f"Failed to decrypt secret '{name}' — DB may be corrupted")
now = _now()
conn.execute(
"UPDATE secrets SET last_accessed_at = ?, access_count = access_count + 1 WHERE name = ?",
(now, name),
)
self._log_access(conn, name, "read", requester)
conn.commit()
return value
finally:
conn.close()
def delete(self, name: str) -> bool:
"""Delete a secret. Returns True if it existed."""
with self._lock:
self._require_unlocked()
conn = self._open_db()
try:
cursor = conn.execute("DELETE FROM secrets WHERE name = ?", (name,))
deleted = cursor.rowcount > 0
if deleted:
self._log_access(conn, name, "delete", "cli")
conn.commit()
return deleted
finally:
conn.close()
def list_secrets(self) -> List[SecretEntry]:
"""List all secrets (metadata only, no values)."""
with self._lock:
self._require_unlocked()
conn = self._open_db()
try:
cursor = conn.execute(
"""SELECT name, category, description, tags,
created_at, updated_at, last_accessed_at, access_count
FROM secrets ORDER BY name"""
)
results = []
for row in cursor:
results.append(SecretEntry(
name=row[0],
category=row[1],
description=row[2],
tags=json.loads(row[3]) if row[3] else [],
created_at=row[4],
updated_at=row[5],
last_accessed_at=row[6],
access_count=row[7],
))
return results
finally:
conn.close()
def get_injectable_secrets(self) -> Dict[str, str]:
"""Return all injectable secrets as a name→value dict.
Used by the startup flow to populate os.environ.
"""
with self._lock:
self._require_unlocked()
conn = self._open_db()
try:
cursor = conn.execute(
"SELECT name, encrypted_value FROM secrets WHERE category = 'injectable'"
)
result = {}
now = _now()
for name, encrypted_value in cursor:
try:
value = self._decrypt(self._master_key, encrypted_value).decode("utf-8")
result[name] = value
except nacl.exceptions.CryptoError:
logger.warning("Failed to decrypt injectable secret '%s' — skipping", name)
continue
if result:
conn.executemany(
"UPDATE secrets SET last_accessed_at = ?, access_count = access_count + 1 WHERE name = ?",
[(now, name) for name in result],
)
conn.commit()
return result
finally:
conn.close()
def set_category(self, name: str, category: str) -> bool:
"""Change the access category of a secret. Returns True if it existed."""
with self._lock:
self._require_unlocked()
conn = self._open_db()
try:
cursor = conn.execute(
"UPDATE secrets SET category = ?, updated_at = ? WHERE name = ?",
(category, _now(), name),
)
conn.commit()
return cursor.rowcount > 0
finally:
conn.close()
def get_access_log(self, limit: int = 50) -> List[dict]:
"""Return recent access log entries."""
with self._lock:
self._require_unlocked()
conn = self._open_db()
try:
cursor = conn.execute(
"""SELECT secret_name, action, requester, timestamp, details
FROM access_log ORDER BY id DESC LIMIT ?""",
(limit,),
)
return [
{
"secret_name": row[0],
"action": row[1],
"requester": row[2],
"timestamp": row[3],
"details": row[4],
}
for row in cursor
]
finally:
conn.close()
def change_passphrase(self, old_passphrase: str, new_passphrase: str) -> None:
"""Re-encrypt all secrets with a new passphrase.
This is an atomic operation — either all secrets are re-encrypted
or none are (transaction rollback on failure).
"""
with self._lock:
if not self.is_initialized:
raise KeystoreError("Keystore not initialized")
# Close persistent connection to avoid "database is locked"
conn = self._open_db()
try:
# Verify old passphrase
old_salt = self._get_metadata(conn, "kdf_salt")
old_key = self._derive_key(old_passphrase, old_salt)
verification = self._get_metadata(conn, "verification_token")
try:
self._decrypt(old_key, verification)
except nacl.exceptions.CryptoError:
raise PassphraseMismatch("Current passphrase is incorrect")
# Generate new salt and key
new_salt = nacl.utils.random(_SALT_LEN)
new_key = self._derive_key(new_passphrase, new_salt)
# Re-encrypt all secrets
cursor = conn.execute("SELECT name, encrypted_value FROM secrets")
updates = []
for name, encrypted_value in cursor:
plaintext = self._decrypt(old_key, encrypted_value)
new_encrypted = self._encrypt(new_key, plaintext)
updates.append((new_encrypted, _now(), name))
conn.executemany(
"UPDATE secrets SET encrypted_value = ?, updated_at = ? WHERE name = ?",
updates,
)
# Update salt and verification token
new_verification = self._encrypt(new_key, b"hermes-keystore-ok")
conn.execute(
"UPDATE metadata SET value = ? WHERE key = 'kdf_salt'",
(new_salt,),
)
conn.execute(
"UPDATE metadata SET value = ? WHERE key = 'verification_token'",
(new_verification,),
)
conn.commit()
# Update in-memory key
self._master_key = new_key
logger.info("Passphrase changed successfully (%d secrets re-encrypted)", len(updates))
finally:
conn.close()
def secret_count(self) -> int:
"""Return the number of stored secrets (works even when locked)."""
try:
conn = self._open_db()
cursor = conn.execute("SELECT COUNT(*) FROM secrets")
count = cursor.fetchone()[0]
conn.close()
return count
except (sqlite3.Error, Exception):
return 0
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _require_unlocked(self) -> None:
if self._master_key is None:
raise KeystoreLocked("Keystore is locked. Call unlock() first.")
def _open_db(self) -> sqlite3.Connection:
"""Open a new SQLite connection to the keystore DB."""
return sqlite3.connect(str(self._db_path), timeout=10)
def _create_schema(self, conn: sqlite3.Connection) -> None:
conn.executescript("""
CREATE TABLE IF NOT EXISTS metadata (
key TEXT PRIMARY KEY,
value BLOB NOT NULL
);
CREATE TABLE IF NOT EXISTS secrets (
name TEXT PRIMARY KEY,
category TEXT NOT NULL DEFAULT 'injectable',
encrypted_value BLOB NOT NULL,
description TEXT DEFAULT '',
tags TEXT DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_accessed_at TEXT,
access_count INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS access_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
secret_name TEXT NOT NULL,
action TEXT NOT NULL,
requester TEXT,
timestamp TEXT NOT NULL,
details TEXT
);
CREATE INDEX IF NOT EXISTS idx_secrets_category
ON secrets(category);
CREATE INDEX IF NOT EXISTS idx_access_log_secret
ON access_log(secret_name);
CREATE INDEX IF NOT EXISTS idx_access_log_timestamp
ON access_log(timestamp);
""")
def _get_metadata(self, conn: sqlite3.Connection, key: str) -> Optional[bytes]:
cursor = conn.execute("SELECT value FROM metadata WHERE key = ?", (key,))
row = cursor.fetchone()
return row[0] if row else None
def _log_access(
self,
conn: sqlite3.Connection,
secret_name: str,
action: str,
requester: str,
details: str = "",
) -> None:
conn.execute(
"INSERT INTO access_log (secret_name, action, requester, timestamp, details) VALUES (?, ?, ?, ?, ?)",
(secret_name, action, requester, _now(), details),
)
@staticmethod
def _derive_key(passphrase: str, salt: bytes) -> bytes:
"""Derive a 256-bit key from passphrase + salt via Argon2id."""
return hash_secret_raw(
secret=passphrase.encode("utf-8"),
salt=salt,
time_cost=_KDF_TIME_COST,
memory_cost=_KDF_MEMORY_COST,
parallelism=_KDF_PARALLELISM,
hash_len=_KDF_HASH_LEN,
type=Type.ID,
)
@staticmethod
def _encrypt(key: bytes, plaintext: bytes) -> bytes:
"""Encrypt with XSalsa20-Poly1305 (AEAD) via ``nacl.secret.SecretBox``.
Returns nonce + ciphertext as a single blob.
SecretBox uses a 24-byte nonce and is widely audited.
"""
box = nacl.secret.SecretBox(key)
return bytes(box.encrypt(plaintext))
@staticmethod
def _decrypt(key: bytes, ciphertext: bytes) -> bytes:
"""Decrypt SecretBox (XSalsa20-Poly1305) ciphertext.
Raises nacl.exceptions.CryptoError on tampered/wrong-key data.
"""
box = nacl.secret.SecretBox(key)
return bytes(box.decrypt(ciphertext))
# ---------------------------------------------------------------------------
# Utility
# ---------------------------------------------------------------------------
def _now() -> str:
"""ISO 8601 UTC timestamp."""
from datetime import datetime, timezone
return datetime.now(timezone.utc).isoformat()

View File

@@ -158,6 +158,7 @@ def _discover_tools():
"tools.send_message_tool",
"tools.honcho_tools",
"tools.homeassistant_tool",
"tools.wallet_tool",
]
import importlib
for mod_name in _modules:

View File

@@ -65,6 +65,21 @@ rl = [
"wandb>=0.15.0,<1",
]
yc-bench = ["yc-bench @ git+https://github.com/collinear-ai/yc-bench.git ; python_version >= '3.12'"]
keystore = [
"argon2-cffi>=23.0,<24",
"pynacl>=1.5.0,<2",
"keyring>=25.0,<26",
]
wallet = [
"hermes-agent[keystore]",
"eth-account>=0.13.0,<1",
"web3>=7.0,<8",
]
wallet-solana = [
"hermes-agent[keystore]",
"solders>=0.21,<1",
"solana>=0.36,<1",
]
all = [
"hermes-agent[modal]",
"hermes-agent[daytona]",
@@ -82,6 +97,7 @@ all = [
"hermes-agent[acp]",
"hermes-agent[voice]",
"hermes-agent[dingtalk]",
"hermes-agent[keystore]",
]
[project.scripts]
@@ -93,7 +109,7 @@ hermes-acp = "acp_adapter.entry:main"
py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_constants", "hermes_state", "hermes_time", "rl_cli", "utils"]
[tool.setuptools.packages.find]
include = ["agent", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "cron", "honcho_integration", "acp_adapter"]
include = ["agent", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "cron", "honcho_integration", "acp_adapter", "keystore", "wallet", "wallet.*"]
[tool.pytest.ini_options]
testpaths = ["tests"]

View File

@@ -0,0 +1,189 @@
"""Gateway keystore injection regression tests."""
from __future__ import annotations
import importlib
import os
import sys
from pathlib import Path
import pytest
nacl = pytest.importorskip("nacl")
argon2 = pytest.importorskip("argon2")
def _reload_gateway_run(monkeypatch, home: Path):
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.delenv("HERMES_KEYSTORE_OWNED_VARS", raising=False)
monkeypatch.delenv("HERMES_KEYSTORE_OWNED_VALUES_JSON", raising=False)
# Reset cached singletons that capture prior HERMES_HOME or lock state.
try:
from keystore.client import reset_keystore
reset_keystore()
except Exception:
pass
try:
from wallet.runtime import reset_runtime
reset_runtime()
except Exception:
pass
sys.modules.pop("gateway.run", None)
import gateway.run as gateway_run
importlib.reload(gateway_run)
return gateway_run
def test_gateway_import_injects_keystore_without_config_yaml(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir(parents=True)
(home / ".env").write_text("")
# Initialize keystore with a secret, but do not create config.yaml.
monkeypatch.setenv("HERMES_HOME", str(home))
from keystore.client import KeystoreClient, reset_keystore
reset_keystore()
ks = KeystoreClient(home / "keystore" / "secrets.db")
ks.initialize("passphrase")
ks.set_secret("OPENAI_API_KEY", "sk-test-from-keystore")
monkeypatch.setenv("HERMES_KEYSTORE_PASSPHRASE", "passphrase")
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
gateway_run = _reload_gateway_run(monkeypatch, home)
assert os.environ.get("OPENAI_API_KEY") == "sk-test-from-keystore"
def test_gateway_refresh_reinjects_keystore_secret(monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir(parents=True)
(home / ".env").write_text("")
(home / "config.yaml").write_text("toolsets:\n- hermes-cli\n")
monkeypatch.setenv("HERMES_HOME", str(home))
from keystore.client import KeystoreClient, reset_keystore
reset_keystore()
ks = KeystoreClient(home / "keystore" / "secrets.db")
ks.initialize("passphrase")
ks.set_secret("OPENAI_API_KEY", "sk-old")
monkeypatch.setenv("HERMES_KEYSTORE_PASSPHRASE", "passphrase")
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
gateway_run = _reload_gateway_run(monkeypatch, home)
assert os.environ.get("OPENAI_API_KEY") == "sk-old"
# Rotate secret in keystore; refresh must overwrite the stale in-process env var
# because that value originally came from keystore injection.
ks.set_secret("OPENAI_API_KEY", "sk-new")
os.environ["OPENAI_API_KEY"] = "stale"
gateway_run._inject_keystore_env(force=True, external_managed_names=set())
assert os.environ.get("OPENAI_API_KEY") == "sk-new"
def test_gateway_refresh_does_not_clobber_external_env(monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir(parents=True)
(home / ".env").write_text("")
monkeypatch.setenv("HERMES_HOME", str(home))
from keystore.client import KeystoreClient, reset_keystore
reset_keystore()
ks = KeystoreClient(home / "keystore" / "secrets.db")
ks.initialize("passphrase")
ks.set_secret("OPENAI_API_KEY", "keystore-value")
monkeypatch.setenv("HERMES_KEYSTORE_PASSPHRASE", "passphrase")
# External env should win at startup and remain authoritative on refresh.
monkeypatch.setenv("OPENAI_API_KEY", "env-wins")
gateway_run = _reload_gateway_run(monkeypatch, home)
assert os.environ.get("OPENAI_API_KEY") == "env-wins"
ks.set_secret("OPENAI_API_KEY", "rotated-keystore-value")
gateway_run._inject_keystore_env(force=True, external_managed_names={"OPENAI_API_KEY"})
assert os.environ.get("OPENAI_API_KEY") == "env-wins"
def test_gateway_refresh_revokes_deleted_keystore_secret(monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir(parents=True)
(home / ".env").write_text("")
(home / "config.yaml").write_text("toolsets:\n- hermes-cli\n")
monkeypatch.setenv("HERMES_HOME", str(home))
from keystore.client import KeystoreClient, reset_keystore
reset_keystore()
ks = KeystoreClient(home / "keystore" / "secrets.db")
ks.initialize("passphrase")
ks.set_secret("OPENAI_API_KEY", "sk-old")
monkeypatch.setenv("HERMES_KEYSTORE_PASSPHRASE", "passphrase")
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
gateway_run = _reload_gateway_run(monkeypatch, home)
assert os.environ.get("OPENAI_API_KEY") == "sk-old"
# Delete from keystore; force refresh should revoke the previously
# injected env var from the long-lived process.
ks.delete_secret("OPENAI_API_KEY")
gateway_run._inject_keystore_env(force=True, external_managed_names=set())
assert os.environ.get("OPENAI_API_KEY") is None
def test_gateway_refresh_delete_preserves_external_replacement(monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir(parents=True)
env_path = home / ".env"
env_path.write_text("")
(home / "config.yaml").write_text("toolsets:\n- hermes-cli\n")
monkeypatch.setenv("HERMES_HOME", str(home))
from keystore.client import KeystoreClient, reset_keystore
reset_keystore()
ks = KeystoreClient(home / "keystore" / "secrets.db")
ks.initialize("passphrase")
ks.set_secret("OPENAI_API_KEY", "sk-old")
monkeypatch.setenv("HERMES_KEYSTORE_PASSPHRASE", "passphrase")
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
gateway_run = _reload_gateway_run(monkeypatch, home)
assert os.environ.get("OPENAI_API_KEY") == "sk-old"
# Secret removed from keystore, but .env now provides a replacement.
ks.delete_secret("OPENAI_API_KEY")
env_path.write_text("OPENAI_API_KEY=env-replacement\n")
from dotenv import load_dotenv
load_dotenv(env_path, override=True)
gateway_run._inject_keystore_env(force=True, external_managed_names={"OPENAI_API_KEY"})
assert os.environ.get("OPENAI_API_KEY") == "env-replacement"
def test_gateway_refresh_delete_preserves_same_value_external_replacement(monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir(parents=True)
env_path = home / ".env"
env_path.write_text("")
(home / "config.yaml").write_text("toolsets:\n- hermes-cli\n")
monkeypatch.setenv("HERMES_HOME", str(home))
from keystore.client import KeystoreClient, reset_keystore
reset_keystore()
ks = KeystoreClient(home / "keystore" / "secrets.db")
ks.initialize("passphrase")
ks.set_secret("OPENAI_API_KEY", "same-value")
monkeypatch.setenv("HERMES_KEYSTORE_PASSPHRASE", "passphrase")
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
gateway_run = _reload_gateway_run(monkeypatch, home)
assert os.environ.get("OPENAI_API_KEY") == "same-value"
# Move the key back to external .env management without rotating the string.
ks.delete_secret("OPENAI_API_KEY")
env_path.write_text("OPENAI_API_KEY=same-value\n")
from dotenv import load_dotenv
load_dotenv(env_path, override=True)
gateway_run._inject_keystore_env(force=True, external_managed_names={"OPENAI_API_KEY"})
assert os.environ.get("OPENAI_API_KEY") == "same-value"

View File

View File

@@ -0,0 +1,46 @@
"""Tests for keystore.categories — secret classification."""
import pytest
from keystore.categories import SecretCategory, default_category, DEFAULT_CATEGORIES
class TestSecretCategory:
def test_enum_values(self):
assert SecretCategory.INJECTABLE.value == "injectable"
assert SecretCategory.GATED.value == "gated"
assert SecretCategory.SEALED.value == "sealed"
assert SecretCategory.USER_ONLY.value == "user_only"
def test_string_enum(self):
"""SecretCategory is a str enum — can be compared as string."""
assert SecretCategory.INJECTABLE == "injectable"
assert SecretCategory.SEALED.value == "sealed"
class TestDefaultCategory:
def test_known_injectable(self):
assert default_category("OPENROUTER_API_KEY") == SecretCategory.INJECTABLE
assert default_category("FAL_KEY") == SecretCategory.INJECTABLE
assert default_category("TELEGRAM_BOT_TOKEN") == SecretCategory.INJECTABLE
def test_known_gated(self):
assert default_category("GITHUB_TOKEN") == SecretCategory.GATED
def test_known_user_only(self):
assert default_category("SUDO_PASSWORD") == SecretCategory.USER_ONLY
def test_wallet_keys_always_sealed(self):
assert default_category("wallet:eth:0xABC") == SecretCategory.SEALED
assert default_category("wallet:sol:7xKL") == SecretCategory.SEALED
assert default_category("wallet:meta:0xABC") == SecretCategory.SEALED
def test_unknown_defaults_to_injectable(self):
"""Unknown keys default to injectable for backward compatibility."""
assert default_category("SOME_RANDOM_KEY") == SecretCategory.INJECTABLE
assert default_category("MY_CUSTOM_TOKEN") == SecretCategory.INJECTABLE
def test_default_categories_dict_complete(self):
"""All entries in DEFAULT_CATEGORIES should be valid SecretCategory values."""
for name, cat in DEFAULT_CATEGORIES.items():
assert isinstance(cat, SecretCategory), f"{name} has invalid category: {cat}"

View File

@@ -0,0 +1,197 @@
"""Tests for keystore.client — high-level keystore API."""
import os
import tempfile
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
nacl = pytest.importorskip("nacl")
argon2 = pytest.importorskip("argon2")
from keystore.client import KeystoreClient, reset_keystore
from keystore.store import PassphraseMismatch, KeystoreLocked
@pytest.fixture(autouse=True)
def _reset_singleton():
"""Reset the global singleton before each test."""
reset_keystore()
yield
reset_keystore()
@pytest.fixture
def ks(tmp_path):
"""Return an initialized and unlocked KeystoreClient."""
db = tmp_path / "keystore" / "secrets.db"
client = KeystoreClient(db)
client.initialize("test-pass")
return client
class TestEnsureUnlocked:
def test_already_unlocked(self, ks):
assert ks.ensure_unlocked() is True
def test_unlock_from_credential_store(self, ks, tmp_path):
ks.lock()
with patch("keystore.credential_store.retrieve_passphrase", return_value="test-pass"):
assert ks.ensure_unlocked(interactive=False) is True
def test_unlock_from_env_var(self, ks):
ks.lock()
with patch.dict(os.environ, {"HERMES_KEYSTORE_PASSPHRASE": "test-pass"}):
with patch("keystore.credential_store.retrieve_passphrase", return_value=None):
assert ks.ensure_unlocked(interactive=False) is True
def test_non_interactive_raises_when_locked(self, ks):
ks.lock()
with patch("keystore.credential_store.retrieve_passphrase", return_value=None):
with patch.dict(os.environ, {}, clear=False):
# Remove the env var if present
os.environ.pop("HERMES_KEYSTORE_PASSPHRASE", None)
with pytest.raises(KeystoreLocked):
ks.ensure_unlocked(interactive=False)
def test_not_initialized_returns_false(self, tmp_path):
db = tmp_path / "ks2" / "secrets.db"
client = KeystoreClient(db)
assert client.ensure_unlocked(interactive=False) is False
class TestInjectEnv:
def test_inject_populates_environ(self, ks):
ks.set_secret("TEST_INJECT_KEY_1", "value1")
ks.set_secret("TEST_INJECT_KEY_2", "value2")
# Clear any existing env vars
os.environ.pop("TEST_INJECT_KEY_1", None)
os.environ.pop("TEST_INJECT_KEY_2", None)
injected = ks.inject_env()
assert os.environ.get("TEST_INJECT_KEY_1") == "value1"
assert os.environ.get("TEST_INJECT_KEY_2") == "value2"
assert injected["TEST_INJECT_KEY_1"] is True
assert injected["TEST_INJECT_KEY_2"] is True
# Cleanup
os.environ.pop("TEST_INJECT_KEY_1", None)
os.environ.pop("TEST_INJECT_KEY_2", None)
def test_inject_does_not_overwrite_existing(self, ks):
ks.set_secret("TEST_INJECT_EXISTING", "from-keystore")
os.environ["TEST_INJECT_EXISTING"] = "from-shell"
injected = ks.inject_env()
assert os.environ["TEST_INJECT_EXISTING"] == "from-shell"
assert injected["TEST_INJECT_EXISTING"] is False
os.environ.pop("TEST_INJECT_EXISTING", None)
def test_inject_skips_non_injectable(self, ks):
ks.set_secret("SEALED_KEY", "secret", category="sealed")
ks.set_secret("USER_KEY", "secret", category="user_only")
os.environ.pop("SEALED_KEY", None)
os.environ.pop("USER_KEY", None)
injected = ks.inject_env()
assert "SEALED_KEY" not in injected
assert "USER_KEY" not in injected
assert "SEALED_KEY" not in os.environ
assert "USER_KEY" not in os.environ
class TestMigrateFromEnv:
def test_migrate_basic(self, ks, tmp_path):
env_file = tmp_path / ".env"
env_file.write_text(
"# Comment line\n"
"OPENROUTER_API_KEY=sk-or-test-123\n"
"FAL_KEY=fal_test_456\n"
"SOME_CONFIG=not-a-secret\n"
"EMPTY_VAR=\n"
)
migrated = ks.migrate_from_env(env_file)
assert "OPENROUTER_API_KEY" in migrated
assert "FAL_KEY" in migrated
# Non-secret short values should be skipped
assert "EMPTY_VAR" not in migrated
# Verify values are stored correctly
assert ks.get_secret("OPENROUTER_API_KEY") == "sk-or-test-123"
assert ks.get_secret("FAL_KEY") == "fal_test_456"
def test_migrate_quoted_values(self, ks, tmp_path):
env_file = tmp_path / ".env"
env_file.write_text(
'MY_API_KEY="sk-quoted-value"\n'
"OTHER_TOKEN='single-quoted-value'\n"
)
migrated = ks.migrate_from_env(env_file)
assert ks.get_secret("MY_API_KEY") == "sk-quoted-value"
assert ks.get_secret("OTHER_TOKEN") == "single-quoted-value"
def test_migrate_nonexistent_file(self, ks, tmp_path):
migrated = ks.migrate_from_env(tmp_path / "nonexistent")
assert migrated == {}
def test_migrate_assigns_categories(self, ks, tmp_path):
env_file = tmp_path / ".env"
env_file.write_text(
"OPENROUTER_API_KEY=sk-test\n"
"SUDO_PASSWORD=mysudopass\n"
"GITHUB_TOKEN=ghp_test1234567890\n"
)
migrated = ks.migrate_from_env(env_file)
assert migrated.get("OPENROUTER_API_KEY") == "injectable"
assert migrated.get("SUDO_PASSWORD") == "user_only"
assert migrated.get("GITHUB_TOKEN") == "gated"
class TestSecretManagement:
def test_set_and_get(self, ks):
ks.set_secret("MY_KEY", "my-value", description="Test key")
assert ks.get_secret("MY_KEY") == "my-value"
def test_delete(self, ks):
ks.set_secret("DEL_KEY", "val")
assert ks.delete_secret("DEL_KEY")
assert ks.get_secret("DEL_KEY") is None
def test_list(self, ks):
ks.set_secret("A", "1")
ks.set_secret("B", "2")
secrets = ks.list_secrets()
assert len(secrets) == 2
def test_set_category_validation(self, ks):
ks.set_secret("KEY", "val")
from keystore.store import KeystoreError
with pytest.raises(KeystoreError, match="Invalid category"):
ks.set_category("KEY", "bogus")
class TestRememberForget:
def test_remember_no_backend(self, ks):
with patch("keystore.credential_store.is_available", return_value=False):
with patch("keystore.credential_store.backend_name", return_value=None):
success, msg = ks.remember_passphrase("test-pass")
assert not success
assert "No credential store" in msg
def test_remember_success(self, ks):
with patch("keystore.credential_store.is_available", return_value=True):
with patch("keystore.credential_store.backend_name", return_value="Test Backend"):
with patch("keystore.credential_store.store_passphrase", return_value=True):
success, msg = ks.remember_passphrase("test-pass")
assert success
assert msg == "Test Backend"
def test_forget(self, ks):
with patch("keystore.credential_store.delete_passphrase", return_value=True):
with patch("keystore.credential_store.backend_name", return_value="Test"):
success, msg = ks.forget_passphrase()
assert success

View File

@@ -0,0 +1,172 @@
"""Tests for keystore.credential_store — cross-platform passphrase caching."""
import os
import platform
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
from keystore.credential_store import (
_KeyringBackend,
_KeyctlBackend,
_EncryptedFileBackend,
_get_machine_id,
_detect_backend,
is_available,
backend_name,
store_passphrase,
retrieve_passphrase,
delete_passphrase,
)
# Reset cached backend between tests
@pytest.fixture(autouse=True)
def _reset_cache():
import keystore.credential_store as cs
cs._cached_backend = None
cs._detection_done = False
yield
cs._cached_backend = None
cs._detection_done = False
class TestMachineId:
def test_returns_string(self):
mid = _get_machine_id()
assert isinstance(mid, str)
assert len(mid) > 0
def test_stable(self):
"""Machine ID should be the same across calls."""
assert _get_machine_id() == _get_machine_id()
class TestEncryptedFileBackend:
"""Test the encrypted file fallback (always available with pynacl)."""
@pytest.fixture
def backend(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
return _EncryptedFileBackend()
@pytest.mark.skipif(
not pytest.importorskip("nacl", reason="pynacl not installed"),
reason="pynacl required",
)
def test_store_and_retrieve(self, backend):
assert backend.store("my-secret-passphrase")
assert backend.retrieve() == "my-secret-passphrase"
@pytest.mark.skipif(
not pytest.importorskip("nacl", reason="pynacl not installed"),
reason="pynacl required",
)
def test_delete(self, backend):
backend.store("passphrase")
assert backend.delete()
assert backend.retrieve() is None
@pytest.mark.skipif(
not pytest.importorskip("nacl", reason="pynacl not installed"),
reason="pynacl required",
)
def test_retrieve_nonexistent(self, backend):
assert backend.retrieve() is None
@pytest.mark.skipif(
not pytest.importorskip("nacl", reason="pynacl not installed"),
reason="pynacl required",
)
def test_overwrite(self, backend):
backend.store("first")
backend.store("second")
assert backend.retrieve() == "second"
class TestKeyringBackend:
def test_wraps_keyring_module(self):
mock_kr = MagicMock()
# Create a real class to simulate a keyring backend
class FakeWinVault:
pass
FakeWinVault.__name__ = "WinVaultKeyring"
mock_kr.get_keyring.return_value = FakeWinVault()
backend = _KeyringBackend(mock_kr)
assert backend.name == "Windows Credential Locker"
def test_store_calls_set_password(self):
mock_kr = MagicMock()
mock_kr.get_keyring.return_value = MagicMock()
backend = _KeyringBackend(mock_kr)
backend.store("pass123")
mock_kr.set_password.assert_called_once()
def test_retrieve_calls_get_password(self):
mock_kr = MagicMock()
mock_kr.get_keyring.return_value = MagicMock()
mock_kr.get_password.return_value = "stored-pass"
backend = _KeyringBackend(mock_kr)
assert backend.retrieve() == "stored-pass"
def test_store_handles_exception(self):
mock_kr = MagicMock()
mock_kr.get_keyring.return_value = MagicMock()
mock_kr.set_password.side_effect = Exception("D-Bus error")
backend = _KeyringBackend(mock_kr)
assert backend.store("pass") is False
class TestDetection:
def test_detect_with_no_backends(self):
"""When keyring is unavailable and keyctl is missing, should fall back to encrypted file."""
with patch.dict("sys.modules", {"keyring": None}):
with patch("subprocess.run", side_effect=OSError("not found")):
# If pynacl is available, should get encrypted file backend
try:
import nacl.secret # noqa
backend = _detect_backend()
if backend is not None:
assert isinstance(backend, _EncryptedFileBackend)
except ImportError:
backend = _detect_backend()
assert backend is None
def test_public_api_consistency(self):
"""is_available and backend_name should agree."""
if is_available():
assert backend_name() is not None
else:
assert backend_name() is None
class TestPublicAPI:
"""Test the module-level public functions with mocked backend."""
def test_store_and_retrieve_with_mock(self):
import keystore.credential_store as cs
mock_backend = MagicMock()
mock_backend.store.return_value = True
mock_backend.retrieve.return_value = "cached-pass"
mock_backend.name = "Mock Backend"
cs._cached_backend = mock_backend
cs._detection_done = True
assert store_passphrase("my-pass") is True
mock_backend.store.assert_called_with("my-pass")
assert retrieve_passphrase() == "cached-pass"
assert backend_name() == "Mock Backend"
assert is_available() is True
def test_no_backend_returns_none(self):
import keystore.credential_store as cs
cs._cached_backend = None
cs._detection_done = True
assert store_passphrase("pass") is False
assert retrieve_passphrase() is None
assert delete_passphrase() is False
assert is_available() is False
assert backend_name() is None

View File

@@ -0,0 +1,32 @@
"""Security-focused tests for credential store behavior."""
import importlib
from unittest.mock import patch
import pytest
@pytest.fixture(autouse=True)
def _reset_cache():
import keystore.credential_store as cs
cs._cached_backend = None
cs._detection_done = False
yield
cs._cached_backend = None
cs._detection_done = False
def test_no_insecure_encrypted_file_auto_fallback_when_no_real_backend():
"""If keyring/keyctl are unavailable, remember backend must be unavailable.
An automatically selected machine-derived encrypted file would be derivable
by the same-user agent process and would collapse the keystore boundary.
"""
import keystore.credential_store as cs
with patch.dict("sys.modules", {"keyring": None}):
with patch("subprocess.run", side_effect=OSError("not found")):
importlib.reload(cs)
assert cs.backend_name() is None
assert cs.is_available() is False
assert cs.store_passphrase("secret") is False

View File

@@ -0,0 +1,270 @@
"""Tests for keystore.store — encrypted SQLite secret store."""
import os
import tempfile
from pathlib import Path
import pytest
# Skip entire module if keystore deps aren't installed
nacl = pytest.importorskip("nacl")
argon2 = pytest.importorskip("argon2")
from keystore.store import (
EncryptedStore,
KeystoreError,
KeystoreLocked,
KeystoreCorrupted,
PassphraseMismatch,
)
@pytest.fixture
def tmp_db(tmp_path):
"""Return a path for a temporary keystore DB."""
return tmp_path / "keystore" / "secrets.db"
@pytest.fixture
def store(tmp_db):
"""Return an initialized and unlocked store."""
s = EncryptedStore(tmp_db)
s.initialize("test-passphrase")
return s
class TestInitialization:
def test_initialize_creates_db(self, tmp_db):
s = EncryptedStore(tmp_db)
assert not s.is_initialized
s.initialize("my-pass")
assert s.is_initialized
assert tmp_db.exists()
def test_initialize_sets_permissions(self, tmp_db):
s = EncryptedStore(tmp_db)
s.initialize("my-pass")
# Directory should be 0700
assert oct(tmp_db.parent.stat().st_mode & 0o777) == oct(0o700)
# File should be 0600
assert oct(tmp_db.stat().st_mode & 0o777) == oct(0o600)
def test_initialize_twice_raises(self, store, tmp_db):
s2 = EncryptedStore(tmp_db)
with pytest.raises(KeystoreError, match="already initialized"):
s2.initialize("another-pass")
def test_initialize_unlocks_immediately(self, tmp_db):
s = EncryptedStore(tmp_db)
assert not s.is_unlocked
s.initialize("pass")
assert s.is_unlocked
class TestUnlockLock:
def test_unlock_correct_passphrase(self, tmp_db):
s = EncryptedStore(tmp_db)
s.initialize("correct-pass")
s.lock()
assert not s.is_unlocked
s.unlock("correct-pass")
assert s.is_unlocked
def test_unlock_wrong_passphrase(self, tmp_db):
s = EncryptedStore(tmp_db)
s.initialize("correct-pass")
s.lock()
with pytest.raises(PassphraseMismatch):
s.unlock("wrong-pass")
def test_unlock_not_initialized(self, tmp_db):
s = EncryptedStore(tmp_db)
with pytest.raises(KeystoreError, match="not initialized"):
s.unlock("any-pass")
def test_lock_clears_key(self, store):
assert store.is_unlocked
store.lock()
assert not store.is_unlocked
def test_operations_fail_when_locked(self, store):
store.lock()
with pytest.raises(KeystoreLocked):
store.set("KEY", "value")
with pytest.raises(KeystoreLocked):
store.get("KEY")
with pytest.raises(KeystoreLocked):
store.list_secrets()
class TestSecretCRUD:
def test_set_and_get(self, store):
store.set("MY_KEY", "my-secret-value", category="injectable")
assert store.get("MY_KEY") == "my-secret-value"
def test_get_nonexistent_returns_none(self, store):
assert store.get("DOES_NOT_EXIST") is None
def test_set_overwrites(self, store):
store.set("KEY", "value1")
store.set("KEY", "value2")
assert store.get("KEY") == "value2"
def test_delete(self, store):
store.set("KEY", "value")
assert store.delete("KEY")
assert store.get("KEY") is None
def test_delete_nonexistent(self, store):
assert not store.delete("NOPE")
def test_list_secrets(self, store):
store.set("A_KEY", "val1", category="injectable", description="First key")
store.set("B_KEY", "val2", category="gated", description="Second key")
secrets = store.list_secrets()
assert len(secrets) == 2
names = [s.name for s in secrets]
assert "A_KEY" in names
assert "B_KEY" in names
# Values should NOT be in the listing
for s in secrets:
assert not hasattr(s, "value")
def test_secret_count(self, store):
assert store.secret_count() == 0
store.set("K1", "v1")
store.set("K2", "v2")
assert store.secret_count() == 2
def test_unicode_values(self, store):
store.set("UNICODE", "こんにちは世界 🔐")
assert store.get("UNICODE") == "こんにちは世界 🔐"
def test_long_values(self, store):
long_val = "x" * 10000
store.set("LONG", long_val)
assert store.get("LONG") == long_val
def test_empty_string_value(self, store):
store.set("EMPTY", "")
assert store.get("EMPTY") == ""
class TestCategories:
def test_set_category(self, store):
store.set("KEY", "val", category="injectable")
assert store.set_category("KEY", "gated")
secrets = store.list_secrets()
assert secrets[0].category == "gated"
def test_sealed_denied_to_agent(self, store):
store.set("WALLET_KEY", "private-key", category="sealed")
# Agent requester should be denied
assert store.get("WALLET_KEY", requester="agent") is None
# Daemon requester should succeed
assert store.get("WALLET_KEY", requester="daemon") == "private-key"
def test_user_only_denied_to_agent(self, store):
store.set("SUDO_PASS", "password", category="user_only")
assert store.get("SUDO_PASS", requester="agent") is None
assert store.get("SUDO_PASS", requester="gateway") is None
assert store.get("SUDO_PASS", requester="cli") == "password"
def test_injectable_accessible_to_all(self, store):
store.set("API_KEY", "sk-123", category="injectable")
assert store.get("API_KEY", requester="agent") == "sk-123"
assert store.get("API_KEY", requester="cli") == "sk-123"
assert store.get("API_KEY", requester="gateway") == "sk-123"
class TestInjectableSecrets:
def test_get_injectable_secrets(self, store):
store.set("KEY1", "val1", category="injectable")
store.set("KEY2", "val2", category="injectable")
store.set("KEY3", "val3", category="sealed")
store.set("KEY4", "val4", category="user_only")
injectable = store.get_injectable_secrets()
assert injectable == {"KEY1": "val1", "KEY2": "val2"}
def test_get_injectable_empty(self, store):
assert store.get_injectable_secrets() == {}
class TestAccessLog:
def test_access_logged(self, store):
store.set("KEY", "val")
store.get("KEY", requester="agent")
log = store.get_access_log(limit=10)
assert len(log) >= 2 # write + read
actions = [e["action"] for e in log]
assert "write" in actions
assert "read" in actions
def test_denied_access_logged(self, store):
store.set("SECRET", "val", category="sealed")
store.get("SECRET", requester="agent") # should be denied
log = store.get_access_log(limit=5)
assert any(e["action"] == "denied" for e in log)
class TestChangePassphrase:
def test_change_passphrase(self, tmp_db):
s = EncryptedStore(tmp_db)
s.initialize("old-pass")
s.set("KEY", "my-value")
s.change_passphrase("old-pass", "new-pass")
# Old passphrase should fail
s.lock()
with pytest.raises(PassphraseMismatch):
s.unlock("old-pass")
# New passphrase should work and data should be intact
s.unlock("new-pass")
assert s.get("KEY") == "my-value"
def test_change_passphrase_wrong_old(self, store):
with pytest.raises(PassphraseMismatch):
store.change_passphrase("wrong-old", "new-pass")
class TestEncryptionIntegrity:
def test_different_passphrases_different_ciphertext(self, tmp_path):
"""Two stores with different passphrases produce different ciphertext."""
db1 = tmp_path / "s1" / "secrets.db"
db2 = tmp_path / "s2" / "secrets.db"
s1 = EncryptedStore(db1)
s1.initialize("pass1")
s1.set("KEY", "same-value")
s2 = EncryptedStore(db2)
s2.initialize("pass2")
s2.set("KEY", "same-value")
# Read raw ciphertext from both DBs
import sqlite3
c1 = sqlite3.connect(str(db1)).execute(
"SELECT encrypted_value FROM secrets WHERE name='KEY'"
).fetchone()[0]
c2 = sqlite3.connect(str(db2)).execute(
"SELECT encrypted_value FROM secrets WHERE name='KEY'"
).fetchone()[0]
assert c1 != c2 # Different keys → different ciphertext
def test_same_value_different_nonce(self, store):
"""Setting the same value twice produces different ciphertext (random nonce)."""
import sqlite3
store.set("KEY", "same-value")
c1 = sqlite3.connect(str(store._db_path)).execute(
"SELECT encrypted_value FROM secrets WHERE name='KEY'"
).fetchone()[0]
store.set("KEY", "same-value")
c2 = sqlite3.connect(str(store._db_path)).execute(
"SELECT encrypted_value FROM secrets WHERE name='KEY'"
).fetchone()[0]
assert c1 != c2 # Random nonce → different ciphertext each time

0
tests/wallet/__init__.py Normal file
View File

View File

@@ -0,0 +1,214 @@
"""Tests for wallet.manager — wallet lifecycle and operations."""
import json
from decimal import Decimal
from unittest.mock import MagicMock, patch
import pytest
nacl = pytest.importorskip("nacl")
argon2 = pytest.importorskip("argon2")
from keystore.client import KeystoreClient
from wallet.manager import WalletManager, WalletInfo, WalletNotFound, WalletError
from wallet.chains import ChainProvider, Balance, TransactionResult, GasEstimate, ChainConfig
class FakeProvider(ChainProvider):
"""Mock chain provider for testing."""
def __init__(self):
super().__init__(ChainConfig(
chain_id="test-chain",
display_name="Test Chain",
symbol="TEST",
decimals=18,
rpc_url="http://localhost:8545",
explorer_url="https://testscan.io",
is_testnet=True,
))
self._balances = {}
self._next_tx_hash = "0xabcdef1234567890"
self._counter = 0
def get_balance(self, address: str) -> Balance:
bal = self._balances.get(address, Decimal("1.5"))
return Balance(
chain="test-chain", address=address,
balance=bal, balance_raw=int(bal * 10**18),
symbol="TEST", decimals=18,
)
def send_transaction(self, from_private_key, to_address, amount) -> TransactionResult:
return TransactionResult(
tx_hash=self._next_tx_hash,
chain="test-chain",
status="submitted",
explorer_url=f"https://testscan.io/tx/{self._next_tx_hash}",
)
def estimate_fee(self, from_address, to_address, amount) -> GasEstimate:
return GasEstimate(
chain="test-chain",
estimated_fee=Decimal("0.001"),
estimated_fee_raw=1000000000000000,
symbol="TEST",
)
def validate_address(self, address: str) -> bool:
return address.startswith("0x") and len(address) == 42
def generate_keypair(self):
self._counter += 1
suffix = format(self._counter, "040x")[-40:]
return ("0x" + suffix.upper(), ("deadbeef" * 7) + format(self._counter, "08x"))
@staticmethod
def address_from_key(private_key: str) -> str:
return "0x" + "B" * 40
@pytest.fixture
def ks(tmp_path):
"""Initialized and unlocked keystore."""
db = tmp_path / "keystore" / "secrets.db"
client = KeystoreClient(db)
client.initialize("test-pass")
return client
@pytest.fixture
def mgr(ks):
"""Wallet manager with a fake test chain provider."""
m = WalletManager(ks)
m.register_provider("test-chain", FakeProvider())
return m
class TestWalletCreation:
def test_create_wallet(self, mgr):
w = mgr.create_wallet(chain="test-chain", label="My Test Wallet")
assert w.label == "My Test Wallet"
assert w.chain == "test-chain"
assert w.address.startswith("0x")
assert w.wallet_type == "user"
def test_create_agent_wallet(self, mgr):
w = mgr.create_wallet(chain="test-chain", wallet_type="agent")
assert w.wallet_type == "agent"
def test_create_unsupported_chain(self, mgr):
with pytest.raises(WalletError, match="No provider"):
mgr.create_wallet(chain="nonexistent")
def test_list_wallets(self, mgr):
mgr.create_wallet(chain="test-chain", label="W1")
mgr.create_wallet(chain="test-chain", label="W2")
wallets = mgr.list_wallets()
assert len(wallets) == 2
labels = {w.label for w in wallets}
assert "W1" in labels
assert "W2" in labels
def test_get_wallet(self, mgr):
w = mgr.create_wallet(chain="test-chain", label="Find Me")
found = mgr.get_wallet(w.wallet_id)
assert found.label == "Find Me"
assert found.address == w.address
def test_get_wallet_not_found(self, mgr):
with pytest.raises(WalletNotFound):
mgr.get_wallet("w_nonexistent")
def test_delete_wallet(self, mgr):
w = mgr.create_wallet(chain="test-chain")
assert mgr.delete_wallet(w.wallet_id)
assert len(mgr.list_wallets()) == 0
class TestImport:
def test_import_wallet(self, mgr):
w = mgr.import_wallet(
chain="test-chain",
private_key="deadbeef" * 8,
label="Imported",
)
assert w.label == "Imported"
assert w.address == "0x" + "B" * 40 # from FakeProvider.address_from_key
def test_duplicate_import_same_address_rejected(self, mgr):
mgr.import_wallet(chain="test-chain", private_key="deadbeef" * 8, label="Imported")
with pytest.raises(WalletError, match="already exists"):
mgr.import_wallet(chain="test-chain", private_key="deadbeef" * 8, label="Imported Again")
def test_delete_does_not_remove_shared_key_of_other_wallet(self, mgr):
# Create one wallet via import, then manually create a second metadata alias to same address
w = mgr.import_wallet(chain="test-chain", private_key="deadbeef" * 8, label="Imported")
# Direct metadata insert simulates legacy duplicate state
mgr._ks.set_secret(
"wallet:meta:w_dup",
json.dumps({
"wallet_id": "w_dup",
"label": "Duplicate",
"chain": w.chain,
"address": w.address,
"wallet_type": "user",
"created_at": w.created_at,
}),
category="sealed",
)
assert mgr.delete_wallet(w.wallet_id)
# Duplicate metadata should still be able to export/read key
dup = mgr.get_wallet("w_dup")
assert mgr.export_private_key(dup.wallet_id)
class TestBalance:
def test_get_balance(self, mgr):
w = mgr.create_wallet(chain="test-chain")
bal = mgr.get_balance(w.wallet_id)
assert bal.symbol == "TEST"
assert bal.balance == Decimal("1.5")
class TestSend:
def test_send_success(self, mgr):
w = mgr.create_wallet(chain="test-chain")
result = mgr.send(w.wallet_id, "0x" + "C" * 40, Decimal("0.1"))
assert result.status == "submitted"
assert result.tx_hash == "0xabcdef1234567890"
def test_send_invalid_address(self, mgr):
w = mgr.create_wallet(chain="test-chain")
result = mgr.send(w.wallet_id, "invalid", Decimal("0.1"))
assert result.status == "failed"
assert "Invalid address" in result.error
def test_tx_history(self, mgr):
w = mgr.create_wallet(chain="test-chain")
mgr.send(w.wallet_id, "0x" + "C" * 40, Decimal("0.1"))
mgr.send(w.wallet_id, "0x" + "D" * 40, Decimal("0.2"))
history = mgr.get_tx_history()
assert len(history) == 2
class TestResolve:
def test_resolve_single_wallet(self, mgr):
w = mgr.create_wallet(chain="test-chain")
resolved = mgr.resolve_wallet()
assert resolved.wallet_id == w.wallet_id
def test_resolve_by_chain(self, mgr):
mgr.create_wallet(chain="test-chain", label="A")
resolved = mgr.resolve_wallet(chain="test-chain")
assert resolved.label == "A"
def test_resolve_no_wallets(self, mgr):
with pytest.raises(WalletNotFound, match="No wallets"):
mgr.resolve_wallet()
def test_resolve_ambiguous(self, mgr):
mgr.create_wallet(chain="test-chain", label="A")
mgr.create_wallet(chain="test-chain", label="B")
with pytest.raises(WalletError, match="Multiple"):
mgr.resolve_wallet()

View File

@@ -0,0 +1,166 @@
"""Tests for wallet persistence: tx log, policy freeze, export path."""
import json
from decimal import Decimal
import pytest
nacl = pytest.importorskip("nacl")
argon2 = pytest.importorskip("argon2")
from keystore.client import KeystoreClient
from wallet.manager import WalletManager
from wallet.policy import PolicyEngine, TxRequest, PolicyVerdict
from wallet.chains import ChainProvider, Balance, TransactionResult, GasEstimate, ChainConfig
class FakeProvider(ChainProvider):
def __init__(self):
super().__init__(ChainConfig(
chain_id="test-chain",
display_name="Test Chain",
symbol="TEST",
decimals=18,
rpc_url="http://localhost:8545",
explorer_url="https://testscan.io",
is_testnet=True,
))
def get_balance(self, address: str) -> Balance:
return Balance("test-chain", address, Decimal("10"), 10, "TEST", 18)
def send_transaction(self, from_private_key, to_address, amount) -> TransactionResult:
return TransactionResult(
tx_hash="0xabc",
chain="test-chain",
status="submitted",
explorer_url="https://testscan.io/tx/0xabc",
)
def estimate_fee(self, from_address, to_address, amount) -> GasEstimate:
return GasEstimate("test-chain", Decimal("0.001"), 1, "TEST")
def validate_address(self, address: str) -> bool:
return True
def generate_keypair(self):
return ("0x" + "A" * 40, "deadbeef" * 8)
@staticmethod
def address_from_key(private_key: str) -> str:
return "0x" + "B" * 40
@pytest.fixture
def ks(tmp_path):
db = tmp_path / "keystore" / "secrets.db"
client = KeystoreClient(db)
client.initialize("test-pass")
return client
def test_export_private_key_for_cli_export_requester(ks, tmp_path):
mgr = WalletManager(ks, state_dir=tmp_path / "wallet")
mgr.register_provider("test-chain", FakeProvider())
w = mgr.create_wallet(chain="test-chain", label="Exportable")
exported = mgr.export_private_key(w.wallet_id)
assert exported
assert isinstance(exported, str)
def test_tx_history_persists_across_manager_instances(ks, tmp_path):
state_dir = tmp_path / "wallet"
mgr1 = WalletManager(ks, state_dir=state_dir)
mgr1.register_provider("test-chain", FakeProvider())
w = mgr1.create_wallet(chain="test-chain")
mgr1.send(w.wallet_id, "0xreceiver", Decimal("1.0"), decided_by="owner_cli", policy_result='{}')
mgr2 = WalletManager(ks, state_dir=state_dir)
mgr2.register_provider("test-chain", FakeProvider())
hist = mgr2.get_tx_history(w.wallet_id)
assert len(hist) == 1
assert hist[0].tx_hash == "0xabc"
def test_tx_history_merges_across_multiple_manager_instances(ks, tmp_path):
state_dir = tmp_path / "wallet"
mgr1 = WalletManager(ks, state_dir=state_dir)
mgr1.register_provider("test-chain", FakeProvider())
w = mgr1.create_wallet(chain="test-chain")
mgr2 = WalletManager(ks, state_dir=state_dir)
mgr2.register_provider("test-chain", FakeProvider())
mgr1.send(w.wallet_id, "0xreceiver1", Decimal("1.0"), decided_by="owner_cli", policy_result='{}')
mgr2.send(w.wallet_id, "0xreceiver2", Decimal("2.0"), decided_by="owner_cli", policy_result='{}')
mgr3 = WalletManager(ks, state_dir=state_dir)
mgr3.register_provider("test-chain", FakeProvider())
hist = mgr3.get_tx_history(w.wallet_id, limit=10)
assert len(hist) == 2
def test_policy_freeze_persists_across_instances(tmp_path):
state = tmp_path / "wallet" / "policy_state.json"
p1 = PolicyEngine(state_path=state)
p1.freeze()
p2 = PolicyEngine(state_path=state)
tx = TxRequest(
wallet_id="w1", wallet_type="agent", chain="test-chain",
to_address="0xreceiver", amount=Decimal("0.1"), symbol="TEST",
)
result = p2.evaluate(tx)
assert result.verdict == PolicyVerdict.BLOCK
assert "frozen" in result.reason.lower()
def test_policy_record_transaction_persists(tmp_path):
state = tmp_path / "wallet" / "policy_state.json"
p1 = PolicyEngine(state_path=state)
tx = TxRequest(
wallet_id="w1", wallet_type="agent", chain="test-chain",
to_address="0xreceiver", amount=Decimal("0.5"), symbol="TEST",
)
p1.record_transaction(tx)
p2 = PolicyEngine(state_path=state)
p2._policies = {"cooldown": {"min_seconds": 99999}}
result = p2.evaluate(tx)
assert result.verdict == PolicyVerdict.BLOCK
assert result.failed == "cooldown"
def test_policy_record_transaction_merges_across_instances(tmp_path):
state = tmp_path / "wallet" / "policy_state.json"
tx = TxRequest(
wallet_id="w1", wallet_type="agent", chain="test-chain",
to_address="0xreceiver", amount=Decimal("1.0"), symbol="TEST",
)
p1 = PolicyEngine(state_path=state)
p2 = PolicyEngine(state_path=state)
p1.record_transaction(tx)
p2.record_transaction(tx)
p3 = PolicyEngine(state_path=state, policies={"daily_limit": {"max_native": "1.5"}})
result = p3.evaluate(tx)
assert result.verdict == PolicyVerdict.BLOCK
assert result.failed == "daily_limit"
def test_user_wallet_hard_blocks_run_before_require_approval(tmp_path):
state = tmp_path / "wallet" / "policy_state.json"
p = PolicyEngine(
state_path=state,
policies={
"spending_limit": {"max_native": "0.5"},
"blocked_recipients": {"addresses": ["0xblocked"]},
},
)
tx = TxRequest(
wallet_id="w1", wallet_type="user", chain="test-chain",
to_address="0xblocked", amount=Decimal("1.0"), symbol="TEST",
)
result = p.evaluate(tx)
assert result.verdict == PolicyVerdict.BLOCK
assert result.failed in {"spending_limit", "blocked_recipients"}

150
tests/wallet/test_policy.py Normal file
View File

@@ -0,0 +1,150 @@
"""Tests for wallet.policy — transaction policy engine."""
import time
from decimal import Decimal
import pytest
from wallet.policy import (
PolicyEngine,
PolicyResult,
PolicyVerdict,
TxRequest,
AGENT_WALLET_DEFAULTS,
)
def _make_tx(**overrides) -> TxRequest:
defaults = {
"wallet_id": "w_test",
"wallet_type": "agent",
"chain": "ethereum-sepolia",
"to_address": "0x1234567890abcdef1234567890abcdef12345678",
"amount": Decimal("0.01"),
"symbol": "ETH",
}
defaults.update(overrides)
return TxRequest(**defaults)
class TestBasicPolicy:
def test_allow_small_agent_tx(self):
engine = PolicyEngine()
tx = _make_tx(amount=Decimal("0.001"))
result = engine.evaluate(tx)
assert result.verdict == PolicyVerdict.ALLOW
def test_block_over_spending_limit(self):
engine = PolicyEngine({"spending_limit": {"max_native": "0.5"}})
tx = _make_tx(amount=Decimal("1.0"))
result = engine.evaluate(tx)
assert result.verdict == PolicyVerdict.BLOCK
assert "spending_limit" in result.failed
def test_require_approval_above_threshold(self):
engine = PolicyEngine({"require_approval": {"above_native": "0.1"}})
tx = _make_tx(amount=Decimal("0.5"), wallet_type="agent")
result = engine.evaluate(tx)
assert result.verdict == PolicyVerdict.REQUIRE_APPROVAL
def test_user_wallet_always_requires_approval(self):
engine = PolicyEngine()
tx = _make_tx(wallet_type="user", amount=Decimal("0.001"))
result = engine.evaluate(tx)
# User wallet defaults require approval for any amount
assert result.verdict == PolicyVerdict.REQUIRE_APPROVAL
class TestRateLimit:
def test_rate_limit_blocks_after_max(self):
engine = PolicyEngine({"rate_limit": {"max_txns": 2, "window_seconds": 3600}})
tx = _make_tx()
# First two should pass
r1 = engine.evaluate(tx)
engine.record_transaction(tx)
r2 = engine.evaluate(tx)
engine.record_transaction(tx)
# Third should be blocked
r3 = engine.evaluate(tx)
assert r3.verdict == PolicyVerdict.BLOCK
assert "rate_limit" in r3.failed
class TestCooldown:
def test_cooldown_blocks_rapid_txs(self):
engine = PolicyEngine({"cooldown": {"min_seconds": 60}})
tx = _make_tx()
# First is fine
engine.record_transaction(tx)
# Immediate second should be blocked
result = engine.evaluate(tx)
assert result.verdict == PolicyVerdict.BLOCK
assert "cooldown" in result.failed
class TestRecipientPolicies:
def test_allowed_recipients_blocks_unknown(self):
engine = PolicyEngine({
"allowed_recipients": {"addresses": ["0xAAAA"]},
})
tx = _make_tx(to_address="0xBBBB")
result = engine.evaluate(tx)
assert result.verdict == PolicyVerdict.BLOCK
def test_allowed_recipients_passes_known(self):
addr = "0x1234567890abcdef1234567890abcdef12345678"
engine = PolicyEngine({
"allowed_recipients": {"addresses": [addr]},
})
tx = _make_tx(to_address=addr)
# Agent defaults may still require approval, but shouldn't be BLOCKED
result = engine.evaluate(tx)
assert result.verdict != PolicyVerdict.BLOCK or result.failed != "allowed_recipients"
def test_blocked_recipients(self):
bad = "0xBADBADBADBADBADBADBADBADBADBADBADBADBADBA"
engine = PolicyEngine({
"blocked_recipients": {"addresses": [bad]},
})
tx = _make_tx(to_address=bad)
result = engine.evaluate(tx)
assert result.verdict == PolicyVerdict.BLOCK
assert "blocked_recipients" in result.failed
class TestFreezeKillSwitch:
def test_freeze_blocks_everything(self):
engine = PolicyEngine()
engine.freeze()
tx = _make_tx(amount=Decimal("0.0001"))
result = engine.evaluate(tx)
assert result.verdict == PolicyVerdict.BLOCK
assert "frozen" in result.reason.lower()
def test_unfreeze_resumes(self):
engine = PolicyEngine()
engine.freeze()
engine.unfreeze()
tx = _make_tx(amount=Decimal("0.0001"))
result = engine.evaluate(tx)
assert result.verdict != PolicyVerdict.BLOCK or result.failed != "freeze"
class TestDailyLimit:
def test_daily_limit_blocks_aggregate(self):
engine = PolicyEngine({"daily_limit": {"max_native": "0.05"}})
tx = _make_tx(amount=Decimal("0.03"))
# First tx: 0.03 of 0.05 limit
r1 = engine.evaluate(tx)
assert r1.verdict != PolicyVerdict.BLOCK or r1.failed != "daily_limit"
engine.record_transaction(tx)
# Second tx: 0.03 more would be 0.06 > 0.05
r2 = engine.evaluate(tx)
assert r2.verdict == PolicyVerdict.BLOCK
assert "daily_limit" in r2.failed

547
tools/wallet_tool.py Normal file
View File

@@ -0,0 +1,547 @@
"""Agent-facing wallet tools.
These are the tools the LLM can call. They go through the wallet manager
and policy engine — the agent never has access to private keys.
All handlers return JSON strings per Hermes convention.
Toolset: ``wallet`` (optional install: ``pip install 'hermes-agent[wallet]'``)
The toolset is gated on:
1. keystore + wallet packages being installed
2. The keystore being initialized and unlocked
3. At least one wallet existing
Tools:
wallet_list — List wallets with addresses and balances
wallet_balance — Check balance of a specific wallet
wallet_send — Send native tokens (policy-gated)
wallet_history — Transaction history
wallet_estimate_gas — Fee estimation
wallet_address — Get a wallet's deposit address (for sharing/receiving)
wallet_networks — List supported and active blockchain networks
"""
import json
import logging
from decimal import Decimal, InvalidOperation
from typing import Optional
from tools.registry import registry
logger = logging.getLogger(__name__)
# Lazy-loaded singleton (initialized on first tool call)
_wallet_manager = None
_policy_engine = None
def _get_manager():
"""Lazy-init the wallet manager + policy engine."""
global _wallet_manager, _policy_engine
if _wallet_manager is not None:
return _wallet_manager, _policy_engine
try:
from wallet.runtime import get_runtime
_wallet_manager, _policy_engine = get_runtime()
return _wallet_manager, _policy_engine
except ImportError:
return None, None
except Exception as e:
logger.debug("Wallet manager init failed: %s", e)
return None, None
def _check_wallet_available() -> bool:
"""Check if wallet functionality is available."""
mgr, _ = _get_manager()
return mgr is not None
# =========================================================================
# Tool handlers
# =========================================================================
def wallet_list(task_id: str = None, **kw) -> str:
"""List all wallets with their addresses and balances."""
mgr, _ = _get_manager()
if mgr is None:
return json.dumps({"error": "Wallet not available. Run 'hermes wallet create' first."})
wallets = mgr.list_wallets()
if not wallets:
return json.dumps({
"wallets": [],
"message": "No wallets found. Create one with 'hermes wallet create'.",
})
result = []
for w in wallets:
entry = {
"wallet_id": w.wallet_id,
"label": w.label,
"chain": w.chain,
"address": w.address,
"type": w.wallet_type,
}
# Try to fetch balance (non-blocking, skip on error)
try:
bal = mgr.get_balance(w.wallet_id)
entry["balance"] = str(bal.balance)
entry["symbol"] = bal.symbol
except Exception:
entry["balance"] = "unavailable"
entry["symbol"] = ""
result.append(entry)
return json.dumps({"wallets": result})
def wallet_balance(args: dict, task_id: str = None, **kw) -> str:
"""Check wallet balance."""
mgr, _ = _get_manager()
if mgr is None:
return json.dumps({"error": "Wallet not available"})
wallet_id = args.get("wallet_id")
chain = args.get("chain")
try:
wallet = mgr.resolve_wallet(wallet_id=wallet_id, chain=chain)
bal = mgr.get_balance(wallet.wallet_id)
return json.dumps({
"wallet": wallet.label,
"address": wallet.address,
"chain": wallet.chain,
"balance": str(bal.balance),
"symbol": bal.symbol,
})
except Exception as e:
return json.dumps({"error": str(e)})
def wallet_send(args: dict, task_id: str = None, **kw) -> str:
"""Request a token transfer. Subject to policy engine approval."""
mgr, policy = _get_manager()
if mgr is None:
return json.dumps({"error": "Wallet not available"})
to_address = args.get("to", "")
amount_str = args.get("amount", "")
wallet_id = args.get("wallet_id")
chain = args.get("chain")
if not to_address or not amount_str:
return json.dumps({"error": "Both 'to' and 'amount' are required"})
try:
amount = Decimal(amount_str)
except InvalidOperation:
return json.dumps({"error": f"Invalid amount: {amount_str}"})
if amount <= 0:
return json.dumps({"error": "Amount must be positive"})
try:
wallet = mgr.resolve_wallet(wallet_id=wallet_id, chain=chain)
except Exception as e:
return json.dumps({"error": str(e)})
# Get chain symbol
try:
provider = mgr.get_provider(wallet.chain)
symbol = provider.config.symbol
except Exception:
symbol = "?"
# Evaluate policy
from wallet.policy import TxRequest, PolicyVerdict
tx_req = TxRequest(
wallet_id=wallet.wallet_id,
wallet_type=wallet.wallet_type,
chain=wallet.chain,
to_address=to_address,
amount=amount,
symbol=symbol,
)
if policy:
result = policy.evaluate(tx_req)
if result.verdict == PolicyVerdict.BLOCK:
return json.dumps({
"status": "blocked",
"reason": result.reason,
"policy": result.failed,
})
if result.verdict == PolicyVerdict.REQUIRE_APPROVAL:
# Stash the transaction for user approval via CLI or gateway
from wallet.approval import PendingWalletTx, submit_pending
pending_tx = PendingWalletTx(
wallet_id=wallet.wallet_id,
chain=wallet.chain,
from_address=wallet.address,
to_address=to_address,
amount=str(amount),
symbol=symbol,
wallet_label=wallet.label,
wallet_type=wallet.wallet_type,
)
# Use task_id as session key (matches how the agent loop tracks sessions)
session_key = kw.get("task_id") or task_id or "default"
submit_pending(session_key, pending_tx)
return json.dumps({
"status": "pending_approval",
"reason": result.reason,
"transaction": {
"from": wallet.address,
"to": to_address,
"amount": str(amount),
"symbol": symbol,
"chain": wallet.chain,
"wallet": wallet.label,
},
"message": (
f"Transaction requires owner approval: send {amount} {symbol} "
f"to {to_address} on {wallet.chain}. "
"The owner will be prompted to approve or deny."
),
})
# Policy passed — execute
try:
tx_result = mgr.send(
wallet.wallet_id,
to_address,
amount,
decided_by="policy_auto",
policy_result=json.dumps({
"verdict": result.verdict.value,
"checked": result.checked,
"failed": result.failed,
"approved_via": "policy_auto",
}),
)
if policy:
policy.record_transaction(tx_req)
if tx_result.status == "failed":
return json.dumps({
"status": "failed",
"error": tx_result.error,
})
return json.dumps({
"status": "submitted",
"tx_hash": tx_result.tx_hash,
"explorer_url": tx_result.explorer_url,
"chain": tx_result.chain,
"from": wallet.address,
"to": to_address,
"amount": str(amount),
"symbol": symbol,
})
except Exception as e:
return json.dumps({"error": f"Transaction failed: {e}"})
def wallet_history(args: dict, task_id: str = None, **kw) -> str:
"""Get transaction history."""
mgr, _ = _get_manager()
if mgr is None:
return json.dumps({"error": "Wallet not available"})
wallet_id = args.get("wallet_id")
limit = args.get("limit", 20)
records = mgr.get_tx_history(wallet_id=wallet_id, limit=limit)
if not records:
return json.dumps({"transactions": [], "message": "No transaction history"})
return json.dumps({
"transactions": [
{
"tx_id": r.tx_id,
"chain": r.chain,
"to": r.to_address,
"amount": r.amount,
"symbol": r.symbol,
"tx_hash": r.tx_hash,
"status": r.status,
"time": r.requested_at,
}
for r in records
],
})
def wallet_estimate_gas(args: dict, task_id: str = None, **kw) -> str:
"""Estimate transaction fee."""
mgr, _ = _get_manager()
if mgr is None:
return json.dumps({"error": "Wallet not available"})
to_address = args.get("to", "")
amount_str = args.get("amount", "0.01")
wallet_id = args.get("wallet_id")
chain = args.get("chain")
try:
amount = Decimal(amount_str)
wallet = mgr.resolve_wallet(wallet_id=wallet_id, chain=chain)
estimate = mgr.estimate_fee(wallet.wallet_id, to_address, amount)
return json.dumps({
"chain": estimate.chain,
"estimated_fee": str(estimate.estimated_fee),
"symbol": estimate.symbol,
})
except Exception as e:
return json.dumps({"error": str(e)})
def wallet_address(args: dict, task_id: str = None, **kw) -> str:
"""Get a wallet's deposit address — for sharing with others or receiving funds."""
mgr, _ = _get_manager()
if mgr is None:
return json.dumps({"error": "Wallet not available"})
wallet_id = args.get("wallet_id")
chain = args.get("chain")
try:
wallet = mgr.resolve_wallet(wallet_id=wallet_id, chain=chain)
provider = mgr.get_provider(wallet.chain)
return json.dumps({
"wallet": wallet.label,
"wallet_id": wallet.wallet_id,
"chain": wallet.chain,
"network": provider.config.display_name,
"address": wallet.address,
"type": wallet.wallet_type,
"is_testnet": provider.config.is_testnet,
"message": (
f"Address for {wallet.label} on {provider.config.display_name}: "
f"{wallet.address}"
),
})
except Exception as e:
return json.dumps({"error": str(e)})
def wallet_networks(args: dict = None, task_id: str = None, **kw) -> str:
"""List supported blockchain networks and which have active wallets."""
mgr, _ = _get_manager()
if mgr is None:
return json.dumps({"error": "Wallet not available"})
wallets = mgr.list_wallets()
wallet_chains = {w.chain for w in wallets}
networks = []
for chain_id in mgr.supported_chains:
try:
provider = mgr.get_provider(chain_id)
cfg = provider.config
networks.append({
"chain_id": chain_id,
"name": cfg.display_name,
"symbol": cfg.symbol,
"is_testnet": cfg.is_testnet,
"has_wallet": chain_id in wallet_chains,
"rpc_url": cfg.rpc_url,
})
except Exception:
networks.append({"chain_id": chain_id, "status": "error"})
# Group by mainnet/testnet for readability
mainnets = [n for n in networks if not n.get("is_testnet")]
testnets = [n for n in networks if n.get("is_testnet")]
return json.dumps({
"mainnets": mainnets,
"testnets": testnets,
"total_networks": len(networks),
"networks_with_wallets": len(wallet_chains),
})
# =========================================================================
# Tool registration
# =========================================================================
registry.register(
name="wallet_list",
toolset="wallet",
schema={
"name": "wallet_list",
"description": (
"List all crypto wallets with their addresses and balances. "
"Shows wallet ID, label, chain, address, and current balance."
),
"parameters": {"type": "object", "properties": {}, "required": []},
},
handler=lambda args, **kw: wallet_list(**kw),
check_fn=_check_wallet_available,
emoji="💰",
)
registry.register(
name="wallet_balance",
toolset="wallet",
schema={
"name": "wallet_balance",
"description": "Check the native token balance of a crypto wallet.",
"parameters": {
"type": "object",
"properties": {
"wallet_id": {
"type": "string",
"description": "Wallet ID (optional — uses default if only one wallet exists)",
},
"chain": {
"type": "string",
"description": "Chain name (e.g. 'ethereum', 'solana', 'base')",
},
},
"required": [],
},
},
handler=lambda args, **kw: wallet_balance(args, **kw),
check_fn=_check_wallet_available,
emoji="💰",
)
registry.register(
name="wallet_send",
toolset="wallet",
schema={
"name": "wallet_send",
"description": (
"Send native tokens (ETH, SOL, etc.) to an address. "
"Subject to spending limits and may require owner approval for large amounts. "
"Returns transaction hash on success or pending_approval status."
),
"parameters": {
"type": "object",
"properties": {
"to": {
"type": "string",
"description": "Recipient wallet address",
},
"amount": {
"type": "string",
"description": "Amount to send in native token units (e.g. '0.01' for 0.01 ETH)",
},
"wallet_id": {
"type": "string",
"description": "Wallet ID to send from (optional — uses default if only one)",
},
"chain": {
"type": "string",
"description": "Chain name (optional if wallet_id is provided)",
},
},
"required": ["to", "amount"],
},
},
handler=lambda args, **kw: wallet_send(args, **kw),
check_fn=_check_wallet_available,
emoji="📤",
)
registry.register(
name="wallet_history",
toolset="wallet",
schema={
"name": "wallet_history",
"description": "Get recent transaction history for a wallet.",
"parameters": {
"type": "object",
"properties": {
"wallet_id": {
"type": "string",
"description": "Wallet ID (optional — shows all if omitted)",
},
"limit": {
"type": "integer",
"description": "Maximum number of transactions to return (default: 20)",
},
},
"required": [],
},
},
handler=lambda args, **kw: wallet_history(args, **kw),
check_fn=_check_wallet_available,
emoji="📋",
)
registry.register(
name="wallet_estimate_gas",
toolset="wallet",
schema={
"name": "wallet_estimate_gas",
"description": "Estimate the transaction fee for sending tokens.",
"parameters": {
"type": "object",
"properties": {
"to": {"type": "string", "description": "Recipient address"},
"amount": {"type": "string", "description": "Amount to send"},
"wallet_id": {"type": "string", "description": "Wallet ID"},
"chain": {"type": "string", "description": "Chain name"},
},
"required": ["to"],
},
},
handler=lambda args, **kw: wallet_estimate_gas(args, **kw),
check_fn=_check_wallet_available,
emoji="",
)
registry.register(
name="wallet_address",
toolset="wallet",
schema={
"name": "wallet_address",
"description": (
"Get a wallet's deposit address for receiving funds. "
"Use this to share your wallet address with others or to check "
"which address to send funds to."
),
"parameters": {
"type": "object",
"properties": {
"wallet_id": {
"type": "string",
"description": "Wallet ID (optional — uses default if only one wallet exists)",
},
"chain": {
"type": "string",
"description": "Chain name (e.g. 'solana', 'ethereum', 'base')",
},
},
"required": [],
},
},
handler=lambda args, **kw: wallet_address(args, **kw),
check_fn=_check_wallet_available,
emoji="📬",
)
registry.register(
name="wallet_networks",
toolset="wallet",
schema={
"name": "wallet_networks",
"description": (
"List all supported blockchain networks and which ones have active wallets. "
"Shows mainnets and testnets separately with their native token symbols."
),
"parameters": {"type": "object", "properties": {}, "required": []},
},
handler=lambda args, **kw: wallet_networks(**kw),
check_fn=_check_wallet_available,
emoji="🌐",
)

View File

@@ -208,6 +208,13 @@ TOOLSETS = {
"includes": []
},
"wallet": {
"description": "Cryptocurrency wallet — check balances, send tokens, view history, manage networks",
"tools": ["wallet_list", "wallet_balance", "wallet_send", "wallet_history",
"wallet_estimate_gas", "wallet_address", "wallet_networks"],
"includes": []
},
# Scenario-specific toolsets

722
uv.lock generated
View File

@@ -253,6 +253,49 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "argon2-cffi"
version = "23.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "argon2-cffi-bindings" },
]
sdist = { url = "https://files.pythonhosted.org/packages/31/fa/57ec2c6d16ecd2ba0cf15f3c7d1c3c2e7b5fcb83555ff56d7ab10888ec8f/argon2_cffi-23.1.0.tar.gz", hash = "sha256:879c3e79a2729ce768ebb7d36d4609e3a78a4ca2ec3a9f12286ca057e3d0db08", size = 42798, upload-time = "2023-08-15T14:13:12.711Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/6a/e8a041599e78b6b3752da48000b14c8d1e8a04ded09c88c714ba047f34f5/argon2_cffi-23.1.0-py3-none-any.whl", hash = "sha256:c670642b78ba29641818ab2e68bd4e6a78ba53b7eff7b4c3815ae16abf91c7ea", size = 15124, upload-time = "2023-08-15T14:13:10.752Z" },
]
[[package]]
name = "argon2-cffi-bindings"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" },
{ url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" },
{ url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" },
{ url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" },
{ url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" },
{ url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" },
{ url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" },
{ url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" },
{ url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" },
{ url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" },
{ url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" },
{ url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" },
{ url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" },
{ url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" },
{ url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" },
{ url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" },
{ url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" },
{ url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" },
{ url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" },
{ url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" },
]
[[package]]
name = "atomicwrites"
version = "1.4.1"
@@ -376,6 +419,88 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/41/0a/0896b829a39b5669a2d811e1a79598de661693685cd62b31f11d0c18e65b/av-17.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dba98603fc4665b4f750de86fbaf6c0cfaece970671a9b529e0e3d1711e8367e", size = 22071058, upload-time = "2026-03-14T14:38:43.663Z" },
]
[[package]]
name = "backports-tarfile"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" },
]
[[package]]
name = "bitarray"
version = "3.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/95/06/92fdc84448d324ab8434b78e65caf4fb4c6c90b4f8ad9bdd4c8021bfaf1e/bitarray-3.8.0.tar.gz", hash = "sha256:3eae38daffd77c9621ae80c16932eea3fb3a4af141fb7cc724d4ad93eff9210d", size = 151991, upload-time = "2025-11-02T21:41:15.117Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/7d/63558f1d0eb09217a3d30c1c847890879973e224a728fcff9391fab999b8/bitarray-3.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:25b9cff6c9856bc396232e2f609ea0c5ec1a8a24c500cee4cca96ba8a3cd50b6", size = 148502, upload-time = "2025-11-02T21:39:09.993Z" },
{ url = "https://files.pythonhosted.org/packages/5e/7b/f957ad211cb0172965b5f0881b67b99e2b6d41512af0a1001f44a44ddf4a/bitarray-3.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d9984017314da772f5f7460add7a0301a4ffc06c72c2998bb16c300a6253607", size = 145484, upload-time = "2025-11-02T21:39:10.904Z" },
{ url = "https://files.pythonhosted.org/packages/9f/dc/897973734f14f91467a3a795a4624752238053ecffaec7c8bbda1e363fda/bitarray-3.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbbbfbb7d039b20d289ce56b1beb46138d65769d04af50c199c6ac4cb6054d52", size = 330909, upload-time = "2025-11-02T21:39:12.276Z" },
{ url = "https://files.pythonhosted.org/packages/67/be/24b4b792426d92de289e73e09682915d567c2e69d47e8857586cbdc865d0/bitarray-3.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1f723e260c35e1c7c57a09d3a6ebe681bd56c83e1208ae3ce1869b7c0d10d4f", size = 358469, upload-time = "2025-11-02T21:39:13.766Z" },
{ url = "https://files.pythonhosted.org/packages/3e/0e/2eda69a7a59a6998df8fb57cc9d1e0e62888c599fb5237b0a8b479a01afb/bitarray-3.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cbd1660fb48827381ce3a621a4fdc237959e1cd4e98b098952a8f624a0726425", size = 369131, upload-time = "2025-11-02T21:39:15.041Z" },
{ url = "https://files.pythonhosted.org/packages/f7/7b/8a372d6635a6b2622477b2f96a569b2cd0318a62bc95a4a2144c7942c987/bitarray-3.8.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df6d7bf3e15b7e6e202a16ff4948a51759354016026deb04ab9b5acbbe35e096", size = 337089, upload-time = "2025-11-02T21:39:16.124Z" },
{ url = "https://files.pythonhosted.org/packages/93/f0/8eca934dbe5dee47a0e5ef44eeb72e85acacc8097c27cd164337bc4ec5d3/bitarray-3.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d5c931ec1c03111718cabf85f6012bb2815fa0ce578175567fa8d6f2cc15d3b4", size = 328504, upload-time = "2025-11-02T21:39:17.321Z" },
{ url = "https://files.pythonhosted.org/packages/88/dd/928b8e23a9950f8a8bfc42bc1e7de41f4e27f57de01a716308be5f683c2b/bitarray-3.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:41b53711f89008ba2de62e4c2d2260a8b357072fd4f18e1351b28955db2719dc", size = 356461, upload-time = "2025-11-02T21:39:18.396Z" },
{ url = "https://files.pythonhosted.org/packages/a9/93/4fb58417aff47fa2fe1874a39c9346b589a1d78c93a9cb24cccede5dc737/bitarray-3.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4f298daaaea58d45e245a132d6d2bdfb6f856da50dc03d75ebb761439fb626cf", size = 353008, upload-time = "2025-11-02T21:39:19.828Z" },
{ url = "https://files.pythonhosted.org/packages/da/54/aa04e4a7b45aa5913f08ee377d43319b0979925e3c0407882eb29df3be66/bitarray-3.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:30989a2451b693c3f9359d91098a744992b5431a0be4858f1fdf0ec76b457125", size = 334048, upload-time = "2025-11-02T21:39:20.924Z" },
{ url = "https://files.pythonhosted.org/packages/da/52/e851f41076df014c05d6ac1ce34fbf7db5fa31241da3e2f09bb2be9e283d/bitarray-3.8.0-cp311-cp311-win32.whl", hash = "sha256:e5aed4754895942ae15ffa48c52d181e1c1463236fda68d2dba29c03aa61786b", size = 142907, upload-time = "2025-11-02T21:39:22.312Z" },
{ url = "https://files.pythonhosted.org/packages/28/01/db0006148b1dd13b4ac2686df8fa57d12f5887df313a506e939af0cb0997/bitarray-3.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:22c540ed20167d3dbb1e2d868ca935180247d620c40eace90efa774504a40e3b", size = 149670, upload-time = "2025-11-02T21:39:23.341Z" },
{ url = "https://files.pythonhosted.org/packages/7b/ea/b7d55ee269b1426f758a535c9ec2a07c056f20f403fa981685c3c8b4798c/bitarray-3.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:84b52b2cf77bb7f703d16c4007b021078dbbe6cf8ffb57abe81a7bacfc175ef2", size = 146709, upload-time = "2025-11-02T21:39:24.343Z" },
{ url = "https://files.pythonhosted.org/packages/82/a0/0c41d893eda756315491adfdbf9bc928aee3d377a7f97a8834d453aa5de1/bitarray-3.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2fcbe9b3a5996b417e030aa33a562e7e20dfc86271e53d7e841fc5df16268b8", size = 148575, upload-time = "2025-11-02T21:39:25.718Z" },
{ url = "https://files.pythonhosted.org/packages/0e/30/12ab2f4a4429bd844b419c37877caba93d676d18be71354fbbeb21d9f4cc/bitarray-3.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cd761d158f67e288fd0ebe00c3b158095ce80a4bc7c32b60c7121224003ba70d", size = 145454, upload-time = "2025-11-02T21:39:26.695Z" },
{ url = "https://files.pythonhosted.org/packages/26/58/314b3e3f219533464e120f0c51ac5123e7b1c1b91f725a4073fb70c5a858/bitarray-3.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c394a3f055b49f92626f83c1a0b6d6cd2c628f1ccd72481c3e3c6aa4695f3b20", size = 332949, upload-time = "2025-11-02T21:39:27.801Z" },
{ url = "https://files.pythonhosted.org/packages/ea/ce/ca8c706bd8341c7a22dd92d2a528af71f7e5f4726085d93f81fd768cb03b/bitarray-3.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:969fd67de8c42affdb47b38b80f1eaa79ac0ef17d65407cdd931db1675315af1", size = 360599, upload-time = "2025-11-02T21:39:28.964Z" },
{ url = "https://files.pythonhosted.org/packages/ef/dc/aa181df85f933052d962804906b282acb433cb9318b08ec2aceb4ee34faf/bitarray-3.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:99d25aff3745c54e61ab340b98400c52ebec04290a62078155e0d7eb30380220", size = 371972, upload-time = "2025-11-02T21:39:30.228Z" },
{ url = "https://files.pythonhosted.org/packages/ff/d9/b805bfa158c7bcf4df0ac19b1be581b47e1ddb792c11023aed80a7058e78/bitarray-3.8.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e645b4c365d6f1f9e0799380ad6395268f3c3b898244a650aaeb8d9d27b74c35", size = 340303, upload-time = "2025-11-02T21:39:31.342Z" },
{ url = "https://files.pythonhosted.org/packages/1f/42/5308cc97ea929e30727292617a3a88293470166851e13c9e3f16f395da55/bitarray-3.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2fa23fdb3beab313950bbb49674e8a161e61449332d3997089fe3944953f1b77", size = 330494, upload-time = "2025-11-02T21:39:32.769Z" },
{ url = "https://files.pythonhosted.org/packages/4c/89/64f1596cb80433323efdbc8dcd0d6e57c40dfbe6ea3341623f34ec397edd/bitarray-3.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:165052a0e61c880f7093808a0c524ce1b3555bfa114c0dfb5c809cd07918a60d", size = 358123, upload-time = "2025-11-02T21:39:34.331Z" },
{ url = "https://files.pythonhosted.org/packages/27/fd/f3d49c5443b57087f888b5e118c8dd78bb7c8e8cfeeed250f8e92128a05f/bitarray-3.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:337c8cd46a4c6568d367ed676cbf2d7de16f890bb31dbb54c44c1d6bb6d4a1de", size = 356046, upload-time = "2025-11-02T21:39:35.449Z" },
{ url = "https://files.pythonhosted.org/packages/aa/db/1fd0b402bd2b47142e958b6930dbb9445235d03fa703c9a24caa6e576ae2/bitarray-3.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21ca6a47bf20db9e7ad74ca04b3d479e4d76109b68333eb23535553d2705339e", size = 336872, upload-time = "2025-11-02T21:39:36.891Z" },
{ url = "https://files.pythonhosted.org/packages/58/73/680b47718f1313b4538af479c4732eaca0aeda34d93fc5b869f87932d57d/bitarray-3.8.0-cp312-cp312-win32.whl", hash = "sha256:178c5a4c7fdfb5cd79e372ae7f675390e670f3732e5bc68d327e01a5b3ff8d55", size = 143025, upload-time = "2025-11-02T21:39:38.303Z" },
{ url = "https://files.pythonhosted.org/packages/f8/11/7792587c19c79a8283e8838f44709fa4338a8f7d2a3091dfd81c07ae89c7/bitarray-3.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:75a3b6e9c695a6570ea488db75b84bb592ff70a944957efa1c655867c575018b", size = 149969, upload-time = "2025-11-02T21:39:39.715Z" },
{ url = "https://files.pythonhosted.org/packages/9a/00/9df64b5d8a84e8e9ec392f6f9ce93f50626a5b301cb6c6b3fe3406454d66/bitarray-3.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:5591daf81313096909d973fb2612fccd87528fdfdd39f6478bdce54543178954", size = 146907, upload-time = "2025-11-02T21:39:40.815Z" },
{ url = "https://files.pythonhosted.org/packages/3e/35/480364d4baf1e34c79076750914664373f561c58abb5c31c35b3fae613ff/bitarray-3.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:18214bac86341f1cc413772e66447d6cca10981e2880b70ecaf4e826c04f95e9", size = 148582, upload-time = "2025-11-02T21:39:42.268Z" },
{ url = "https://files.pythonhosted.org/packages/5e/a8/718b95524c803937f4edbaaf6480f39c80f6ed189d61357b345e8361ffb6/bitarray-3.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:01c5f0dc080b0ebb432f7a68ee1e88a76bd34f6d89c9568fcec65fb16ed71f0e", size = 145433, upload-time = "2025-11-02T21:39:43.552Z" },
{ url = "https://files.pythonhosted.org/packages/03/66/4a10f30dc9e2e01e3b4ecd44a511219f98e63c86b0e0f704c90fac24059b/bitarray-3.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86685fa04067f7175f9718489ae755f6acde03593a1a9ca89305554af40e14fd", size = 332986, upload-time = "2025-11-02T21:39:44.656Z" },
{ url = "https://files.pythonhosted.org/packages/53/25/4c08774d847f80a1166e4c704b4e0f1c417c0afe6306eae0bc5e70d35faa/bitarray-3.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56896ceeffe25946c4010320629e2d858ca763cd8ded273c81672a5edbcb1e0a", size = 360634, upload-time = "2025-11-02T21:39:45.798Z" },
{ url = "https://files.pythonhosted.org/packages/a5/8f/bf8ad26169ebd0b2746d5c7564db734453ca467f8aab87e9d43b0a794383/bitarray-3.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9858dcbc23ba7eaadcd319786b982278a1a2b2020720b19db43e309579ff76fb", size = 371992, upload-time = "2025-11-02T21:39:46.968Z" },
{ url = "https://files.pythonhosted.org/packages/a9/16/ce166754e7c9d10650e02914552fa637cf3b2591f7ed16632bbf6b783312/bitarray-3.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa7dec53c25f1949513457ef8b0ea1fb40e76c672cc4d2daa8ad3c8d6b73491a", size = 340315, upload-time = "2025-11-02T21:39:48.182Z" },
{ url = "https://files.pythonhosted.org/packages/de/2a/fbba3a106ddd260e84b9a624f730257c32ba51a8a029565248dfedfdf6f2/bitarray-3.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15a2eff91f54d2b1f573cca8ca6fb58763ce8fea80e7899ab028f3987ef71cd5", size = 330473, upload-time = "2025-11-02T21:39:49.705Z" },
{ url = "https://files.pythonhosted.org/packages/68/97/56cf3c70196e7307ad32318a9d6ed969dbdc6a4534bbe429112fa7dfe42e/bitarray-3.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b1572ee0eb1967e71787af636bb7d1eb9c6735d5337762c450650e7f51844594", size = 358129, upload-time = "2025-11-02T21:39:51.189Z" },
{ url = "https://files.pythonhosted.org/packages/fd/be/afd391a5c0896d3339613321b2f94af853f29afc8bd3fbc327431244c642/bitarray-3.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5bfac7f236ba1a4d402644bdce47fb9db02a7cf3214a1f637d3a88390f9e5428", size = 356005, upload-time = "2025-11-02T21:39:52.355Z" },
{ url = "https://files.pythonhosted.org/packages/ae/08/a8e1a371babba29bad3378bb3a2cdca2b012170711e7fe1f22031a6b7b95/bitarray-3.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f0a55cf02d2cdd739b40ce10c09bbdd520e141217696add7a48b56e67bdfdfe6", size = 336862, upload-time = "2025-11-02T21:39:54.345Z" },
{ url = "https://files.pythonhosted.org/packages/ee/8a/6dc1d0fdc06991c8dc3b1fcfe1ae49fbaced42064cd1b5f24278e73fe05f/bitarray-3.8.0-cp313-cp313-win32.whl", hash = "sha256:a2ba92f59e30ce915e9e79af37649432e3a212ddddf416d4d686b1b4825bcdb2", size = 143018, upload-time = "2025-11-02T21:39:56.361Z" },
{ url = "https://files.pythonhosted.org/packages/2e/72/76e13f5cd23b8b9071747909663ce3b02da24a5e7e22c35146338625db35/bitarray-3.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:1c8f2a5d8006db5a555e06f9437e76bf52537d3dfd130cb8ae2b30866aca32c9", size = 149977, upload-time = "2025-11-02T21:39:57.718Z" },
{ url = "https://files.pythonhosted.org/packages/01/37/60f336c32336cc3ec03b0c61076f16ea2f05d5371c8a56e802161d218b77/bitarray-3.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:50ddbe3a7b4b6ab96812f5a4d570f401a2cdb95642fd04c062f98939610bbeee", size = 146930, upload-time = "2025-11-02T21:39:59.308Z" },
{ url = "https://files.pythonhosted.org/packages/1b/b0/411327a6c7f6b2bead64bb06fe60b92e0344957ec1ab0645d5ccc25fdafe/bitarray-3.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8cbd4bfc933b33b85c43ef4c1f4d5e3e9d91975ea6368acf5fbac02bac06ea89", size = 148563, upload-time = "2025-11-02T21:40:01.006Z" },
{ url = "https://files.pythonhosted.org/packages/2a/bc/ff80d97c627d774f879da0ea93223adb1267feab7e07d5c17580ffe6d632/bitarray-3.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9d35d8f8a1c9ed4e2b08187b513f8a3c71958600129db3aa26d85ea3abfd1310", size = 145422, upload-time = "2025-11-02T21:40:02.535Z" },
{ url = "https://files.pythonhosted.org/packages/66/e7/b4cb6c5689aacd0a32f3aa8a507155eaa33528c63de2f182b60843fbf700/bitarray-3.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99f55e14e7c56f4fafe1343480c32b110ef03836c21ff7c48bae7add6818f77c", size = 332852, upload-time = "2025-11-02T21:40:03.645Z" },
{ url = "https://files.pythonhosted.org/packages/e7/91/fbd1b047e3e2f4b65590f289c8151df1d203d75b005f5aae4e072fe77d76/bitarray-3.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dfbe2aa45b273f49e715c5345d94874cb65a28482bf231af408891c260601b8d", size = 360801, upload-time = "2025-11-02T21:40:04.827Z" },
{ url = "https://files.pythonhosted.org/packages/ef/4a/63064c593627bac8754fdafcb5343999c93ab2aeb27bcd9d270a010abea5/bitarray-3.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:64af877116edf051375b45f0bda648143176a017b13803ec7b3a3111dc05f4c5", size = 371408, upload-time = "2025-11-02T21:40:05.985Z" },
{ url = "https://files.pythonhosted.org/packages/46/97/ddc07723767bdafd170f2ff6e173c940fa874192783ee464aa3c1dedf07d/bitarray-3.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cdfbb27f2c46bb5bbdcee147530cbc5ca8ab858d7693924e88e30ada21b2c5e2", size = 340033, upload-time = "2025-11-02T21:40:07.189Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1e/e1ea9f1146fd4af032817069ff118918d73e5de519854ce3860e2ed560ff/bitarray-3.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4d73d4948dcc5591d880db8933004e01f1dd2296df9de815354d53469beb26fe", size = 330774, upload-time = "2025-11-02T21:40:08.496Z" },
{ url = "https://files.pythonhosted.org/packages/cf/9f/8242296c124a48d1eab471fd0838aeb7ea9c6fd720302d99ab7855d3e6d3/bitarray-3.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:28a85b056c0eb7f5d864c0ceef07034117e8ebfca756f50648c71950a568ba11", size = 358337, upload-time = "2025-11-02T21:40:10.035Z" },
{ url = "https://files.pythonhosted.org/packages/b5/6b/9095d75264c67d479f298c80802422464ce18c3cdd893252eeccf4997611/bitarray-3.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:79ec4498a545733ecace48d780d22407411b07403a2e08b9a4d7596c0b97ebd7", size = 355639, upload-time = "2025-11-02T21:40:11.485Z" },
{ url = "https://files.pythonhosted.org/packages/a0/af/c93c0ae5ef824136e90ac7ddf6cceccb1232f34240b2f55a922f874da9b4/bitarray-3.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:33af25c4ff7723363cb8404dfc2eefeab4110b654f6c98d26aba8a08c745d860", size = 336999, upload-time = "2025-11-02T21:40:12.709Z" },
{ url = "https://files.pythonhosted.org/packages/81/0f/72c951f5997b2876355d5e671f78dd2362493254876675cf22dbd24389ae/bitarray-3.8.0-cp314-cp314-win32.whl", hash = "sha256:2c3bb96b6026643ce24677650889b09073f60b9860a71765f843c99f9ab38b25", size = 142169, upload-time = "2025-11-02T21:40:14.031Z" },
{ url = "https://files.pythonhosted.org/packages/8a/55/ef1b4de8107bf13823da8756c20e1fbc9452228b4e837f46f6d9ddba3eb3/bitarray-3.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:847c7f61964225fc489fe1d49eda7e0e0d253e98862c012cecf845f9ad45cdf4", size = 148737, upload-time = "2025-11-02T21:40:15.436Z" },
{ url = "https://files.pythonhosted.org/packages/5f/26/bc0784136775024ac56cc67c0d6f9aa77a7770de7f82c3a7c9be11c217cd/bitarray-3.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:a2cb35a6efaa0e3623d8272471371a12c7e07b51a33e5efce9b58f655d864b4e", size = 146083, upload-time = "2025-11-02T21:40:17.135Z" },
{ url = "https://files.pythonhosted.org/packages/6e/64/57984e64264bf43d93a1809e645972771566a2d0345f4896b041ce20b000/bitarray-3.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:15e8d0597cc6e8496de6f4dea2a6880c57e1251502a7072f5631108a1aa28521", size = 149455, upload-time = "2025-11-02T21:40:18.558Z" },
{ url = "https://files.pythonhosted.org/packages/81/c0/0d5f2eaef1867f462f764bdb07d1e116c33a1bf052ea21889aefe4282f5b/bitarray-3.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8ffe660e963ae711cb9e2b8d8461c9b1ad6167823837fc17d59d5e539fb898fa", size = 146491, upload-time = "2025-11-02T21:40:19.665Z" },
{ url = "https://files.pythonhosted.org/packages/65/c6/bc1261f7a8862c0c59220a484464739e52235fd1e2afcb24d7f7d3fb5702/bitarray-3.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4779f356083c62e29b4198d290b7b17a39a69702d150678b7efff0fdddf494a8", size = 339721, upload-time = "2025-11-02T21:40:21.277Z" },
{ url = "https://files.pythonhosted.org/packages/81/d8/289ca55dd2939ea17b1108dc53bffc0fdc5160ba44f77502dfaae35d08c6/bitarray-3.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:025d133bf4ca8cf75f904eeb8ea946228d7c043231866143f31946a6f4dd0bf3", size = 367823, upload-time = "2025-11-02T21:40:22.463Z" },
{ url = "https://files.pythonhosted.org/packages/91/a2/61e7461ca9ac0fcb70f327a2e84b006996d2a840898e69037a39c87c6d06/bitarray-3.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:451f9958850ea98440d542278368c8d1e1ea821e2494b204570ba34a340759df", size = 377341, upload-time = "2025-11-02T21:40:23.789Z" },
{ url = "https://files.pythonhosted.org/packages/6c/87/4a0c9c8bdb13916d443e04d8f8542eef9190f31425da3c17c3478c40173f/bitarray-3.8.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6d79f659965290af60d6acc8e2716341865fe74609a7ede2a33c2f86ad893b8f", size = 344985, upload-time = "2025-11-02T21:40:25.261Z" },
{ url = "https://files.pythonhosted.org/packages/17/4c/ff9259b916efe53695b631772e5213699c738efc2471b5ffe273f4000994/bitarray-3.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fbf05678c2ae0064fb1b8de7e9e8f0fc30621b73c8477786dd0fb3868044a8c8", size = 336796, upload-time = "2025-11-02T21:40:26.942Z" },
{ url = "https://files.pythonhosted.org/packages/0f/4b/51b2468bbddbade5e2f3b8d5db08282c5b309e8687b0f02f75a8b5ff559c/bitarray-3.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:c396358023b876cff547ce87f4e8ff8a2280598873a137e8cc69e115262260b8", size = 365085, upload-time = "2025-11-02T21:40:28.224Z" },
{ url = "https://files.pythonhosted.org/packages/bf/79/53473bfc2e052c6dbb628cdc1b156be621c77aaeb715918358b01574be55/bitarray-3.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed3493a369fe849cce98542d7405c88030b355e4d2e113887cb7ecc86c205773", size = 361012, upload-time = "2025-11-02T21:40:29.635Z" },
{ url = "https://files.pythonhosted.org/packages/c4/b1/242bf2e44bfc69e73fa2b954b425d761a8e632f78ea31008f1c3cfad0854/bitarray-3.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c764fb167411d5afaef88138542a4bfa28bd5e5ded5e8e42df87cef965efd6e9", size = 340644, upload-time = "2025-11-02T21:40:31.089Z" },
{ url = "https://files.pythonhosted.org/packages/cf/01/12e5ecf30a5de28a32485f226cad4b8a546845f65f755ce0365057ab1e92/bitarray-3.8.0-cp314-cp314t-win32.whl", hash = "sha256:e12769d3adcc419e65860de946df8d2ed274932177ac1cdb05186e498aaa9149", size = 143630, upload-time = "2025-11-02T21:40:32.351Z" },
{ url = "https://files.pythonhosted.org/packages/b6/92/6b6ade587b08024a8a890b07724775d29da9cf7497be5c3cbe226185e463/bitarray-3.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0ca70ccf789446a6dfde40b482ec21d28067172cd1f8efd50d5548159fccad9e", size = 150250, upload-time = "2025-11-02T21:40:33.596Z" },
{ url = "https://files.pythonhosted.org/packages/ed/40/be3858ffed004e47e48a2cefecdbf9b950d41098b780f9dc3aa609a88351/bitarray-3.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2a3d1b05ffdd3e95687942ae7b13c63689f85d3f15c39b33329e3cb9ce6c015f", size = 147015, upload-time = "2025-11-02T21:40:35.064Z" },
]
[[package]]
name = "blinker"
version = "1.9.0"
@@ -583,6 +708,54 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
]
[[package]]
name = "ckzg"
version = "2.1.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/12/44/fdb579a0d035a1e510511e3c3b9ca98ba2ea240a24f112b1882478bfc2ff/ckzg-2.1.7.tar.gz", hash = "sha256:a0c61c5fd573af0267bcb435ef0f499911289ceb05e863480779ea284a3bb928", size = 1127878, upload-time = "2026-03-11T14:11:13.745Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/f1/aa4fac509f986ada4718517a2d167b7ce7efae9624c0f7f71c113c4debbd/ckzg-2.1.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c9172f571ac7ec6d90207ad1903d921c38e48482bc028f723d6908720af1add6", size = 96366, upload-time = "2026-03-11T14:10:15.098Z" },
{ url = "https://files.pythonhosted.org/packages/96/c6/30cdc5b43928221c67b3853c10c54a21c525802a10af23cbfc188f6ad2d8/ckzg-2.1.7-cp311-cp311-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:c5494f39edeffedfa085fe85614a1c05ddd895ceb9d6c1800dc5355f9132a8f9", size = 180266, upload-time = "2026-03-11T14:10:16.142Z" },
{ url = "https://files.pythonhosted.org/packages/e5/97/86f6030cb6daff6d87b8d0c2a666f09360b5b179fdc3507bcc60ef26318e/ckzg-2.1.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb67250207b93d2df7f694bb74bd6b4a15fb2bb67d6a78977ae8ff431678c7e7", size = 165983, upload-time = "2026-03-11T14:10:17.407Z" },
{ url = "https://files.pythonhosted.org/packages/19/85/547814b4c6a09ebd27af9f682b7066c5c4569acd4fea74841cfe8964e5ab/ckzg-2.1.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7828cb549e2e8368e966c9dab87f3a51456647f1a3e79bdac9194e17bbc4d54", size = 175698, upload-time = "2026-03-11T14:10:18.35Z" },
{ url = "https://files.pythonhosted.org/packages/30/a0/890e33ac991222aaa919a092e0de397e59df75baa92ec17f89370062863d/ckzg-2.1.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:23eacac20c6d3be2c87e592c11d02e4a1912e799d77e2559502455e85113e7b4", size = 173516, upload-time = "2026-03-11T14:10:19.615Z" },
{ url = "https://files.pythonhosted.org/packages/a8/71/ec6f713fb1056a647d4a7fad4ced15faedcd5d7b2a6f34ece81a9d1dbdd8/ckzg-2.1.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4dd2afdc41f063e57eb569034b81088ba724240d3247ca78ea6591a1e04df50d", size = 188621, upload-time = "2026-03-11T14:10:20.865Z" },
{ url = "https://files.pythonhosted.org/packages/d8/86/04572a67546e66b809946a7234cac0e3aa67bfa4a256d8440eefb1deaf87/ckzg-2.1.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b3af91c230982d59afe6f42c9c2a4c74412424a566bd09a42ffdfb451872335a", size = 183257, upload-time = "2026-03-11T14:10:21.808Z" },
{ url = "https://files.pythonhosted.org/packages/da/c1/3060e997955e61699e4f6a431ff3cd3f780cd8ccfab0a2e0462848680185/ckzg-2.1.7-cp311-cp311-win_amd64.whl", hash = "sha256:f959a3bbc6d7aa7a653946e67dadaa78c0c79828aaa93b125a26f171a602b8fa", size = 99823, upload-time = "2026-03-11T14:10:22.674Z" },
{ url = "https://files.pythonhosted.org/packages/09/40/8c2d610066a2efd4048553ff12aa832c916822ec9c888ca924565e520a7b/ckzg-2.1.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:126050ffb23b504c34c4c2073c54bd8b42f4a3034798a631c9e85911e26caf47", size = 96386, upload-time = "2026-03-11T14:10:23.532Z" },
{ url = "https://files.pythonhosted.org/packages/29/b6/092bd10eb35e9fe3d316410791d9055039c5dd29caf03c72cc86fce45624/ckzg-2.1.7-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:936b4bffc1a6fa2bf261eb5e673f4fcc59feaf70c6c07aac1b02e3e1f942fdb6", size = 180447, upload-time = "2026-03-11T14:10:24.368Z" },
{ url = "https://files.pythonhosted.org/packages/53/7e/f1c15ec078bee7660a2cafa103c4efdf9686256a348565ef6a1cb70ff1c4/ckzg-2.1.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:902c03b689d13684cd8b61c8e1b7a65528fdd5e1ab9d76338ddb2e902b5fd1ea", size = 166242, upload-time = "2026-03-11T14:10:25.671Z" },
{ url = "https://files.pythonhosted.org/packages/bf/de/c22535e16163a836f76d7c3606a6e579a7a02862b4797b832cd6de5f6a1d/ckzg-2.1.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e635e5e1f6ff8ffc05d2961ccfc4b3e8c95e50c87d9765b2dfe09e32474c402", size = 176015, upload-time = "2026-03-11T14:10:26.976Z" },
{ url = "https://files.pythonhosted.org/packages/af/4f/56c303eab20d92e5d140f96881c8c7e2eaa05976d6cb887ab574d780d09d/ckzg-2.1.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cbedb5e4732d37c87fe45a2b25891d00f434d4e0f4dd612daa034fe2011e5939", size = 173682, upload-time = "2026-03-11T14:10:27.857Z" },
{ url = "https://files.pythonhosted.org/packages/85/0a/0feb878383e9c83d6dcd760b8de2f3095546cc09b1717ae65cbb47f90b20/ckzg-2.1.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:665d0094466b576e390b4a5e1caf199f1165841e99bf7b3cc65117f12ba4ea74", size = 188873, upload-time = "2026-03-11T14:10:28.85Z" },
{ url = "https://files.pythonhosted.org/packages/48/29/c2eb07882465c32478e575334311ad6cea21c5d76d54da6c900dd6cb8e62/ckzg-2.1.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f5d4d1fb20eda15b901fc393a4bfd39b1be661008218f9f0db47d4e143d25d62", size = 183566, upload-time = "2026-03-11T14:10:29.777Z" },
{ url = "https://files.pythonhosted.org/packages/c8/48/4d1f5c470cc6eb73aaba30125e6fb62759ce69bbdb2a74c160f69f601236/ckzg-2.1.7-cp312-cp312-win_amd64.whl", hash = "sha256:b580f65e61f3d89a99bfeeac0e256cf68c63d29df1c1e5e788785085083a303b", size = 99811, upload-time = "2026-03-11T14:10:30.719Z" },
{ url = "https://files.pythonhosted.org/packages/87/32/495600f43a277bcb413d08f23f594dc548ac0d7927ad1ce7db28e58afadd/ckzg-2.1.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e23e10b227209bfae11f6f1f88ff2a8b0a2232248f985321e5e844c9dd7a4c5f", size = 96394, upload-time = "2026-03-11T14:10:31.535Z" },
{ url = "https://files.pythonhosted.org/packages/e4/fe/c3708cfdbc228298c0f5fa4d08ceee7cc01cb7f7d105bfc9ebc68c39060d/ckzg-2.1.7-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:382c015860e7159b1ec5a85642127d4b55f6b36eef5f73d664fc409d26a3b367", size = 180484, upload-time = "2026-03-11T14:10:32.418Z" },
{ url = "https://files.pythonhosted.org/packages/28/55/d689769ea0f9b2c2c16d8390f4c3cf7cd7dea0df68542b2a435c341df0b0/ckzg-2.1.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6666801e925d2f1d7c045fe943c1265c39b90444f88288735cc1245c4fa8018a", size = 166301, upload-time = "2026-03-11T14:10:33.363Z" },
{ url = "https://files.pythonhosted.org/packages/16/ff/e172b4ae4bef05bf88bb8f27d2b9858b56c9984ad1708eeef82ac787fe7c/ckzg-2.1.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e823de2fd4103abc4b51512d27aa3e14107e84718e11a596eefcddc6f313b25", size = 176052, upload-time = "2026-03-11T14:10:34.621Z" },
{ url = "https://files.pythonhosted.org/packages/61/0a/dcf28e0126e5a6f8f8b7505b4b5b637ca25e1095272fbee73f8967e3a545/ckzg-2.1.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a65c7be0bb72a159c5a4b98cc3c759b868274697de11d8248f5dde32f2400776", size = 173691, upload-time = "2026-03-11T14:10:35.577Z" },
{ url = "https://files.pythonhosted.org/packages/2a/d2/fe404ad0bd79aaeb1e75fb4981d21e37364e59517813f7f085914026a7f6/ckzg-2.1.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:62523b275f74f2729fc788d02b26e447dabfd7706ffe8882ee96d776db54b920", size = 188909, upload-time = "2026-03-11T14:10:36.798Z" },
{ url = "https://files.pythonhosted.org/packages/55/d7/ef2d30c88270ab1a0daffa8a0f8453b72035569d3295ad3dcaba9b5250a6/ckzg-2.1.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5d998cd6d0f8e37e969c96315ac8c1e87fcf581cf27ab970bd33e62dc1c43357", size = 183597, upload-time = "2026-03-11T14:10:37.812Z" },
{ url = "https://files.pythonhosted.org/packages/93/77/1e04840c866284bec3489154caec22855829b0c2d028bd1de771655175e3/ckzg-2.1.7-cp313-cp313-win_amd64.whl", hash = "sha256:d48b75fca9e928b2ea288fc079b0522fb91af5742b5eb4f2fdea4fc33a1b7b4e", size = 99808, upload-time = "2026-03-11T14:10:38.701Z" },
{ url = "https://files.pythonhosted.org/packages/24/ab/11eb63c520cae074195b05cd644bf45be061b910b5c97abdaae02876a50e/ckzg-2.1.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c19b98f29f4459587e1ec4cce3e2e10963a6974293cf3143d13ce43c30542806", size = 96400, upload-time = "2026-03-11T14:10:39.59Z" },
{ url = "https://files.pythonhosted.org/packages/31/7d/3678cbb22f31a50dd354b9d3efcb9366dd5b97cdddbf270213a66b03ad41/ckzg-2.1.7-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:d31583a24cf8166d81c36f1e424de1f343c1d604dbc8c68d938a908236ae11a3", size = 180492, upload-time = "2026-03-11T14:10:40.766Z" },
{ url = "https://files.pythonhosted.org/packages/48/a5/355f898c75e19ac6426798c28a9767bdc734bebb40c4cd15572f644745ba/ckzg-2.1.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:baf6ac696e6a40b33ddb57aa0729d5e39230bd13fa4f1e40fe9236e8920d83fe", size = 166322, upload-time = "2026-03-11T14:10:41.752Z" },
{ url = "https://files.pythonhosted.org/packages/ff/f5/7ffc482dc628c43d9c7a1b19392e1a920ccfd1da8d2e07d7dcc79c3e3bd2/ckzg-2.1.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8bbdf89f9327e442415a810beca692729c35664e154a6830296124a5c6f05470", size = 176061, upload-time = "2026-03-11T14:10:42.649Z" },
{ url = "https://files.pythonhosted.org/packages/26/56/f79ee2a177b4522fe47709e9f7e48407cd54a63c3d7bc1ca3002c705b3a7/ckzg-2.1.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:716c2dde0a91c0095797b843f78a6425e20a3d8945ecb4f90550b5c681b6be05", size = 173746, upload-time = "2026-03-11T14:10:43.657Z" },
{ url = "https://files.pythonhosted.org/packages/b9/a7/95b160707b22161817245de8b9e44ea143b9a2083b0c625e5e5cd4a2e20a/ckzg-2.1.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2a9f1a05ed44512b80581e47918b1f4546974e8e924ee0e8de84ab32de197326", size = 188923, upload-time = "2026-03-11T14:10:44.635Z" },
{ url = "https://files.pythonhosted.org/packages/33/d4/ecfbecf763d42606dba8ab9d7de557d01816afad1e2f3cb1cc7efd6fc254/ckzg-2.1.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:42005c188e37c2f65d44f3a2585e89de18e0e229bc667a600d8716808ea2c33b", size = 183607, upload-time = "2026-03-11T14:10:45.846Z" },
{ url = "https://files.pythonhosted.org/packages/4a/72/becb801d8f1224de265f299790f5b2c95e71546ab7ab24a1fd3ebb99519e/ckzg-2.1.7-cp314-cp314-win_amd64.whl", hash = "sha256:14fbc642b1e81893df76a1636fddc169173da5dcdb55fc08a030658cd186150e", size = 102517, upload-time = "2026-03-11T14:10:47.079Z" },
{ url = "https://files.pythonhosted.org/packages/a8/6c/b310f05a6a27baaa53915b43483cc061080e3245c7facaa3c5b3a3cd7c5e/ckzg-2.1.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:da1a07e25ecaeb341ad4caf583fdec12c6af1ef3642289bb7dfcad2ca1b73dd3", size = 96609, upload-time = "2026-03-11T14:10:48.019Z" },
{ url = "https://files.pythonhosted.org/packages/0d/96/e1ccbf3f90595d50aa98a8a9c3c1327e6be0575ddbf8292b26b0cfa69b06/ckzg-2.1.7-cp314-cp314t-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:c657892f93eb70e3295b4f385e25380644c40f8bfebfcd55659f5017257c5b8c", size = 183315, upload-time = "2026-03-11T14:10:49.224Z" },
{ url = "https://files.pythonhosted.org/packages/bc/94/2c7ff1983f82756b29011ad612bc0e1d8f4a1989073c94fd66868bc296d3/ckzg-2.1.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:03af4cf053be82c22a893c8ef971d17687182dd2e75bcc2fab320bc27a62b7cb", size = 169457, upload-time = "2026-03-11T14:10:50.601Z" },
{ url = "https://files.pythonhosted.org/packages/98/cd/8c7247181843185ff5e34ebd400594e0fbe2d81e03324f124834f377ea74/ckzg-2.1.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6ecd9c44427a0035a8a9cb3dc18b4b3c72347f7be7c9f6866b8eddd6598bf0a9", size = 178841, upload-time = "2026-03-11T14:10:51.598Z" },
{ url = "https://files.pythonhosted.org/packages/da/cb/cf2ed4cf461bd2891792317615075745053e2585d8a2cf26a8414ad01983/ckzg-2.1.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16e313e6029e88a564724217dd8eddd6226fbf0a0c07bf65a210bf3512c7b8ad", size = 176489, upload-time = "2026-03-11T14:10:52.905Z" },
{ url = "https://files.pythonhosted.org/packages/50/65/8b7d9cf8883f0df1a15cb20ecec99dfc02fc7bf05bf53509bb270e3a1db0/ckzg-2.1.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8461ec7d69ccb450d4a4d031494a86dc6c15ad54b671967d4a8bdcd8158155b2", size = 191690, upload-time = "2026-03-11T14:10:53.855Z" },
{ url = "https://files.pythonhosted.org/packages/83/56/a1fba1b4a2f90d5fc48d3e62f59f0791c90e85b6ebb600ffeee81ea9cfa6/ckzg-2.1.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:53f420a3fa55a92265e23394caa2aac5b0e1e63ee6489d414cafeb0accde9a9e", size = 186204, upload-time = "2026-03-11T14:10:54.821Z" },
{ url = "https://files.pythonhosted.org/packages/c7/a9/a3284a64216f31a886ff216621c6b3806ca7ad7388908f68fcab9007c881/ckzg-2.1.7-cp314-cp314t-win_amd64.whl", hash = "sha256:2cdcc023d842900564d6070e397cab0d04fd393e6af07d60bdd1c97dc3ff09fd", size = 102660, upload-time = "2026-03-11T14:10:55.974Z" },
]
[[package]]
name = "click"
version = "8.3.1"
@@ -613,6 +786,28 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "construct"
version = "2.10.70"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/02/77/8c84b98eca70d245a2a956452f21d57930d22ab88cbeed9290ca630cf03f/construct-2.10.70.tar.gz", hash = "sha256:4d2472f9684731e58cc9c56c463be63baa1447d674e0d66aeb5627b22f512c29", size = 86337, upload-time = "2023-11-29T08:44:49.545Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/fb/08b3f4bf05da99aba8ffea52a558758def16e8516bc75ca94ff73587e7d3/construct-2.10.70-py3-none-any.whl", hash = "sha256:c80be81ef595a1a821ec69dc16099550ed22197615f4320b57cc9ce2a672cb30", size = 63020, upload-time = "2023-11-29T08:44:46.876Z" },
]
[[package]]
name = "construct-typing"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "construct" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/ae/659fe4866d89ef5a3a65cddbdd7b35882f4feb72db383821965f2fcea934/construct_typing-0.7.0.tar.gz", hash = "sha256:71d110dedff39bd3b603c734077032a7065bc597a49db1f5b03a211d05dbac23", size = 45104, upload-time = "2025-10-27T19:30:29.614Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/0c/2db6f7e1ae9795e436c6a0dc0bc38b12b8c8a228cb63203e24190b755b3b/construct_typing-0.7.0-py3-none-any.whl", hash = "sha256:c92383c6e8e5d07ba25811c8d5163820458d821e73bb1006541f43f89788646c", size = 24350, upload-time = "2025-10-27T19:30:27.505Z" },
]
[[package]]
name = "contourpy"
version = "1.3.3"
@@ -813,6 +1008,149 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" },
]
[[package]]
name = "cytoolz"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "toolz" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bd/d4/16916f3dc20a3f5455b63c35dcb260b3716f59ce27a93586804e70e431d5/cytoolz-1.1.0.tar.gz", hash = "sha256:13a7bf254c3c0d28b12e2290b82aed0f0977a4c2a2bf84854fcdc7796a29f3b0", size = 642510, upload-time = "2025-10-19T00:44:56.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/82/edf1d0c32b6222f2c22e5618d6db855d44eb59f9b6f22436ff963c5d0a5c/cytoolz-1.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dba8e5a8c6e3c789d27b0eb5e7ce5ed7d032a7a9aae17ca4ba5147b871f6e327", size = 1314345, upload-time = "2025-10-19T00:40:13.273Z" },
{ url = "https://files.pythonhosted.org/packages/2d/b5/0e3c1edaa26c2bd9db90cba0ac62c85bbca84224c7ae1c2e0072c4ea64c5/cytoolz-1.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:44b31c05addb0889167a720123b3b497b28dd86f8a0aeaf3ae4ffa11e2c85d55", size = 989259, upload-time = "2025-10-19T00:40:15.196Z" },
{ url = "https://files.pythonhosted.org/packages/09/aa/e2b2ee9fc684867e817640764ea5807f9d25aa1e7bdba02dd4b249aab0f7/cytoolz-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:653cb18c4fc5d8a8cfce2bce650aabcbe82957cd0536827367d10810566d5294", size = 986551, upload-time = "2025-10-19T00:40:16.831Z" },
{ url = "https://files.pythonhosted.org/packages/39/9f/4e8ee41acf6674f10a9c2c9117b2f219429a5a0f09bba6135f34ca4f08a6/cytoolz-1.1.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:853a5b4806915020c890e1ce70cc056bbc1dd8bc44f2d74d555cccfd7aefba7d", size = 2688378, upload-time = "2025-10-19T00:40:18.552Z" },
{ url = "https://files.pythonhosted.org/packages/78/94/ef006f3412bc22444d855a0fc9ecb81424237fb4e5c1a1f8f5fb79ac978f/cytoolz-1.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7b44e9de86bea013fe84fd8c399d6016bbb96c37c5290769e5c99460b9c53e5", size = 2798299, upload-time = "2025-10-19T00:40:20.191Z" },
{ url = "https://files.pythonhosted.org/packages/df/aa/365953926ee8b4f2e07df7200c0d73632155908c8867af14b2d19cc9f1f7/cytoolz-1.1.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:098d628a801dc142e9740126be5624eb7aef1d732bc7a5719f60a2095547b485", size = 2639311, upload-time = "2025-10-19T00:40:22.289Z" },
{ url = "https://files.pythonhosted.org/packages/7c/ee/62beaaee7df208f22590ad07ef8875519af49c52ca39d99460b14a00f15a/cytoolz-1.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:779ee4096ed7a82cffab89372ffc339631c285079dbf33dbe7aff1f6174985df", size = 2979532, upload-time = "2025-10-19T00:40:24.006Z" },
{ url = "https://files.pythonhosted.org/packages/c5/04/2211251e450bed111ada1194dc42c461da9aea441de62a01e4085ea6de9f/cytoolz-1.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f2ce18dd99533d077e9712f9faa852f389f560351b1efd2f2bdb193a95eddde2", size = 3018632, upload-time = "2025-10-19T00:40:26.175Z" },
{ url = "https://files.pythonhosted.org/packages/ed/a2/4a3400e4d07d3916172bf74fede08020d7b4df01595d8a97f1e9507af5ae/cytoolz-1.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac266a34437812cf841cecbfe19f355ab9c3dd1ef231afc60415d40ff12a76e4", size = 2788579, upload-time = "2025-10-19T00:40:27.878Z" },
{ url = "https://files.pythonhosted.org/packages/fe/82/bb88caa53a41f600e7763c517d50e2efbbe6427ea395716a92b83f44882a/cytoolz-1.1.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1920b9b9c13d60d0bb6cd14594b3bce0870022eccb430618c37156da5f2b7a55", size = 2593024, upload-time = "2025-10-19T00:40:29.601Z" },
{ url = "https://files.pythonhosted.org/packages/09/a8/8b25e59570da16c7a0f173b8c6ec0aa6f3abd47fd385c007485acb459896/cytoolz-1.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47caa376dafd2bdc29f8a250acf59c810ec9105cd6f7680b9a9d070aae8490ec", size = 2715304, upload-time = "2025-10-19T00:40:31.151Z" },
{ url = "https://files.pythonhosted.org/packages/d4/56/faec7696f235521b926ffdf92c102f5b029f072d28e1020364e55b084820/cytoolz-1.1.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5ab2c97d8aaa522b038cca9187b1153347af22309e7c998b14750c6fdec7b1cb", size = 2654461, upload-time = "2025-10-19T00:40:32.884Z" },
{ url = "https://files.pythonhosted.org/packages/aa/82/f790ed167c04b8d2a33bed30770a9b7066fc4f573321d797190e5f05685f/cytoolz-1.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4bce006121b120e8b359244ee140bb0b1093908efc8b739db8dbaa3f8fb42139", size = 2672077, upload-time = "2025-10-19T00:40:34.543Z" },
{ url = "https://files.pythonhosted.org/packages/d9/b3/80b8183e7eee44f45bfa3cdd3ebdadf3dd43ffc686f96d442a6c4dded45d/cytoolz-1.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7fc0f1e4e9bb384d26e73c6657bbc26abdae4ff66a95933c00f3d578be89181b", size = 2881589, upload-time = "2025-10-19T00:40:36.315Z" },
{ url = "https://files.pythonhosted.org/packages/8f/05/ac5ba5ddb88a3ba7ecea4bf192194a838af564d22ea7a4812cbb6bd106ce/cytoolz-1.1.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:dd3f894ff972da1994d06ac6157d74e40dda19eb31fe5e9b7863ca4278c3a167", size = 2589924, upload-time = "2025-10-19T00:40:38.317Z" },
{ url = "https://files.pythonhosted.org/packages/8e/cd/100483cae3849d24351c8333a815dc6adaf3f04912486e59386d86d9db9a/cytoolz-1.1.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0846f49cf8a4496bd42659040e68bd0484ce6af819709cae234938e039203ba0", size = 2868059, upload-time = "2025-10-19T00:40:40.025Z" },
{ url = "https://files.pythonhosted.org/packages/34/6e/3a7c56b325772d39397fc3aafb4dc054273982097178b6c3917c6dad48de/cytoolz-1.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:16a3af394ade1973226d64bb2f9eb3336adbdea03ed5b134c1bbec5a3b20028e", size = 2721692, upload-time = "2025-10-19T00:40:41.621Z" },
{ url = "https://files.pythonhosted.org/packages/fa/ca/9fdaee32c3bc769dfb7e7991d9499136afccea67e423d097b8fb3c5acbc1/cytoolz-1.1.0-cp311-cp311-win32.whl", hash = "sha256:b786c9c8aeab76cc2f76011e986f7321a23a56d985b77d14f155d5e5514ea781", size = 899349, upload-time = "2025-10-19T00:40:43.183Z" },
{ url = "https://files.pythonhosted.org/packages/fd/04/2ab98edeea90311e4029e1643e43d2027b54da61453292d9ea51a103ee87/cytoolz-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:ebf06d1c5344fb22fee71bf664234733e55db72d74988f2ecb7294b05e4db30c", size = 945831, upload-time = "2025-10-19T00:40:44.693Z" },
{ url = "https://files.pythonhosted.org/packages/b4/8d/777d86ea6bcc68b0fc926b0ef8ab51819e2176b37aadea072aac949d5231/cytoolz-1.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:b63f5f025fac893393b186e132e3e242de8ee7265d0cd3f5bdd4dda93f6616c9", size = 904076, upload-time = "2025-10-19T00:40:46.678Z" },
{ url = "https://files.pythonhosted.org/packages/c6/ec/01426224f7acf60183d3921b25e1a8e71713d3d39cb464d64ac7aace6ea6/cytoolz-1.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:99f8e134c9be11649342853ec8c90837af4089fc8ff1e8f9a024a57d1fa08514", size = 1327800, upload-time = "2025-10-19T00:40:48.674Z" },
{ url = "https://files.pythonhosted.org/packages/b4/07/e07e8fedd332ac9626ad58bea31416dda19bfd14310731fa38b16a97e15f/cytoolz-1.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0a6f44cf9319c30feb9a50aa513d777ef51efec16f31c404409e7deb8063df64", size = 997118, upload-time = "2025-10-19T00:40:50.919Z" },
{ url = "https://files.pythonhosted.org/packages/ab/72/c0f766d63ed2f9ea8dc8e1628d385d99b41fb834ce17ac3669e3f91e115d/cytoolz-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:945580dc158c557172fca899a35a99a16fbcebf6db0c77cb6621084bc82189f9", size = 991169, upload-time = "2025-10-19T00:40:52.887Z" },
{ url = "https://files.pythonhosted.org/packages/df/4b/1f757353d1bf33e56a7391ecc9bc49c1e529803b93a9d2f67fe5f92906fe/cytoolz-1.1.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:257905ec050d04f2f856854620d1e25556fd735064cebd81b460f54939b9f9d5", size = 2700680, upload-time = "2025-10-19T00:40:54.597Z" },
{ url = "https://files.pythonhosted.org/packages/25/73/9b25bb7ed8d419b9d6ff2ae0b3d06694de79a3f98f5169a1293ff7ad3a3f/cytoolz-1.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82779049f352fb3ab5e8c993ab45edbb6e02efb1f17f0b50f4972c706cc51d76", size = 2824951, upload-time = "2025-10-19T00:40:56.137Z" },
{ url = "https://files.pythonhosted.org/packages/0c/93/9c787f7c909e75670fff467f2504725d06d8c3f51d6dfe22c55a08c8ccd4/cytoolz-1.1.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7d3e405e435320e08c5a1633afaf285a392e2d9cef35c925d91e2a31dfd7a688", size = 2679635, upload-time = "2025-10-19T00:40:57.799Z" },
{ url = "https://files.pythonhosted.org/packages/50/aa/9ee92c302cccf7a41a7311b325b51ebeff25d36c1f82bdc1bbe3f58dc947/cytoolz-1.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:923df8f5591e0d20543060c29909c149ab1963a7267037b39eee03a83dbc50a8", size = 2938352, upload-time = "2025-10-19T00:40:59.49Z" },
{ url = "https://files.pythonhosted.org/packages/6a/a3/3b58c5c1692c3bacd65640d0d5c7267a7ebb76204f7507aec29de7063d2f/cytoolz-1.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:25db9e4862f22ea0ae2e56c8bec9fc9fd756b655ae13e8c7b5625d7ed1c582d4", size = 3022121, upload-time = "2025-10-19T00:41:01.209Z" },
{ url = "https://files.pythonhosted.org/packages/e1/93/c647bc3334355088c57351a536c2d4a83dd45f7de591fab383975e45bff9/cytoolz-1.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7a98deb11ccd8e5d9f9441ef2ff3352aab52226a2b7d04756caaa53cd612363", size = 2857656, upload-time = "2025-10-19T00:41:03.456Z" },
{ url = "https://files.pythonhosted.org/packages/b2/c2/43fea146bf4141deea959e19dcddf268c5ed759dec5c2ed4a6941d711933/cytoolz-1.1.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dce4ee9fc99104bc77efdea80f32ca5a650cd653bcc8a1d984a931153d3d9b58", size = 2551284, upload-time = "2025-10-19T00:41:05.347Z" },
{ url = "https://files.pythonhosted.org/packages/6f/df/cdc7a81ce5cfcde7ef523143d545635fc37e80ccacce140ae58483a21da3/cytoolz-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80d6da158f7d20c15819701bbda1c041f0944ede2f564f5c739b1bc80a9ffb8b", size = 2721673, upload-time = "2025-10-19T00:41:07.528Z" },
{ url = "https://files.pythonhosted.org/packages/45/be/f8524bb9ad8812ad375e61238dcaa3177628234d1b908ad0b74e3657cafd/cytoolz-1.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:3b5c5a192abda123ad45ef716ec9082b4cf7d95e9ada8291c5c2cc5558be858b", size = 2722884, upload-time = "2025-10-19T00:41:09.698Z" },
{ url = "https://files.pythonhosted.org/packages/23/e6/6bb8e4f9c267ad42d1ff77b6d2e4984665505afae50a216290e1d7311431/cytoolz-1.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5b399ce7d967b1cb6280250818b786be652aa8ddffd3c0bb5c48c6220d945ab5", size = 2685486, upload-time = "2025-10-19T00:41:11.349Z" },
{ url = "https://files.pythonhosted.org/packages/d7/dd/88619f9c8d2b682562c0c886bbb7c35720cb83fda2ac9a41bdd14073d9bd/cytoolz-1.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e7e29a1a03f00b4322196cfe8e2c38da9a6c8d573566052c586df83aacc5663c", size = 2839661, upload-time = "2025-10-19T00:41:13.053Z" },
{ url = "https://files.pythonhosted.org/packages/b8/8d/4478ebf471ee78dd496d254dc0f4ad729cd8e6ba8257de4f0a98a2838ef2/cytoolz-1.1.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5291b117d71652a817ec164e7011f18e6a51f8a352cc9a70ed5b976c51102fda", size = 2547095, upload-time = "2025-10-19T00:41:16.054Z" },
{ url = "https://files.pythonhosted.org/packages/e6/68/f1dea33367b0b3f64e199c230a14a6b6f243c189020effafd31e970ca527/cytoolz-1.1.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:8caef62f846a9011676c51bda9189ae394cdd6bb17f2946ecaedc23243268320", size = 2870901, upload-time = "2025-10-19T00:41:17.727Z" },
{ url = "https://files.pythonhosted.org/packages/4a/9a/33591c09dfe799b8fb692cf2ad383e2c41ab6593cc960b00d1fc8a145655/cytoolz-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:de425c5a8e3be7bb3a195e19191d28d9eb3c2038046064a92edc4505033ec9cb", size = 2765422, upload-time = "2025-10-19T00:41:20.075Z" },
{ url = "https://files.pythonhosted.org/packages/60/2b/a8aa233c9416df87f004e57ae4280bd5e1f389b4943d179f01020c6ec629/cytoolz-1.1.0-cp312-cp312-win32.whl", hash = "sha256:296440a870e8d1f2e1d1edf98f60f1532b9d3ab8dfbd4b25ec08cd76311e79e5", size = 901933, upload-time = "2025-10-19T00:41:21.646Z" },
{ url = "https://files.pythonhosted.org/packages/ad/33/4c9bdf8390dc01d2617c7f11930697157164a52259b6818ddfa2f94f89f4/cytoolz-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:07156987f224c6dac59aa18fb8bf91e1412f5463961862716a3381bf429c8699", size = 947989, upload-time = "2025-10-19T00:41:23.288Z" },
{ url = "https://files.pythonhosted.org/packages/35/ac/6e2708835875f5acb52318462ed296bf94ed0cb8c7cb70e62fbd03f709e3/cytoolz-1.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:23e616b38f5b3160c7bb45b0f84a8f3deb4bd26b29fb2dfc716f241c738e27b8", size = 903913, upload-time = "2025-10-19T00:41:24.992Z" },
{ url = "https://files.pythonhosted.org/packages/71/4a/b3ddb3ee44fe0045e95dd973746f93f033b6f92cce1fc3cbbe24b329943c/cytoolz-1.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:76c9b58555300be6dde87a41faf1f97966d79b9a678b7a526fcff75d28ef4945", size = 976728, upload-time = "2025-10-19T00:41:26.5Z" },
{ url = "https://files.pythonhosted.org/packages/42/21/a3681434aa425875dd828bb515924b0f12c37a55c7d2bc5c0c5de3aeb0b4/cytoolz-1.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d1d638b10d3144795655e9395566ce35807df09219fd7cacd9e6acbdef67946a", size = 986057, upload-time = "2025-10-19T00:41:28.911Z" },
{ url = "https://files.pythonhosted.org/packages/d9/cb/efc1b29e211e0670a6953222afaac84dcbba5cb940b130c0e49858978040/cytoolz-1.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:26801c1a165e84786a99e03c9c9973356caaca002d66727b761fb1042878ef06", size = 992632, upload-time = "2025-10-19T00:41:30.612Z" },
{ url = "https://files.pythonhosted.org/packages/be/b0/e50621d21e939338c97faab651f58ea7fa32101226a91de79ecfb89d71e1/cytoolz-1.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2a9a464542912d3272f6dccc5142df057c71c6a5cbd30439389a732df401afb7", size = 1317534, upload-time = "2025-10-19T00:41:32.625Z" },
{ url = "https://files.pythonhosted.org/packages/0d/6b/25aa9739b0235a5bc4c1ea293186bc6822a4c6607acfe1422423287e7400/cytoolz-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ed6104fa942aa5784bf54f339563de637557e3443b105760bc4de8f16a7fc79b", size = 992336, upload-time = "2025-10-19T00:41:34.073Z" },
{ url = "https://files.pythonhosted.org/packages/e1/53/5f4deb0ff958805309d135d899c764364c1e8a632ce4994bd7c45fb98df2/cytoolz-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56161f0ab60dc4159ec343509abaf809dc88e85c7e420e354442c62e3e7cbb77", size = 986118, upload-time = "2025-10-19T00:41:35.7Z" },
{ url = "https://files.pythonhosted.org/packages/1c/e3/f6255b76c8cc0debbe1c0779130777dc0434da6d9b28a90d9f76f8cb67cd/cytoolz-1.1.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:832bd36cc9123535f1945acf6921f8a2a15acc19cfe4065b1c9b985a28671886", size = 2679563, upload-time = "2025-10-19T00:41:37.926Z" },
{ url = "https://files.pythonhosted.org/packages/59/8a/acc6e39a84e930522b965586ad3a36694f9bf247b23188ee0eb47b1c9ed1/cytoolz-1.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1842636b6e034f229bf084c2bcdcfd36c8437e752eefd2c74ce9e2f10415cb6e", size = 2813020, upload-time = "2025-10-19T00:41:39.935Z" },
{ url = "https://files.pythonhosted.org/packages/db/f5/0083608286ad1716eda7c41f868e85ac549f6fd6b7646993109fa0bdfd98/cytoolz-1.1.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:823df012ab90d2f2a0f92fea453528539bf71ac1879e518524cd0c86aa6df7b9", size = 2669312, upload-time = "2025-10-19T00:41:41.55Z" },
{ url = "https://files.pythonhosted.org/packages/47/a8/d16080b575520fe5da00cede1ece4e0a4180ec23f88dcdc6a2f5a90a7f7f/cytoolz-1.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f1fcf9e7e7b3487883ff3f815abc35b89dcc45c4cf81c72b7ee457aa72d197b", size = 2922147, upload-time = "2025-10-19T00:41:43.252Z" },
{ url = "https://files.pythonhosted.org/packages/7e/bc/716c9c1243701e58cad511eb3937fd550e645293c5ed1907639c5d66f194/cytoolz-1.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4cdb3fa1772116827f263f25b0cdd44c663b6701346a56411960534a06c082de", size = 2981602, upload-time = "2025-10-19T00:41:45.354Z" },
{ url = "https://files.pythonhosted.org/packages/14/bc/571b232996846b27f4ac0c957dc8bf60261e9b4d0d01c8d955e82329544e/cytoolz-1.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1b5c95041741b81430454db65183e133976f45ac3c03454cfa8147952568529", size = 2830103, upload-time = "2025-10-19T00:41:47.959Z" },
{ url = "https://files.pythonhosted.org/packages/5b/55/c594afb46ecd78e4b7e1fb92c947ed041807875661ceda73baaf61baba4f/cytoolz-1.1.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b2079fd9f1a65f4c61e6278c8a6d4f85edf30c606df8d5b32f1add88cbbe2286", size = 2533802, upload-time = "2025-10-19T00:41:49.683Z" },
{ url = "https://files.pythonhosted.org/packages/93/83/1edcf95832555a78fc43b975f3ebe8ceadcc9664dd47fd33747a14df5069/cytoolz-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a92a320d72bef1c7e2d4c6d875125cf57fc38be45feb3fac1bfa64ea401f54a4", size = 2706071, upload-time = "2025-10-19T00:41:51.386Z" },
{ url = "https://files.pythonhosted.org/packages/e2/df/035a408df87f25cfe3611557818b250126cd2281b2104cd88395de205583/cytoolz-1.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06d1c79aa51e6a92a90b0e456ebce2288f03dd6a76c7f582bfaa3eda7692e8a5", size = 2707575, upload-time = "2025-10-19T00:41:53.305Z" },
{ url = "https://files.pythonhosted.org/packages/7a/a4/ef78e13e16e93bf695a9331321d75fbc834a088d941f1c19e6b63314e257/cytoolz-1.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e1d7be25f6971e986a52b6d3a0da28e1941850985417c35528f6823aef2cfec5", size = 2660486, upload-time = "2025-10-19T00:41:55.542Z" },
{ url = "https://files.pythonhosted.org/packages/30/7a/2c3d60682b26058d435416c4e90d4a94db854de5be944dfd069ed1be648a/cytoolz-1.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:964b248edc31efc50a65e9eaa0c845718503823439d2fa5f8d2c7e974c2b5409", size = 2819605, upload-time = "2025-10-19T00:41:58.257Z" },
{ url = "https://files.pythonhosted.org/packages/45/92/19b722a1d83cc443fbc0c16e0dc376f8a451437890d3d9ee370358cf0709/cytoolz-1.1.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c9ff2b3c57c79b65cb5be14a18c6fd4a06d5036fb3f33e973a9f70e9ac13ca28", size = 2533559, upload-time = "2025-10-19T00:42:00.324Z" },
{ url = "https://files.pythonhosted.org/packages/1d/15/fa3b7891da51115204416f14192081d3dea0eaee091f123fdc1347de8dd1/cytoolz-1.1.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:22290b73086af600042d99f5ce52a43d4ad9872c382610413176e19fc1d4fd2d", size = 2839171, upload-time = "2025-10-19T00:42:01.881Z" },
{ url = "https://files.pythonhosted.org/packages/46/40/d3519d5cd86eebebf1e8b7174ec32dfb6ecec67b48b0cfb92bf226659b5a/cytoolz-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a2ade74fccd080ea793382968913ee38d7a35c921df435bbf0a6aeecf0d17574", size = 2743379, upload-time = "2025-10-19T00:42:03.809Z" },
{ url = "https://files.pythonhosted.org/packages/93/e2/a9e7511f0a13fdbefa5bf73cf8e4763878140de9453fd3e50d6ac57b6be7/cytoolz-1.1.0-cp313-cp313-win32.whl", hash = "sha256:db5dbcfda1c00e937426cbf9bdc63c24ebbc358c3263bfcbc1ab4a88dc52aa8e", size = 900844, upload-time = "2025-10-19T00:42:05.967Z" },
{ url = "https://files.pythonhosted.org/packages/d6/a4/fb7eb403c6a4c81e5a30363f34a71adcc8bf5292dc8ea32e2440aa5668f2/cytoolz-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:9e2d3fe3b45c3eb7233746f7aca37789be3dceec3e07dcc406d3e045ea0f7bdc", size = 946461, upload-time = "2025-10-19T00:42:07.983Z" },
{ url = "https://files.pythonhosted.org/packages/93/bb/1c8c33d353548d240bc6e8677ee8c3560ce5fa2f084e928facf7c35a6dcf/cytoolz-1.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:32c559f95ff44a9ebcbd934acaa1e6dc8f3e6ffce4762a79a88528064873d6d5", size = 902673, upload-time = "2025-10-19T00:42:09.982Z" },
{ url = "https://files.pythonhosted.org/packages/c4/ba/4a53acc60f59030fcaf48c7766e3c4c81bd997379425aa45b129396557b5/cytoolz-1.1.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9e2cd93b28f667c5870a070ab2b8bb4397470a85c4b204f2454b0ad001cd1ca3", size = 1372336, upload-time = "2025-10-19T00:42:12.104Z" },
{ url = "https://files.pythonhosted.org/packages/ac/90/f28fd8ad8319d8f5c8da69a2c29b8cf52a6d2c0161602d92b366d58926ab/cytoolz-1.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f494124e141a9361f31d79875fe7ea459a3be2b9dadd90480427c0c52a0943d4", size = 1011930, upload-time = "2025-10-19T00:42:14.231Z" },
{ url = "https://files.pythonhosted.org/packages/c9/95/4561c4e0ad1c944f7673d6d916405d68080f10552cfc5d69a1cf2475a9a1/cytoolz-1.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:53a3262bf221f19437ed544bf8c0e1980c81ac8e2a53d87a9bc075dba943d36f", size = 1020610, upload-time = "2025-10-19T00:42:15.877Z" },
{ url = "https://files.pythonhosted.org/packages/c3/14/b2e1ffa4995ec36e1372e243411ff36325e4e6d7ffa34eb4098f5357d176/cytoolz-1.1.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:47663e57d3f3f124921f38055e86a1022d0844c444ede2e8f090d3bbf80deb65", size = 2917327, upload-time = "2025-10-19T00:42:17.706Z" },
{ url = "https://files.pythonhosted.org/packages/4a/29/7cab6c609b4514ac84cca2f7dca6c509977a8fc16d27c3a50e97f105fa6a/cytoolz-1.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5a8755c4104ee4e3d5ba434c543b5f85fdee6a1f1df33d93f518294da793a60", size = 3108951, upload-time = "2025-10-19T00:42:19.363Z" },
{ url = "https://files.pythonhosted.org/packages/9a/71/1d1103b819458679277206ad07d78ca6b31c4bb88d6463fd193e19bfb270/cytoolz-1.1.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4d96ff3d381423af1b105295f97de86d1db51732c9566eb37378bab6670c5010", size = 2807149, upload-time = "2025-10-19T00:42:20.964Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d4/3d83a05a21e7d2ed2b9e6daf489999c29934b005de9190272b8a2e3735d0/cytoolz-1.1.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0ec96b3d537cdf47d4e76ded199f7440715f4c71029b45445cff92c1248808c2", size = 3111608, upload-time = "2025-10-19T00:42:22.684Z" },
{ url = "https://files.pythonhosted.org/packages/51/88/96f68354c3d4af68de41f0db4fe41a23b96a50a4a416636cea325490cfeb/cytoolz-1.1.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:208e2f2ef90a32b0acbff3303d90d89b13570a228d491d2e622a7883a3c68148", size = 3179373, upload-time = "2025-10-19T00:42:24.395Z" },
{ url = "https://files.pythonhosted.org/packages/ce/50/ed87a5cd8e6f27ffbb64c39e9730e18ec66c37631db2888ae711909f10c9/cytoolz-1.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d416a81bb0bd517558668e49d30a7475b5445f9bbafaab7dcf066f1e9adba36", size = 3003120, upload-time = "2025-10-19T00:42:26.18Z" },
{ url = "https://files.pythonhosted.org/packages/d3/a7/acde155b050d6eaa8e9c7845c98fc5fb28501568e78e83ebbf44f8855274/cytoolz-1.1.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f32e94c91ffe49af04835ee713ebd8e005c85ebe83e7e1fdcc00f27164c2d636", size = 2703225, upload-time = "2025-10-19T00:42:27.93Z" },
{ url = "https://files.pythonhosted.org/packages/1b/b6/9d518597c5bdea626b61101e8d2ff94124787a42259dafd9f5fc396f346a/cytoolz-1.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:15d0c6405efc040499c46df44056a5c382f551a7624a41cf3e4c84a96b988a15", size = 2956033, upload-time = "2025-10-19T00:42:29.993Z" },
{ url = "https://files.pythonhosted.org/packages/89/7a/93e5f860926165538c85e1c5e1670ad3424f158df810f8ccd269da652138/cytoolz-1.1.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:bf069c5381d757debae891401b88b3a346ba3a28ca45ba9251103b282463fad8", size = 2862950, upload-time = "2025-10-19T00:42:31.803Z" },
{ url = "https://files.pythonhosted.org/packages/76/e6/99d6af00487bedc27597b54c9fcbfd5c833a69c6b7a9b9f0fff777bfc7aa/cytoolz-1.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d5cf15892e63411ec1bd67deff0e84317d974e6ab2cdfefdd4a7cea2989df66", size = 2861757, upload-time = "2025-10-19T00:42:33.625Z" },
{ url = "https://files.pythonhosted.org/packages/71/ca/adfa1fb7949478135a37755cb8e88c20cd6b75c22a05f1128f05f3ab2c60/cytoolz-1.1.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3e3872c21170f8341656f8692f8939e8800dcee6549ad2474d4c817bdefd62cd", size = 2979049, upload-time = "2025-10-19T00:42:35.377Z" },
{ url = "https://files.pythonhosted.org/packages/70/4c/7bf47a03a4497d500bc73d4204e2d907771a017fa4457741b2a1d7c09319/cytoolz-1.1.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b9ddeff8e8fd65eb1fcefa61018100b2b627e759ea6ad275d2e2a93ffac147bf", size = 2699492, upload-time = "2025-10-19T00:42:37.133Z" },
{ url = "https://files.pythonhosted.org/packages/7e/e7/3d034b0e4817314f07aa465d5864e9b8df9d25cb260a53dd84583e491558/cytoolz-1.1.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:02feeeda93e1fa3b33414eb57c2b0aefd1db8f558dd33fdfcce664a0f86056e4", size = 2995646, upload-time = "2025-10-19T00:42:38.912Z" },
{ url = "https://files.pythonhosted.org/packages/c1/62/be357181c71648d9fe1d1ce91cd42c63457dcf3c158e144416fd51dced83/cytoolz-1.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d08154ad45349162b6c37f12d5d1b2e6eef338e657b85e1621e4e6a4a69d64cb", size = 2919481, upload-time = "2025-10-19T00:42:40.85Z" },
{ url = "https://files.pythonhosted.org/packages/62/d5/bf5434fde726c4f80cb99912b2d8e0afa1587557e2a2d7e0315eb942f2de/cytoolz-1.1.0-cp313-cp313t-win32.whl", hash = "sha256:10ae4718a056948d73ca3e1bb9ab1f95f897ec1e362f829b9d37cc29ab566c60", size = 951595, upload-time = "2025-10-19T00:42:42.877Z" },
{ url = "https://files.pythonhosted.org/packages/64/29/39c161e9204a9715321ddea698cbd0abc317e78522c7c642363c20589e71/cytoolz-1.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:1bb77bc6197e5cb19784b6a42bb0f8427e81737a630d9d7dda62ed31733f9e6c", size = 1004445, upload-time = "2025-10-19T00:42:44.855Z" },
{ url = "https://files.pythonhosted.org/packages/e2/5a/7cbff5e9a689f558cb0bdf277f9562b2ac51acf7cd15e055b8c3efb0e1ef/cytoolz-1.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:563dda652c6ff52d215704fbe6b491879b78d7bbbb3a9524ec8e763483cb459f", size = 926207, upload-time = "2025-10-19T00:42:46.456Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e8/297a85ba700f437c01eba962428e6ab4572f6c3e68e8ff442ce5c9d3a496/cytoolz-1.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d542cee7c7882d2a914a33dec4d3600416fb336734df979473249d4c53d207a1", size = 980613, upload-time = "2025-10-19T00:42:47.988Z" },
{ url = "https://files.pythonhosted.org/packages/e8/d7/2b02c9d18e9cc263a0e22690f78080809f1eafe72f26b29ccc115d3bf5c8/cytoolz-1.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31922849b701b0f24bb62e56eb2488dcd3aa6ae3057694bd6b3b7c4c2bc27c2f", size = 990476, upload-time = "2025-10-19T00:42:49.653Z" },
{ url = "https://files.pythonhosted.org/packages/89/26/b6b159d2929310fca0eff8a4989cd4b1ecbdf7c46fdff46c7a20fcae55c8/cytoolz-1.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e68308d32afd31943314735c1335e4ab5696110e96b405f6bdb8f2a8dc771a16", size = 992712, upload-time = "2025-10-19T00:42:51.306Z" },
{ url = "https://files.pythonhosted.org/packages/42/a0/f7c572aa151ed466b0fce4a327c3cc916d3ef3c82e341be59ea4b9bee9e4/cytoolz-1.1.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fc4bb48b3b866e1867f7c6411a4229e5b44be3989060663713e10efc24c9bd5f", size = 1322596, upload-time = "2025-10-19T00:42:52.978Z" },
{ url = "https://files.pythonhosted.org/packages/72/7c/a55d035e20b77b6725e85c8f1a418b3a4c23967288b8b0c2d1a40f158cbe/cytoolz-1.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:456f77207d1445025d7ef262b8370a05492dcb1490cb428b0f3bf1bd744a89b0", size = 992825, upload-time = "2025-10-19T00:42:55.026Z" },
{ url = "https://files.pythonhosted.org/packages/03/af/39d2d3db322136e12e9336a1f13bab51eab88b386bfb11f91d3faff8ba34/cytoolz-1.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:174ebc71ebb20a9baeffce6ee07ee2cd913754325c93f99d767380d8317930f7", size = 990525, upload-time = "2025-10-19T00:42:56.666Z" },
{ url = "https://files.pythonhosted.org/packages/a6/bd/65d7a869d307f9b10ad45c2c1cbb40b81a8d0ed1138fa17fd904f5c83298/cytoolz-1.1.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8b3604fef602bcd53415055a4f68468339192fd17be39e687ae24f476d23d56e", size = 2672409, upload-time = "2025-10-19T00:42:58.81Z" },
{ url = "https://files.pythonhosted.org/packages/2d/fb/74dfd844bfd67e810bd36e8e3903a143035447245828e7fcd7c81351d775/cytoolz-1.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3604b959a01f64c366e7d10ec7634d5f5cfe10301e27a8f090f6eb3b2a628a18", size = 2808477, upload-time = "2025-10-19T00:43:00.577Z" },
{ url = "https://files.pythonhosted.org/packages/d6/1f/587686c43e31c19241ec317da66438d093523921ea7749bbc65558a30df9/cytoolz-1.1.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6db2127a3c1bc2f59f08010d2ae53a760771a9de2f67423ad8d400e9ba4276e8", size = 2636881, upload-time = "2025-10-19T00:43:02.24Z" },
{ url = "https://files.pythonhosted.org/packages/bc/6d/90468cd34f77cb38a11af52c4dc6199efcc97a486395a21bef72e9b7602e/cytoolz-1.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56584745ac647993a016a21bc76399113b7595e312f8d0a1b140c9fcf9b58a27", size = 2937315, upload-time = "2025-10-19T00:43:03.954Z" },
{ url = "https://files.pythonhosted.org/packages/d9/50/7b92cd78c613b92e3509e6291d3fb7e0d72ebda999a8df806a96c40ca9ab/cytoolz-1.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db2c4c3a7f7bd7e03bb1a236a125c8feb86c75802f4ecda6ecfaf946610b2930", size = 2959988, upload-time = "2025-10-19T00:43:05.758Z" },
{ url = "https://files.pythonhosted.org/packages/44/d5/34b5a28a8d9bb329f984b4c2259407ca3f501d1abeb01bacea07937d85d1/cytoolz-1.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48cb8a692111a285d2b9acd16d185428176bfbffa8a7c274308525fccd01dd42", size = 2795116, upload-time = "2025-10-19T00:43:07.411Z" },
{ url = "https://files.pythonhosted.org/packages/f5/d9/5dd829e33273ec03bdc3c812e6c3281987ae2c5c91645582f6c331544a64/cytoolz-1.1.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d2f344ba5eb17dcf38ee37fdde726f69053f54927db8f8a1bed6ac61e5b1890d", size = 2535390, upload-time = "2025-10-19T00:43:09.104Z" },
{ url = "https://files.pythonhosted.org/packages/87/1f/7f9c58068a8eec2183110df051bc6b69dd621143f84473eeb6dc1b32905a/cytoolz-1.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:abf76b1c1abd031f098f293b6d90ee08bdaa45f8b5678430e331d991b82684b1", size = 2704834, upload-time = "2025-10-19T00:43:10.942Z" },
{ url = "https://files.pythonhosted.org/packages/d2/90/667def5665333575d01a65fe3ec0ca31b897895f6e3bc1a42d6ea3659369/cytoolz-1.1.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ddf9a38a5b686091265ff45b53d142e44a538cd6c2e70610d3bc6be094219032", size = 2658441, upload-time = "2025-10-19T00:43:12.655Z" },
{ url = "https://files.pythonhosted.org/packages/23/79/6615f9a14960bd29ac98b823777b6589357833f65cf1a11b5abc1587c120/cytoolz-1.1.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:946786755274f07bb2be0400f28adb31d7d85a7c7001873c0a8e24a503428fb3", size = 2654766, upload-time = "2025-10-19T00:43:14.325Z" },
{ url = "https://files.pythonhosted.org/packages/b0/99/be59c6e0ae02153ef10ae1ff0f380fb19d973c651b50cf829a731f6c9e79/cytoolz-1.1.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d5b8f78b9fed79cf185ad4ddec099abeef45951bdcb416c5835ba05f0a1242c7", size = 2827649, upload-time = "2025-10-19T00:43:16.132Z" },
{ url = "https://files.pythonhosted.org/packages/19/b7/854ddcf9f9618844108677c20d48f4611b5c636956adea0f0e85e027608f/cytoolz-1.1.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fccde6efefdbc02e676ccb352a2ccc8a8e929f59a1c6d3d60bb78e923a49ca44", size = 2533456, upload-time = "2025-10-19T00:43:17.764Z" },
{ url = "https://files.pythonhosted.org/packages/45/66/bfe6fbb2bdcf03c8377c8c2f542576e15f3340c905a09d78a6cb3badd39a/cytoolz-1.1.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:717b7775313da5f51b0fbf50d865aa9c39cb241bd4cb605df3cf2246d6567397", size = 2826455, upload-time = "2025-10-19T00:43:19.561Z" },
{ url = "https://files.pythonhosted.org/packages/c3/0c/cce4047bd927e95f59e73319c02c9bc86bd3d76392e0eb9e41a1147a479c/cytoolz-1.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5158744a09d0e0e4a4f82225e3a3c4ebf38f9ae74467aaa905467270e52f2794", size = 2714897, upload-time = "2025-10-19T00:43:21.291Z" },
{ url = "https://files.pythonhosted.org/packages/ac/9a/061323bb289b565802bad14fb7ab59fcd8713105df142bcf4dd9ff64f8ac/cytoolz-1.1.0-cp314-cp314-win32.whl", hash = "sha256:1ed534bdbbf063b2bb28fca7d0f6723a3e5a72b086e7c7fe6d74ae8c3e4d00e2", size = 901490, upload-time = "2025-10-19T00:43:22.895Z" },
{ url = "https://files.pythonhosted.org/packages/a3/20/1f3a733d710d2a25d6f10b463bef55ada52fe6392a5d233c8d770191f48a/cytoolz-1.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:472c1c9a085f5ad973ec0ad7f0b9ba0969faea6f96c9e397f6293d386f3a25ec", size = 946730, upload-time = "2025-10-19T00:43:24.838Z" },
{ url = "https://files.pythonhosted.org/packages/f2/22/2d657db4a5d1c10a152061800f812caba9ef20d7bd2406f51a5fd800c180/cytoolz-1.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:a7ad7ca3386fa86bd301be3fa36e7f0acb024f412f665937955acfc8eb42deff", size = 905722, upload-time = "2025-10-19T00:43:26.439Z" },
{ url = "https://files.pythonhosted.org/packages/19/97/b4a8c76796a9a8b9bc90c7992840fa1589a1af8e0426562dea4ce9b384a7/cytoolz-1.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:64b63ed4b71b1ba813300ad0f06b8aff19a12cf51116e0e4f1ed837cea4debcf", size = 1372606, upload-time = "2025-10-19T00:43:28.491Z" },
{ url = "https://files.pythonhosted.org/packages/08/d4/a1bb1a32b454a2d650db8374ff3bf875ba0fc1c36e6446ec02a83b9140a1/cytoolz-1.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a60ba6f2ed9eb0003a737e1ee1e9fa2258e749da6477946008d4324efa25149f", size = 1012189, upload-time = "2025-10-19T00:43:30.177Z" },
{ url = "https://files.pythonhosted.org/packages/21/4b/2f5cbbd81588918ee7dd70cffb66731608f578a9b72166aafa991071af7d/cytoolz-1.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1aa58e2434d732241f7f051e6f17657e969a89971025e24578b5cbc6f1346485", size = 1020624, upload-time = "2025-10-19T00:43:31.712Z" },
{ url = "https://files.pythonhosted.org/packages/f5/99/c4954dd86cd593cd776a038b36795a259b8b5c12cbab6363edf5f6d9c909/cytoolz-1.1.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6965af3fc7214645970e312deb9bd35a213a1eaabcfef4f39115e60bf2f76867", size = 2917016, upload-time = "2025-10-19T00:43:33.531Z" },
{ url = "https://files.pythonhosted.org/packages/b2/7c/f1f70a17e272b433232bc8a27df97e46b202d6cc07e3b0d63f7f41ba0f2d/cytoolz-1.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ddd2863f321d67527d3b67a93000a378ad6f967056f68c06467fe011278a6d0e", size = 3107634, upload-time = "2025-10-19T00:43:35.57Z" },
{ url = "https://files.pythonhosted.org/packages/8f/bd/c3226a57474b4aef1f90040510cba30d0decd3515fed48dc229b37c2f898/cytoolz-1.1.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4e6b428e9eb5126053c2ae0efa62512ff4b38ed3951f4d0888ca7005d63e56f5", size = 2806221, upload-time = "2025-10-19T00:43:37.707Z" },
{ url = "https://files.pythonhosted.org/packages/c3/47/2f7bfe4aaa1e07dc9828bea228ed744faf73b26aee0c1bdf3b5520bf1909/cytoolz-1.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d758e5ef311d2671e0ae8c214c52e44617cf1e58bef8f022b547b9802a5a7f30", size = 3107671, upload-time = "2025-10-19T00:43:39.401Z" },
{ url = "https://files.pythonhosted.org/packages/4d/12/6ff3b04fbd1369d0fcd5f8b5910ba6e427e33bf113754c4c35ec3f747924/cytoolz-1.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a95416eca473e6c1179b48d86adcf528b59c63ce78f4cb9934f2e413afa9b56b", size = 3176350, upload-time = "2025-10-19T00:43:41.148Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8c/6691d986b728e77b5d2872743ebcd962d37a2d0f7e9ad95a81b284fbf905/cytoolz-1.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36c8ede93525cf11e2cc787b7156e5cecd7340193ef800b816a16f1404a8dc6d", size = 3001173, upload-time = "2025-10-19T00:43:42.923Z" },
{ url = "https://files.pythonhosted.org/packages/7a/cb/f59d83a5058e1198db5a1f04e4a124c94d60390e4fa89b6d2e38ee8288a0/cytoolz-1.1.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c949755b6d8a649c5fbc888bc30915926f1b09fe42fea9f289e297c2f6ddd3", size = 2701374, upload-time = "2025-10-19T00:43:44.716Z" },
{ url = "https://files.pythonhosted.org/packages/b7/f0/1ae6d28df503b0bdae094879da2072b8ba13db5919cd3798918761578411/cytoolz-1.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e1b6d37545816905a76d9ed59fa4e332f929e879f062a39ea0f6f620405cdc27", size = 2953081, upload-time = "2025-10-19T00:43:47.103Z" },
{ url = "https://files.pythonhosted.org/packages/f4/06/d86fe811c6222dc32d3e08f5d88d2be598a6055b4d0590e7c1428d55c386/cytoolz-1.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:05332112d4087904842b36954cd1d3fc0e463a2f4a7ef9477bd241427c593c3b", size = 2862228, upload-time = "2025-10-19T00:43:49.353Z" },
{ url = "https://files.pythonhosted.org/packages/ae/32/978ef6f42623be44a0a03ae9de875ab54aa26c7e38c5c4cd505460b0927d/cytoolz-1.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:31538ca2fad2d688cbd962ccc3f1da847329e2258a52940f10a2ac0719e526be", size = 2861971, upload-time = "2025-10-19T00:43:51.028Z" },
{ url = "https://files.pythonhosted.org/packages/ee/f7/74c69497e756b752b359925d1feef68b91df024a4124a823740f675dacd3/cytoolz-1.1.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:747562aa70abf219ea16f07d50ac0157db856d447f7f498f592e097cbc77df0b", size = 2975304, upload-time = "2025-10-19T00:43:52.99Z" },
{ url = "https://files.pythonhosted.org/packages/5b/2b/3ce0e6889a6491f3418ad4d84ae407b8456b02169a5a1f87990dbba7433b/cytoolz-1.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:3dc15c48b20c0f467e15e341e102896c8422dccf8efc6322def5c1b02f074629", size = 2697371, upload-time = "2025-10-19T00:43:55.312Z" },
{ url = "https://files.pythonhosted.org/packages/15/87/c616577f0891d97860643c845f7221e95240aa589586de727e28a5eb6e52/cytoolz-1.1.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3c03137ee6103ba92d5d6ad6a510e86fded69cd67050bd8a1843f15283be17ac", size = 2992436, upload-time = "2025-10-19T00:43:57.253Z" },
{ url = "https://files.pythonhosted.org/packages/e7/9f/490c81bffb3428ab1fa114051fbb5ba18aaa2e2fe4da5bf4170ca524e6b3/cytoolz-1.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:be8e298d88f88bd172b59912240558be3b7a04959375646e7fd4996401452941", size = 2917612, upload-time = "2025-10-19T00:43:59.423Z" },
{ url = "https://files.pythonhosted.org/packages/66/35/0fec2769660ca6472bbf3317ab634675827bb706d193e3240aaf20eab961/cytoolz-1.1.0-cp314-cp314t-win32.whl", hash = "sha256:3d407140f5604a89578285d4aac7b18b8eafa055cf776e781aabb89c48738fad", size = 960842, upload-time = "2025-10-19T00:44:01.143Z" },
{ url = "https://files.pythonhosted.org/packages/46/b4/b7ce3d3cd20337becfec978ecfa6d0ef64884d0cf32d44edfed8700914b9/cytoolz-1.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:56e5afb69eb6e1b3ffc34716ee5f92ffbdb5cb003b3a5ca4d4b0fe700e217162", size = 1020835, upload-time = "2025-10-19T00:44:03.246Z" },
{ url = "https://files.pythonhosted.org/packages/2c/1f/0498009aa563a9c5d04f520aadc6e1c0942434d089d0b2f51ea986470f55/cytoolz-1.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:27b19b4a286b3ff52040efa42dbe403730aebe5fdfd2def704eb285e2125c63e", size = 927963, upload-time = "2025-10-19T00:44:04.85Z" },
{ url = "https://files.pythonhosted.org/packages/84/32/0522207170294cf691112a93c70a8ef942f60fa9ff8e793b63b1f09cedc0/cytoolz-1.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f32e93a55681d782fc6af939f6df36509d65122423cbc930be39b141064adff8", size = 922014, upload-time = "2025-10-19T00:44:44.911Z" },
{ url = "https://files.pythonhosted.org/packages/4c/49/9be2d24adaa18fa307ff14e3e43f02b2ae4b69c4ce51cee6889eb2114990/cytoolz-1.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5d9bc596751cbda8073e65be02ca11706f00029768fbbbc81e11a8c290bb41aa", size = 918134, upload-time = "2025-10-19T00:44:47.122Z" },
{ url = "https://files.pythonhosted.org/packages/5c/b3/6a76c3b94c6c87c72ea822e7e67405be6b649c2e37778eeac7c0c0c69de8/cytoolz-1.1.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b16660d01c3931951fab49db422c627897c38c1a1f0393a97582004019a4887", size = 981970, upload-time = "2025-10-19T00:44:48.906Z" },
{ url = "https://files.pythonhosted.org/packages/f6/8a/606e4c7ed14aa6a86aee6ca84a2cb804754dc6c4905b8f94e09e49f1ce60/cytoolz-1.1.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b7de5718e2113d4efccea3f06055758cdbc17388ecc3341ba4d1d812837d7c1a", size = 978877, upload-time = "2025-10-19T00:44:50.819Z" },
{ url = "https://files.pythonhosted.org/packages/97/ec/ad474dcb1f6c1ebfdda3c2ad2edbb1af122a0e79c9ff2cb901ffb5f59662/cytoolz-1.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a12a2a1a6bc44099491c05a12039efa08cc33a3d0f8c7b0566185e085e139283", size = 964279, upload-time = "2025-10-19T00:44:52.476Z" },
{ url = "https://files.pythonhosted.org/packages/68/8c/d245fd416c69d27d51f14d5ad62acc4ee5971088ee31c40ffe1cc109af68/cytoolz-1.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:047defa7f5f9a32f82373dbc3957289562e8a3fa58ae02ec8e4dca4f43a33a21", size = 916630, upload-time = "2025-10-19T00:44:54.059Z" },
]
[[package]]
name = "datasets"
version = "4.8.4"
@@ -1133,6 +1471,124 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/97/a8/c070e1340636acb38d4e6a7e45c46d168a462b48b9b3257e14ca0e5af79b/environs-14.6.0-py3-none-any.whl", hash = "sha256:f8fb3d6c6a55872b0c6db077a28f5a8c7b8984b7c32029613d44cef95cfc0812", size = 17205, upload-time = "2026-02-20T04:02:07.299Z" },
]
[[package]]
name = "eth-abi"
version = "5.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "eth-typing" },
{ name = "eth-utils" },
{ name = "parsimonious" },
]
sdist = { url = "https://files.pythonhosted.org/packages/00/71/d9e1380bd77fd22f98b534699af564f189b56d539cc2b9dab908d4e4c242/eth_abi-5.2.0.tar.gz", hash = "sha256:178703fa98c07d8eecd5ae569e7e8d159e493ebb6eeb534a8fe973fbc4e40ef0", size = 49797, upload-time = "2025-01-14T16:29:34.629Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/b4/2f3982c4cbcbf5eeb6aec62df1533c0e63c653b3021ff338d44944405676/eth_abi-5.2.0-py3-none-any.whl", hash = "sha256:17abe47560ad753f18054f5b3089fcb588f3e3a092136a416b6c1502cb7e8877", size = 28511, upload-time = "2025-01-14T16:29:31.862Z" },
]
[[package]]
name = "eth-account"
version = "0.13.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "bitarray" },
{ name = "ckzg" },
{ name = "eth-abi" },
{ name = "eth-keyfile" },
{ name = "eth-keys" },
{ name = "eth-rlp" },
{ name = "eth-utils" },
{ name = "hexbytes" },
{ name = "pydantic" },
{ name = "rlp" },
]
sdist = { url = "https://files.pythonhosted.org/packages/74/cf/20f76a29be97339c969fd765f1237154286a565a1d61be98e76bb7af946a/eth_account-0.13.7.tar.gz", hash = "sha256:5853ecbcbb22e65411176f121f5f24b8afeeaf13492359d254b16d8b18c77a46", size = 935998, upload-time = "2025-04-21T21:11:21.204Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/18/088fb250018cbe665bc2111974301b2d59f294a565aff7564c4df6878da2/eth_account-0.13.7-py3-none-any.whl", hash = "sha256:39727de8c94d004ff61d10da7587509c04d2dc7eac71e04830135300bdfc6d24", size = 587452, upload-time = "2025-04-21T21:11:18.346Z" },
]
[[package]]
name = "eth-hash"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3c/f5/c67fc24f2f676aa9b7ab29679d44f113f314c817207cd4319353356f62da/eth_hash-0.8.0.tar.gz", hash = "sha256:b009752b620da2e9c7668014849d1f5fadbe4f138603f1871cc5d4ca706896b1", size = 12225, upload-time = "2026-03-25T16:36:55.099Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/87/87/b36792150ca0b28e4df683a34be15a61461ca0e349e5b5cf3ec8f694edb9/eth_hash-0.8.0-py3-none-any.whl", hash = "sha256:523718a51b369ab89866b929a5c93c52978cd866ea309192ad980dd8271f9fac", size = 7965, upload-time = "2026-03-25T16:36:54.205Z" },
]
[package.optional-dependencies]
pycryptodome = [
{ name = "pycryptodome" },
]
[[package]]
name = "eth-keyfile"
version = "0.8.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "eth-keys" },
{ name = "eth-utils" },
{ name = "pycryptodome" },
]
sdist = { url = "https://files.pythonhosted.org/packages/35/66/dd823b1537befefbbff602e2ada88f1477c5b40ec3731e3d9bc676c5f716/eth_keyfile-0.8.1.tar.gz", hash = "sha256:9708bc31f386b52cca0969238ff35b1ac72bd7a7186f2a84b86110d3c973bec1", size = 12267, upload-time = "2024-04-23T20:28:53.862Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/fc/48a586175f847dd9e05e5b8994d2fe8336098781ec2e9836a2ad94280281/eth_keyfile-0.8.1-py3-none-any.whl", hash = "sha256:65387378b82fe7e86d7cb9f8d98e6d639142661b2f6f490629da09fddbef6d64", size = 7510, upload-time = "2024-04-23T20:28:51.063Z" },
]
[[package]]
name = "eth-keys"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "eth-typing" },
{ name = "eth-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/58/11/1ed831c50bd74f57829aa06e58bd82a809c37e070ee501c953b9ac1f1552/eth_keys-0.7.0.tar.gz", hash = "sha256:79d24fd876201df67741de3e3fefb3f4dbcbb6ace66e47e6fe662851a4547814", size = 30166, upload-time = "2025-04-07T17:40:21.697Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4d/25/0ae00f2b0095e559d61ad3dc32171bd5a29dfd95ab04b4edd641f7c75f72/eth_keys-0.7.0-py3-none-any.whl", hash = "sha256:b0cdda8ffe8e5ba69c7c5ca33f153828edcace844f67aabd4542d7de38b159cf", size = 20656, upload-time = "2025-04-07T17:40:20.441Z" },
]
[[package]]
name = "eth-rlp"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "eth-utils" },
{ name = "hexbytes" },
{ name = "rlp" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7f/ea/ad39d001fa9fed07fad66edb00af701e29b48be0ed44a3bcf58cb3adf130/eth_rlp-2.2.0.tar.gz", hash = "sha256:5e4b2eb1b8213e303d6a232dfe35ab8c29e2d3051b86e8d359def80cd21db83d", size = 7720, upload-time = "2025-02-04T21:51:08.134Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/3b/57efe2bc2df0980680d57c01a36516cd3171d2319ceb30e675de19fc2cc5/eth_rlp-2.2.0-py3-none-any.whl", hash = "sha256:5692d595a741fbaef1203db6a2fedffbd2506d31455a6ad378c8449ee5985c47", size = 4446, upload-time = "2025-02-04T21:51:05.823Z" },
]
[[package]]
name = "eth-typing"
version = "6.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/37/e7/06c5af99ad40494f6d10126a9030ff4eb14c5b773f2a4076017efb0a163a/eth_typing-6.0.0.tar.gz", hash = "sha256:315dd460dc0b71c15a6cd51e3c0b70d237eec8771beb844144f3a1fb4adb2392", size = 21852, upload-time = "2026-03-25T16:41:57.444Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/0d/e756622fab29f404d846d7464f929d642a7ee6eff5b38bcc79e7c64ac630/eth_typing-6.0.0-py3-none-any.whl", hash = "sha256:ee74fb641eb36dd885e1c42c2a3055314efa532b3e71480816df70a94d35cfb9", size = 19191, upload-time = "2026-03-25T16:41:55.544Z" },
]
[[package]]
name = "eth-utils"
version = "6.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cytoolz", marker = "implementation_name == 'cpython'" },
{ name = "eth-hash" },
{ name = "eth-typing" },
{ name = "pydantic" },
{ name = "toolz", marker = "implementation_name == 'pypy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/1b/0b8548da7b31eba87ed58bca1d0de5dcb13a6c113e02c09019ec5a6716ed/eth_utils-6.0.0.tar.gz", hash = "sha256:eb54b2f82dd300d3142c49a89da195e823f5e5284d43203593f87c67bad92a96", size = 123457, upload-time = "2026-03-25T17:11:51.433Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/45/a20b907227b9d1aea2e36f7b12818d055629ca9bc65fc282b45738f28ca3/eth_utils-6.0.0-py3-none-any.whl", hash = "sha256:63cf48ee32c45541cb5748751909a8345c470432fb6f0fed4bd7c53fd6400469", size = 102473, upload-time = "2026-03-25T17:11:49.953Z" },
]
[[package]]
name = "execnet"
version = "2.1.2"
@@ -1510,6 +1966,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" },
{ url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" },
{ url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" },
{ url = "https://files.pythonhosted.org/packages/9c/8b/1430a04657735a3f23116c2e0d5eb10220928846e4537a938a41b350bed6/greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2", size = 605046, upload-time = "2026-02-20T21:02:45.234Z" },
{ url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" },
{ url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" },
{ url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" },
@@ -1518,6 +1975,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" },
{ url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" },
{ url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" },
{ url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" },
{ url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" },
{ url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" },
{ url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" },
@@ -1526,6 +1984,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" },
{ url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" },
{ url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" },
{ url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" },
{ url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" },
{ url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" },
{ url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" },
@@ -1534,6 +1993,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" },
{ url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" },
{ url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" },
{ url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" },
{ url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" },
{ url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" },
@@ -1542,6 +2002,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" },
{ url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" },
{ url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" },
{ url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" },
{ url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" },
{ url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" },
{ url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" },
@@ -1630,16 +2091,19 @@ acp = [
all = [
{ name = "agent-client-protocol" },
{ name = "aiohttp" },
{ name = "argon2-cffi" },
{ name = "croniter" },
{ name = "daytona" },
{ name = "dingtalk-stream" },
{ name = "discord-py", extra = ["voice"] },
{ name = "elevenlabs" },
{ name = "honcho-ai" },
{ name = "keyring" },
{ name = "mcp" },
{ name = "modal" },
{ name = "numpy" },
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
{ name = "pynacl" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-xdist" },
@@ -1674,6 +2138,11 @@ homeassistant = [
honcho = [
{ name = "honcho-ai" },
]
keystore = [
{ name = "argon2-cffi" },
{ name = "keyring" },
{ name = "pynacl" },
]
matrix = [
{ name = "matrix-nio", extra = ["e2e"] },
]
@@ -1715,6 +2184,20 @@ voice = [
{ name = "numpy" },
{ name = "sounddevice" },
]
wallet = [
{ name = "argon2-cffi" },
{ name = "eth-account" },
{ name = "keyring" },
{ name = "pynacl" },
{ name = "web3" },
]
wallet-solana = [
{ name = "argon2-cffi" },
{ name = "keyring" },
{ name = "pynacl" },
{ name = "solana" },
{ name = "solders" },
]
yc-bench = [
{ name = "yc-bench", marker = "python_full_version >= '3.12'" },
]
@@ -1726,6 +2209,7 @@ requires-dist = [
{ name = "aiohttp", marker = "extra == 'messaging'", specifier = ">=3.13.3,<4" },
{ name = "aiohttp", marker = "extra == 'sms'", specifier = ">=3.9.0,<4" },
{ name = "anthropic", specifier = ">=0.39.0,<1" },
{ name = "argon2-cffi", marker = "extra == 'keystore'", specifier = ">=23.0,<24" },
{ name = "atroposlib", marker = "extra == 'rl'", git = "https://github.com/NousResearch/atropos.git" },
{ name = "croniter", marker = "extra == 'cron'", specifier = ">=6.0.0,<7" },
{ name = "daytona", marker = "extra == 'daytona'", specifier = ">=0.148.0,<1" },
@@ -1733,6 +2217,7 @@ requires-dist = [
{ name = "discord-py", extras = ["voice"], marker = "extra == 'messaging'", specifier = ">=2.7.1,<3" },
{ name = "edge-tts", specifier = ">=7.2.7,<8" },
{ name = "elevenlabs", marker = "extra == 'tts-premium'", specifier = ">=1.0,<2" },
{ name = "eth-account", marker = "extra == 'wallet'", specifier = ">=0.13.0,<1" },
{ name = "fal-client", specifier = ">=0.13.1,<1" },
{ name = "fastapi", marker = "extra == 'rl'", specifier = ">=0.104.0,<1" },
{ name = "faster-whisper", specifier = ">=1.0.0,<2" },
@@ -1746,6 +2231,9 @@ requires-dist = [
{ name = "hermes-agent", extras = ["dingtalk"], marker = "extra == 'all'" },
{ name = "hermes-agent", extras = ["homeassistant"], marker = "extra == 'all'" },
{ name = "hermes-agent", extras = ["honcho"], marker = "extra == 'all'" },
{ name = "hermes-agent", extras = ["keystore"], marker = "extra == 'all'" },
{ name = "hermes-agent", extras = ["keystore"], marker = "extra == 'wallet'" },
{ name = "hermes-agent", extras = ["keystore"], marker = "extra == 'wallet-solana'" },
{ name = "hermes-agent", extras = ["mcp"], marker = "extra == 'all'" },
{ name = "hermes-agent", extras = ["messaging"], marker = "extra == 'all'" },
{ name = "hermes-agent", extras = ["modal"], marker = "extra == 'all'" },
@@ -1757,6 +2245,7 @@ requires-dist = [
{ name = "honcho-ai", marker = "extra == 'honcho'", specifier = ">=2.0.1,<3" },
{ name = "httpx", specifier = ">=0.28.1,<1" },
{ name = "jinja2", specifier = ">=3.1.5,<4" },
{ name = "keyring", marker = "extra == 'keystore'", specifier = ">=25.0,<26" },
{ name = "matrix-nio", extras = ["e2e"], marker = "extra == 'matrix'", specifier = ">=0.24.0,<1" },
{ name = "mcp", marker = "extra == 'dev'", specifier = ">=1.2.0,<2" },
{ name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.2.0,<2" },
@@ -1768,6 +2257,7 @@ requires-dist = [
{ name = "ptyprocess", marker = "sys_platform != 'win32' and extra == 'pty'", specifier = ">=0.7.0,<1" },
{ name = "pydantic", specifier = ">=2.12.5,<3" },
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.0,<3" },
{ name = "pynacl", marker = "extra == 'keystore'", specifier = ">=1.5.0,<2" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2,<10" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.3.0,<2" },
{ name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.0,<4" },
@@ -1782,14 +2272,26 @@ requires-dist = [
{ name = "slack-bolt", marker = "extra == 'slack'", specifier = ">=1.18.0,<2" },
{ name = "slack-sdk", marker = "extra == 'messaging'", specifier = ">=3.27.0,<4" },
{ name = "slack-sdk", marker = "extra == 'slack'", specifier = ">=3.27.0,<4" },
{ name = "solana", marker = "extra == 'wallet-solana'", specifier = ">=0.36,<1" },
{ name = "solders", marker = "extra == 'wallet-solana'", specifier = ">=0.21,<1" },
{ name = "sounddevice", marker = "extra == 'voice'", specifier = ">=0.4.6,<1" },
{ name = "tenacity", specifier = ">=9.1.4,<10" },
{ name = "tinker", marker = "extra == 'rl'", git = "https://github.com/thinking-machines-lab/tinker.git" },
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'rl'", specifier = ">=0.24.0,<1" },
{ name = "wandb", marker = "extra == 'rl'", specifier = ">=0.15.0,<1" },
{ name = "web3", marker = "extra == 'wallet'", specifier = ">=7.0,<8" },
{ name = "yc-bench", marker = "python_full_version >= '3.12' and extra == 'yc-bench'", git = "https://github.com/collinear-ai/yc-bench.git" },
]
provides-extras = ["modal", "daytona", "dev", "messaging", "cron", "slack", "matrix", "cli", "tts-premium", "voice", "pty", "honcho", "mcp", "homeassistant", "sms", "acp", "dingtalk", "rl", "yc-bench", "all"]
provides-extras = ["modal", "daytona", "dev", "messaging", "cron", "slack", "matrix", "cli", "tts-premium", "voice", "pty", "honcho", "mcp", "homeassistant", "sms", "acp", "dingtalk", "rl", "yc-bench", "keystore", "wallet", "wallet-solana", "all"]
[[package]]
name = "hexbytes"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7f/87/adf4635b4b8c050283d74e6db9a81496063229c9263e6acc1903ab79fbec/hexbytes-1.3.1.tar.gz", hash = "sha256:a657eebebdfe27254336f98d8af6e2236f3f83aed164b87466b6cf6c5f5a4765", size = 8633, upload-time = "2025-05-14T16:45:17.5Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/e0/3b31492b1c89da3c5a846680517871455b30c54738486fc57ac79a5761bd/hexbytes-1.3.1-py3-none-any.whl", hash = "sha256:da01ff24a1a9a2b1881c4b85f0e9f9b0f51b526b379ffa23832ae7899d29c2c7", size = 5074, upload-time = "2025-05-14T16:45:16.179Z" },
]
[[package]]
name = "hf-transfer"
@@ -2016,6 +2518,51 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jaraco-classes"
version = "3.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "more-itertools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" },
]
[[package]]
name = "jaraco-context"
version = "6.1.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backports-tarfile", marker = "python_full_version < '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" },
]
[[package]]
name = "jaraco-functools"
version = "4.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "more-itertools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" },
]
[[package]]
name = "jeepney"
version = "0.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
@@ -2122,6 +2669,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" },
]
[[package]]
name = "jsonalias"
version = "0.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ec/45/ee7e17002cb7f3264f755ff6a1a72c55d1830e07808d643167d2a2277c4f/jsonalias-0.1.1.tar.gz", hash = "sha256:64f04d935397d579fc94509e1fcb6212f2d081235d9d6395bd10baedf760a769", size = 1095, upload-time = "2022-10-28T22:57:56.224Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/41/ed/05aebce69f78c104feff2ffcdd5a6f9d668a208aba3a8bf56e3750809fd8/jsonalias-0.1.1-py3-none-any.whl", hash = "sha256:a56d2888e6397812c606156504e861e8ec00e188005af149f003c787db3d3f18", size = 1312, upload-time = "2022-10-28T22:57:54.763Z" },
]
[[package]]
name = "jsonlines"
version = "4.0.0"
@@ -2161,6 +2717,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
]
[[package]]
name = "keyring"
version = "25.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "importlib-metadata", marker = "python_full_version < '3.12'" },
{ name = "jaraco-classes" },
{ name = "jaraco-context" },
{ name = "jaraco-functools" },
{ name = "jeepney", marker = "sys_platform == 'linux'" },
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
{ name = "secretstorage", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" },
]
[[package]]
name = "kiwisolver"
version = "1.5.0"
@@ -2569,6 +3143,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/aa/f0ffbe6bf679a597e8be692ca3cde47de6156435c2b72cf752fec719bb1f/modal-1.3.4-py3-none-any.whl", hash = "sha256:d66a851969f447936b3512f1c3708435ce1ca81171eeddc3eb0678f594493380", size = 773837, upload-time = "2026-02-23T15:44:03.635Z" },
]
[[package]]
name = "more-itertools"
version = "10.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" },
]
[[package]]
name = "mpmath"
version = "1.3.0"
@@ -3201,6 +3784,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/3e/2218fa29637781b8e7ac35a928108ff2614ddd40879389d3af2caa725af5/parallel_web-0.4.2-py3-none-any.whl", hash = "sha256:aa3a4a9aecc08972c5ce9303271d4917903373dff4dd277d9a3e30f9cff53346", size = 144012, upload-time = "2026-03-09T22:24:33.979Z" },
]
[[package]]
name = "parsimonious"
version = "0.10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "regex" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7b/91/abdc50c4ef06fdf8d047f60ee777ca9b2a7885e1a9cea81343fbecda52d7/parsimonious-0.10.0.tar.gz", hash = "sha256:8281600da180ec8ae35427a4ab4f7b82bfec1e3d1e52f80cb60ea82b9512501c", size = 52172, upload-time = "2022-09-03T17:01:17.004Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/0f/c8b64d9b54ea631fcad4e9e3c8dbe8c11bb32a623be94f22974c88e71eaf/parsimonious-0.10.0-py3-none-any.whl", hash = "sha256:982ab435fabe86519b57f6b35610aa4e4e977e9f02a14353edf4bbc75369fc0f", size = 48427, upload-time = "2022-09-03T17:01:13.814Z" },
]
[[package]]
name = "peewee"
version = "3.19.0"
@@ -3903,6 +4498,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" },
]
[[package]]
name = "pyunormalize"
version = "17.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/25/ab/b912c484cfb96ba4834efe050bbf10c9e157bd8189eb859aefba8712b136/pyunormalize-17.0.0.tar.gz", hash = "sha256:0949a3e56817e287febcaf1b0cc4b5adf0bb107628d379335938040947eec792", size = 53121, upload-time = "2025-09-28T20:53:06.141Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/92/80/61512483dc509e3ae8a42fb143479d1e406ce1d91f8f08d538a3dde39c6d/pyunormalize-17.0.0-py3-none-any.whl", hash = "sha256:f0d93b076f938db2b26d319d04f2b58505d1cd7a80b5b72badbe7d1aa4d2a31c", size = 51358, upload-time = "2025-09-28T20:53:04.876Z" },
]
[[package]]
name = "pywin32"
version = "311"
@@ -3922,6 +4526,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
]
[[package]]
name = "pywin32-ctypes"
version = "0.2.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
]
[[package]]
name = "pywinpty"
version = "2.0.15"
@@ -4135,6 +4748,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
]
[[package]]
name = "rlp"
version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "eth-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1b/2d/439b0728a92964a04d9c88ea1ca9ebb128893fbbd5834faa31f987f2fd4c/rlp-4.1.0.tar.gz", hash = "sha256:be07564270a96f3e225e2c107db263de96b5bc1f27722d2855bd3459a08e95a9", size = 33429, upload-time = "2025-02-04T22:05:59.089Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/fb/e4c0ced9893b84ac95b7181d69a9786ce5879aeb3bbbcbba80a164f85d6a/rlp-4.1.0-py3-none-any.whl", hash = "sha256:8eca394c579bad34ee0b937aecb96a57052ff3716e19c7a578883e767bc5da6f", size = 19973, upload-time = "2025-02-04T22:05:57.05Z" },
]
[[package]]
name = "rpds-py"
version = "0.30.0"
@@ -4265,6 +4890,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload-time = "2025-11-19T15:18:44.427Z" },
]
[[package]]
name = "secretstorage"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "jeepney" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" },
]
[[package]]
name = "sentry-sdk"
version = "2.56.0"
@@ -4353,6 +4991,42 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "solana"
version = "0.36.11"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "construct-typing" },
{ name = "httpx" },
{ name = "solders" },
{ name = "typing-extensions" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/66/b8cd6e4d95bfe46798942ace31935e7799005a4e2180869dc7bac6b75be9/solana-0.36.11.tar.gz", hash = "sha256:2fdcf483674f4b88fe6510524bf3234a5837d19fe1815aa5a285f2739d28b3a3", size = 54516, upload-time = "2026-01-03T02:11:52.243Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/8d/807eebf0560759ad90464060e0d1d87ff5409beb6ed56104c553a83a976a/solana-0.36.11-py3-none-any.whl", hash = "sha256:1d659decc67a40ee1e9b5ded373a076b87cf3b4bd0645e120d16d9348c2025ba", size = 64786, upload-time = "2026-01-03T02:11:50.811Z" },
]
[[package]]
name = "solders"
version = "0.27.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonalias" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/25/80a81bb3dc4c70329dd0016edbdfbf2e8d8300a98ab9cd1a6ea0266bda7c/solders-0.27.1.tar.gz", hash = "sha256:7d8a24ad2f193afcdc02d6f3975917a7358b0f0ab7f4b3695b135ff2008222c8", size = 180923, upload-time = "2025-11-15T07:50:52.32Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/6b/0c0ee4766705824261779d00229fb95308d6b28422613e0e2af577f60ee3/solders-0.27.1-cp38-abi3-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:4dcd8e766bab24afbe9e0ae363d86f9810457e04b00c8a9149f69ca939ed587c", size = 24883435, upload-time = "2025-11-15T07:50:34.42Z" },
{ url = "https://files.pythonhosted.org/packages/33/1c/be04a1b26e18c409dd006d214198dc03f0b657c1cb34f4c83b763f8348f0/solders-0.27.1-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5d87b145cc0129095f9cff8c7f28d2e910bc5b5a4cf257c263b08a4b95f111dd", size = 6480729, upload-time = "2025-11-15T07:50:37.323Z" },
{ url = "https://files.pythonhosted.org/packages/48/03/98dc73c266b11ed5c13b3933510a1aa115becf97f45bec1a22da9d03ffa9/solders-0.27.1-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6082bbe46b7b1b2b005d046011f89fcae75fc5ea4f1a0ef5c2e9dfb5fe7930ce", size = 12744782, upload-time = "2025-11-15T07:50:39.283Z" },
{ url = "https://files.pythonhosted.org/packages/a0/39/35384d8fb80d05937bd9e8af7237cfe3f0d017c8aba357209d90d428f3a0/solders-0.27.1-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ccb821c2e4af43d976f312086f248a67352b3986e5f4c87af41cfeac6d8b5683", size = 6601257, upload-time = "2025-11-15T07:50:41.738Z" },
{ url = "https://files.pythonhosted.org/packages/8c/65/8989e521142473bf1130613476a4449e106bb97ed6cc86097f6f519b1234/solders-0.27.1-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:663a10566ae81f67c4515d4db5fbf51b735204741728c1a5cde11c4e019a51df", size = 7277802, upload-time = "2025-11-15T07:50:43.789Z" },
{ url = "https://files.pythonhosted.org/packages/f2/41/87ecf12cec0e7aa9c67b0cf1b8079fb28aa0af91e97328a3bd0c5e3001ba/solders-0.27.1-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:d14f05a77dbbf7966fb26f255c81302e6127550bdb66c2fdc99f522043fdf376", size = 7082541, upload-time = "2025-11-15T07:50:45.847Z" },
{ url = "https://files.pythonhosted.org/packages/33/b9/35e6f59b41bb205b26c7318fcdca43f3d59464fd3ddc13d36f36427f64d4/solders-0.27.1-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f778eeab411acec0a765a01c7b772f8eca8a8543d98276bd83cb826960da211b", size = 6845568, upload-time = "2025-11-15T07:50:47.698Z" },
{ url = "https://files.pythonhosted.org/packages/9b/f3/14ed12d8d5047ababaca3271f82ebbf500ff74b6358f283962232103a12d/solders-0.27.1-cp38-abi3-win_amd64.whl", hash = "sha256:f3b787c29570a46d219c7a67543d8b0fadc73abda346653aa20e8eccd839e78b", size = 5295092, upload-time = "2025-11-15T07:50:50.517Z" },
]
[[package]]
name = "sounddevice"
version = "0.5.5"
@@ -4634,6 +5308,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" },
]
[[package]]
name = "toolz"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/11/d6/114b492226588d6ff54579d95847662fc69196bdeec318eb45393b24c192/toolz-1.1.0.tar.gz", hash = "sha256:27a5c770d068c110d9ed9323f24f1543e83b2f300a687b7891c1a6d56b697b5b", size = 52613, upload-time = "2025-10-17T04:03:21.661Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl", hash = "sha256:15ccc861ac51c53696de0a5d6d4607f99c210739caf987b5d2054f3efed429d8", size = 58093, upload-time = "2025-10-17T04:03:20.435Z" },
]
[[package]]
name = "tornado"
version = "6.5.5"
@@ -4719,6 +5402,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/63/2463d89481e811f007b0e1cd0a91e52e141b47f9de724d20db7b861dcfec/types_certifi-2021.10.8.3-py3-none-any.whl", hash = "sha256:b2d1e325e69f71f7c78e5943d410e650b4707bb0ef32e4ddf3da37f54176e88a", size = 2136, upload-time = "2022-06-09T15:19:03.127Z" },
]
[[package]]
name = "types-requests"
version = "2.32.4.20260324"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6c/b1/66bafdc85965e5aa3db42e1b9128bf8abe252edd7556d00a07ef437a3e0e/types_requests-2.32.4.20260324.tar.gz", hash = "sha256:33a2a9ccb1de7d4e4da36e347622c35418f6761269014cc32857acabd5df739e", size = 23765, upload-time = "2026-03-24T04:06:35.106Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/5a/ce5999f9bd72c7fac681d26cd0a5782b379053bfc2214e2a3fbe30852c9e/types_requests-2.32.4.20260324-py3-none-any.whl", hash = "sha256:f83ef2deb284fe99a249b8b0b0a3e4b9809e01ff456063c4df0aac7670c07ab9", size = 20735, upload-time = "2026-03-24T04:06:33.9Z" },
]
[[package]]
name = "types-toml"
version = "0.10.8.20240310"
@@ -4981,6 +5676,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" },
]
[[package]]
name = "web3"
version = "7.14.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
{ name = "eth-abi" },
{ name = "eth-account" },
{ name = "eth-hash", extra = ["pycryptodome"] },
{ name = "eth-typing" },
{ name = "eth-utils" },
{ name = "hexbytes" },
{ name = "pydantic" },
{ name = "pyunormalize" },
{ name = "pywin32", marker = "sys_platform == 'win32'" },
{ name = "requests" },
{ name = "types-requests" },
{ name = "typing-extensions" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/26/41/435cb36d36fc5142428292b876d0553d35af95e1582ecb7d8bcb64039d18/web3-7.14.1.tar.gz", hash = "sha256:856dc8517f362aefa75fdc298d975894055565dc866f21279f27fe060b7fb2c3", size = 2208998, upload-time = "2026-02-03T22:56:41.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/d1/862bbf48867685de1a563de20a9bad2b8c5c5678b3f08adc0e06797783f5/web3-7.14.1-py3-none-any.whl", hash = "sha256:bec367ba44261f874662aed9b5e138aa7bb907700a30a7580b2264534e88ce12", size = 1371268, upload-time = "2026-02-03T22:56:36.577Z" },
]
[[package]]
name = "websockets"
version = "15.0.1"

20
wallet/__init__.py Normal file
View File

@@ -0,0 +1,20 @@
"""hermes-wallet — crypto wallet for Hermes Agent.
Built on top of hermes-keystore. Private keys are stored as sealed
secrets — the agent never has direct access. Transactions go through
a policy engine and optional owner approval.
Scope (v1):
- Wallet creation, import, listing
- Native token transfers (ETH, SOL)
- Balance checks
- Transaction history (local log)
- Policy engine (spending limits, rate limits, approval thresholds)
- CLI + gateway approval flow for high-value transactions
Out of scope (v1):
- Smart contracts / DeFi / swaps
- ERC-20 / SPL token transfers
- Hardware wallets
- Multi-sig
"""

132
wallet/approval.py Normal file
View File

@@ -0,0 +1,132 @@
"""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}"})

102
wallet/chains/__init__.py Normal file
View File

@@ -0,0 +1,102 @@
"""Abstract chain provider interface.
Each blockchain (EVM, Solana, etc.) implements this interface.
The wallet manager dispatches through it without knowing chain specifics.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from decimal import Decimal
from typing import List, Optional
@dataclass
class Balance:
"""Native token balance for a wallet."""
chain: str
address: str
balance: Decimal # In native units (ETH, SOL)
balance_raw: int # In smallest unit (wei, lamports)
symbol: str # "ETH", "SOL"
decimals: int # 18 for ETH, 9 for SOL
@dataclass
class TransactionResult:
"""Result of a submitted transaction."""
tx_hash: str
chain: str
status: str # "submitted" | "confirmed" | "failed"
explorer_url: str = ""
gas_used: Optional[int] = None
error: Optional[str] = None
@dataclass
class GasEstimate:
"""Gas/fee estimate for a transaction."""
chain: str
estimated_fee: Decimal # In native units
estimated_fee_raw: int # In smallest unit
symbol: str
@dataclass
class ChainConfig:
"""Configuration for a blockchain."""
chain_id: str # "ethereum", "base", "solana", etc.
display_name: str
symbol: str # Native token symbol
decimals: int
rpc_url: str
explorer_url: str # Base URL for tx explorer
is_testnet: bool = False
class ChainProvider(ABC):
"""Abstract interface for blockchain interaction."""
def __init__(self, config: ChainConfig):
self.config = config
@property
def chain_id(self) -> str:
return self.config.chain_id
@abstractmethod
def get_balance(self, address: str) -> Balance:
"""Get native token balance for an address."""
...
@abstractmethod
def send_transaction(
self,
from_private_key: str,
to_address: str,
amount: Decimal,
) -> TransactionResult:
"""Sign and broadcast a native token transfer.
Args:
from_private_key: Hex-encoded private key (provided by daemon, never by agent)
to_address: Recipient address
amount: Amount in native units (ETH, SOL)
Returns:
TransactionResult with tx hash and status
"""
...
@abstractmethod
def estimate_fee(self, from_address: str, to_address: str, amount: Decimal) -> GasEstimate:
"""Estimate transaction fee."""
...
@abstractmethod
def validate_address(self, address: str) -> bool:
"""Check if an address is valid for this chain."""
...
def explorer_tx_url(self, tx_hash: str) -> str:
"""Return the block explorer URL for a transaction."""
return f"{self.config.explorer_url}/tx/{tx_hash}"

241
wallet/chains/evm.py Normal file
View File

@@ -0,0 +1,241 @@
"""EVM chain provider — Ethereum, Base, Polygon, Arbitrum, etc.
Uses eth-account for key management/signing and web3.py for RPC.
All EVM chains share this single provider with different ChainConfig.
"""
import logging
from decimal import Decimal
from typing import Optional
from wallet.chains import (
Balance,
ChainConfig,
ChainProvider,
GasEstimate,
TransactionResult,
)
logger = logging.getLogger(__name__)
try:
from eth_account import Account
from web3 import Web3
_WEB3_AVAILABLE = True
except ImportError:
_WEB3_AVAILABLE = False
# ---------------------------------------------------------------------------
# Pre-built chain configs
# ---------------------------------------------------------------------------
EVM_CHAINS = {
"ethereum": ChainConfig(
chain_id="ethereum",
display_name="Ethereum Mainnet",
symbol="ETH",
decimals=18,
rpc_url="https://eth.llamarpc.com",
explorer_url="https://etherscan.io",
),
"ethereum-sepolia": ChainConfig(
chain_id="ethereum-sepolia",
display_name="Ethereum Sepolia (Testnet)",
symbol="ETH",
decimals=18,
rpc_url="https://rpc.sepolia.org",
explorer_url="https://sepolia.etherscan.io",
is_testnet=True,
),
"base": ChainConfig(
chain_id="base",
display_name="Base",
symbol="ETH",
decimals=18,
rpc_url="https://mainnet.base.org",
explorer_url="https://basescan.org",
),
"base-sepolia": ChainConfig(
chain_id="base-sepolia",
display_name="Base Sepolia (Testnet)",
symbol="ETH",
decimals=18,
rpc_url="https://sepolia.base.org",
explorer_url="https://sepolia.basescan.org",
is_testnet=True,
),
"polygon": ChainConfig(
chain_id="polygon",
display_name="Polygon",
symbol="POL",
decimals=18,
rpc_url="https://polygon-rpc.com",
explorer_url="https://polygonscan.com",
),
"arbitrum": ChainConfig(
chain_id="arbitrum",
display_name="Arbitrum One",
symbol="ETH",
decimals=18,
rpc_url="https://arb1.arbitrum.io/rpc",
explorer_url="https://arbiscan.io",
),
"optimism": ChainConfig(
chain_id="optimism",
display_name="Optimism",
symbol="ETH",
decimals=18,
rpc_url="https://mainnet.optimism.io",
explorer_url="https://optimistic.etherscan.io",
),
}
# EVM chain IDs (for transaction signing)
_CHAIN_IDS = {
"ethereum": 1,
"ethereum-sepolia": 11155111,
"base": 8453,
"base-sepolia": 84532,
"polygon": 137,
"arbitrum": 42161,
"optimism": 10,
}
class EVMProvider(ChainProvider):
"""Provider for all EVM-compatible chains."""
def __init__(self, config: ChainConfig, rpc_url_override: str = ""):
if not _WEB3_AVAILABLE:
raise ImportError(
"web3 and eth-account are required for EVM wallet support. "
"Install with: pip install 'hermes-agent[wallet]'"
)
super().__init__(config)
url = rpc_url_override or config.rpc_url
self._w3 = Web3(Web3.HTTPProvider(url))
self._evm_chain_id = _CHAIN_IDS.get(config.chain_id)
def get_balance(self, address: str) -> Balance:
checksum = Web3.to_checksum_address(address)
balance_wei = self._w3.eth.get_balance(checksum)
balance_eth = Decimal(balance_wei) / Decimal(10 ** self.config.decimals)
return Balance(
chain=self.config.chain_id,
address=address,
balance=balance_eth,
balance_raw=balance_wei,
symbol=self.config.symbol,
decimals=self.config.decimals,
)
def send_transaction(
self,
from_private_key: str,
to_address: str,
amount: Decimal,
) -> TransactionResult:
account = Account.from_key(from_private_key)
to_checksum = Web3.to_checksum_address(to_address)
amount_wei = int(amount * Decimal(10 ** self.config.decimals))
try:
nonce = self._w3.eth.get_transaction_count(account.address)
# Build transaction
tx = {
"to": to_checksum,
"value": amount_wei,
"nonce": nonce,
"chainId": self._evm_chain_id,
}
# Use EIP-1559 if supported, otherwise legacy
try:
latest = self._w3.eth.get_block("latest")
if hasattr(latest, "baseFeePerGas") and latest.baseFeePerGas is not None:
# EIP-1559
max_priority = self._w3.eth.max_priority_fee
base_fee = latest.baseFeePerGas
tx["maxFeePerGas"] = base_fee * 2 + max_priority
tx["maxPriorityFeePerGas"] = max_priority
else:
tx["gasPrice"] = self._w3.eth.gas_price
except Exception:
tx["gasPrice"] = self._w3.eth.gas_price
# Estimate gas
tx["gas"] = self._w3.eth.estimate_gas(tx)
# Sign and send
signed = self._w3.eth.account.sign_transaction(tx, from_private_key)
tx_hash = self._w3.eth.send_raw_transaction(signed.raw_transaction)
tx_hash_hex = tx_hash.hex()
logger.info("Transaction sent: %s on %s", tx_hash_hex, self.config.chain_id)
return TransactionResult(
tx_hash=tx_hash_hex,
chain=self.config.chain_id,
status="submitted",
explorer_url=self.explorer_tx_url(tx_hash_hex),
)
except Exception as e:
logger.error("Transaction failed on %s: %s", self.config.chain_id, e)
return TransactionResult(
tx_hash="",
chain=self.config.chain_id,
status="failed",
error=str(e),
)
def estimate_fee(self, from_address: str, to_address: str, amount: Decimal) -> GasEstimate:
from_checksum = Web3.to_checksum_address(from_address)
to_checksum = Web3.to_checksum_address(to_address)
amount_wei = int(amount * Decimal(10 ** self.config.decimals))
try:
gas_limit = self._w3.eth.estimate_gas({
"from": from_checksum,
"to": to_checksum,
"value": amount_wei,
})
gas_price = self._w3.eth.gas_price
fee_wei = gas_limit * gas_price
fee_eth = Decimal(fee_wei) / Decimal(10 ** self.config.decimals)
return GasEstimate(
chain=self.config.chain_id,
estimated_fee=fee_eth,
estimated_fee_raw=fee_wei,
symbol=self.config.symbol,
)
except Exception as e:
# Return a rough estimate on failure
rough_fee = Decimal("0.0005") # ~21000 gas * ~24 gwei
return GasEstimate(
chain=self.config.chain_id,
estimated_fee=rough_fee,
estimated_fee_raw=int(rough_fee * Decimal(10 ** 18)),
symbol=self.config.symbol,
)
def validate_address(self, address: str) -> bool:
try:
Web3.to_checksum_address(address)
return True
except (ValueError, Exception):
return False
def generate_keypair(self) -> tuple[str, str]:
"""Generate a new EVM keypair. Returns (address, private_key_hex)."""
account = Account.create()
return account.address, account.key.hex()
@staticmethod
def address_from_key(private_key: str) -> str:
"""Derive address from a private key."""
account = Account.from_key(private_key)
return account.address

177
wallet/chains/solana.py Normal file
View File

@@ -0,0 +1,177 @@
"""Solana chain provider.
Uses solders for key management/signing and solana-py for RPC.
"""
import logging
from decimal import Decimal
from typing import Optional
from wallet.chains import (
Balance,
ChainConfig,
ChainProvider,
GasEstimate,
TransactionResult,
)
logger = logging.getLogger(__name__)
try:
from solders.keypair import Keypair
from solders.pubkey import Pubkey
from solders.system_program import transfer, TransferParams
from solders.transaction import Transaction
from solders.message import Message
from solders.hash import Hash as SolHash
from solana.rpc.api import Client as SolanaClient
from solana.rpc.commitment import Confirmed
_SOLANA_AVAILABLE = True
except ImportError:
_SOLANA_AVAILABLE = False
SOLANA_CHAINS = {
"solana": ChainConfig(
chain_id="solana",
display_name="Solana Mainnet",
symbol="SOL",
decimals=9,
rpc_url="https://api.mainnet-beta.solana.com",
explorer_url="https://explorer.solana.com",
),
"solana-devnet": ChainConfig(
chain_id="solana-devnet",
display_name="Solana Devnet (Testnet)",
symbol="SOL",
decimals=9,
rpc_url="https://api.devnet.solana.com",
explorer_url="https://explorer.solana.com",
is_testnet=True,
),
}
_LAMPORTS_PER_SOL = 1_000_000_000
class SolanaProvider(ChainProvider):
"""Provider for Solana."""
def __init__(self, config: ChainConfig, rpc_url_override: str = ""):
if not _SOLANA_AVAILABLE:
raise ImportError(
"solders and solana are required for Solana wallet support. "
"Install with: pip install 'hermes-agent[wallet-solana]'"
)
super().__init__(config)
url = rpc_url_override or config.rpc_url
self._client = SolanaClient(url)
def get_balance(self, address: str) -> Balance:
pubkey = Pubkey.from_string(address)
resp = self._client.get_balance(pubkey, commitment=Confirmed)
lamports = resp.value
sol = Decimal(lamports) / Decimal(_LAMPORTS_PER_SOL)
return Balance(
chain=self.config.chain_id,
address=address,
balance=sol,
balance_raw=lamports,
symbol="SOL",
decimals=9,
)
def send_transaction(
self,
from_private_key: str,
to_address: str,
amount: Decimal,
) -> TransactionResult:
try:
# Parse keypair — stored as hex-encoded 64-byte keypair (secret + public)
key_bytes = bytes.fromhex(from_private_key)
keypair = Keypair.from_bytes(key_bytes)
to_pubkey = Pubkey.from_string(to_address)
lamports = int(amount * Decimal(_LAMPORTS_PER_SOL))
# Get recent blockhash — use Finalized for reliability on devnet
from solana.rpc.commitment import Finalized
blockhash_resp = self._client.get_latest_blockhash(commitment=Finalized)
recent_blockhash = blockhash_resp.value.blockhash
# Build transfer instruction
ix = transfer(TransferParams(
from_pubkey=keypair.pubkey(),
to_pubkey=to_pubkey,
lamports=lamports,
))
# Build and sign transaction
msg = Message.new_with_blockhash([ix], keypair.pubkey(), recent_blockhash)
tx = Transaction.new_unsigned(msg)
tx.sign([keypair], recent_blockhash)
# Send
resp = self._client.send_transaction(tx)
tx_hash = str(resp.value)
cluster_param = ""
if self.config.is_testnet:
cluster_param = "?cluster=devnet"
logger.info("Solana transaction sent: %s", tx_hash)
return TransactionResult(
tx_hash=tx_hash,
chain=self.config.chain_id,
status="submitted",
explorer_url=f"{self.config.explorer_url}/tx/{tx_hash}{cluster_param}",
)
except Exception as e:
logger.error("Solana transaction failed: %s", e)
return TransactionResult(
tx_hash="",
chain=self.config.chain_id,
status="failed",
error=str(e),
)
def estimate_fee(self, from_address: str, to_address: str, amount: Decimal) -> GasEstimate:
# Solana has a flat base fee of 5000 lamports per signature
# Priority fees are optional and variable
fee_lamports = 5000
fee_sol = Decimal(fee_lamports) / Decimal(_LAMPORTS_PER_SOL)
return GasEstimate(
chain=self.config.chain_id,
estimated_fee=fee_sol,
estimated_fee_raw=fee_lamports,
symbol="SOL",
)
def validate_address(self, address: str) -> bool:
try:
Pubkey.from_string(address)
return True
except (ValueError, Exception):
return False
def generate_keypair(self) -> tuple[str, str]:
"""Generate a new Solana keypair. Returns (address, private_key_hex).
Stores the full 64-byte keypair (secret + public) because
solders.Keypair.from_bytes() requires it.
"""
kp = Keypair()
return str(kp.pubkey()), bytes(kp).hex()
@staticmethod
def address_from_key(private_key: str) -> str:
"""Derive address from a private key."""
key_bytes = bytes.fromhex(private_key)
kp = Keypair.from_bytes(key_bytes)
return str(kp.pubkey())
def explorer_tx_url(self, tx_hash: str) -> str:
cluster_param = ""
if self.config.is_testnet:
cluster_param = "?cluster=devnet"
return f"{self.config.explorer_url}/tx/{tx_hash}{cluster_param}"

486
wallet/cli.py Normal file
View File

@@ -0,0 +1,486 @@
"""CLI subcommands for ``hermes wallet``.
Provides:
hermes wallet create — Create a new wallet (fresh keypair)
hermes wallet create-agent — Create an agent wallet (auto-approve within policy)
hermes wallet import — Import wallet from exported private key (migration)
hermes wallet export — Export private key for migration to another machine
hermes wallet list — List wallets
hermes wallet balance — Check balance
hermes wallet send — Send tokens (interactive approval)
hermes wallet fund — Show deposit address
hermes wallet history — Transaction history
hermes wallet freeze — Kill switch
hermes wallet unfreeze — Resume operations
hermes wallet status — Show wallet status
"""
import argparse
import getpass
import json
import sys
from decimal import Decimal, InvalidOperation
try:
from rich.console import Console
from rich.table import Table
_RICH = True
except ImportError:
_RICH = False
def _cprint(msg: str, style: str = "") -> None:
if _RICH:
Console().print(msg, style=style)
else:
print(msg)
def _get_wallet_manager():
"""Initialize and return the shared wallet manager + policy engine."""
try:
from wallet.runtime import get_runtime
mgr, policy = get_runtime()
if mgr is None:
from keystore.store import KeystoreLocked
raise KeystoreLocked("Keystore is locked")
return mgr, policy
except ImportError as e:
_cprint(f"\n ✗ Wallet dependencies not installed: {e}", style="bold red")
_cprint(" Install with: pip install 'hermes-agent[wallet]'\n")
sys.exit(1)
# =========================================================================
# Subcommand handlers
# =========================================================================
def cmd_wallet_create(args: argparse.Namespace) -> None:
"""Create a new wallet."""
mgr, _ = _get_wallet_manager()
chain = args.chain
label = args.label or ""
if chain not in mgr.supported_chains:
_cprint(f"\n ✗ Unsupported chain: {chain}", style="bold red")
_cprint(f" Available: {', '.join(mgr.supported_chains)}\n")
return
wallet = mgr.create_wallet(chain=chain, label=label, wallet_type="user")
_cprint(f"\n ✓ Wallet created!", style="bold green")
_cprint(f" Label: {wallet.label}")
_cprint(f" Chain: {wallet.chain}")
_cprint(f" Address: {wallet.address}")
_cprint(f" ID: {wallet.wallet_id}")
_cprint(f"\n 💡 Fund this wallet by sending tokens to the address above.")
_cprint(f" This is a fresh wallet — send it some tokens to get started.")
_cprint(f" All transactions from this wallet require your approval.\n")
def cmd_wallet_create_agent(args: argparse.Namespace) -> None:
"""Create an agent wallet with auto-approve policies."""
mgr, _ = _get_wallet_manager()
chain = args.chain
label = args.label or "Agent Wallet"
if chain not in mgr.supported_chains:
_cprint(f"\n ✗ Unsupported chain: {chain}", style="bold red")
return
wallet = mgr.create_wallet(chain=chain, label=label, wallet_type="agent")
_cprint(f"\n ✓ Agent wallet created!", style="bold green")
_cprint(f" Label: {wallet.label}")
_cprint(f" Chain: {wallet.chain}")
_cprint(f" Address: {wallet.address}")
_cprint(f" ID: {wallet.wallet_id}")
_cprint(f"\n ⚠️ Agent wallets auto-approve transactions within policy limits.")
_cprint(f" Default: max 1.0 {mgr.get_provider(chain).config.symbol}/tx, 5.0/day\n")
def cmd_wallet_import(args: argparse.Namespace) -> None:
"""Import a wallet from an exported private key (for migration)."""
mgr, _ = _get_wallet_manager()
chain = args.chain
label = args.label or ""
wallet_type = args.type or "user"
_cprint("\n 📦 Import Wallet")
_cprint(" This is for migrating a wallet from another Hermes install.")
_cprint(" Use 'hermes wallet export' on the source machine first.\n")
private_key = getpass.getpass(" Private key (hidden): ")
if not private_key:
_cprint("\n ✗ Cancelled\n", style="yellow")
return
try:
wallet = mgr.import_wallet(
chain=chain, private_key=private_key.strip(),
label=label, wallet_type=wallet_type,
)
_cprint(f"\n ✓ Wallet imported!", style="bold green")
_cprint(f" Label: {wallet.label}")
_cprint(f" Chain: {wallet.chain}")
_cprint(f" Address: {wallet.address}")
_cprint(f" Type: {wallet.wallet_type}")
_cprint(f" ID: {wallet.wallet_id}\n")
except Exception as e:
_cprint(f"\n ✗ Import failed: {e}\n", style="bold red")
def cmd_wallet_export(args: argparse.Namespace) -> None:
"""Export a wallet's private key for migration to another machine."""
mgr, _ = _get_wallet_manager()
wallet_id = args.wallet_id
chain = args.chain
try:
wallet = mgr.resolve_wallet(wallet_id=wallet_id, chain=chain)
except Exception as e:
_cprint(f"\n{e}\n", style="bold red")
return
_cprint(f"\n ⚠️ Export Private Key: {wallet.label}")
_cprint(f" Chain: {wallet.chain}")
_cprint(f" Address: {wallet.address}")
_cprint(f"\n This will display the private key in your terminal.")
_cprint(f" Anyone with this key has FULL control of this wallet.")
_cprint(f" Make sure nobody is watching your screen.\n")
# Require passphrase re-entry as confirmation
passphrase = getpass.getpass(" Re-enter keystore passphrase to confirm: ")
if not passphrase:
_cprint("\n ✗ Cancelled\n", style="yellow")
return
# Verify passphrase
try:
from keystore.client import get_keystore
ks = get_keystore()
# Quick verify by attempting to re-derive (the store validates on unlock)
from keystore.store import EncryptedStore
test_store = EncryptedStore(ks._store._db_path)
test_store.unlock(passphrase)
test_store.lock()
except Exception:
_cprint("\n ✗ Incorrect passphrase\n", style="bold red")
return
try:
private_key = mgr.export_private_key(wallet.wallet_id)
_cprint(f"\n Private key for {wallet.label}:")
_cprint(f" {private_key}\n", style="bold")
_cprint(f" To import on another machine:")
_cprint(f" hermes wallet import --chain {wallet.chain} --type {wallet.wallet_type}")
_cprint(f"\n ⚠️ This key will not be shown again. Copy it now.\n")
except Exception as e:
_cprint(f"\n ✗ Export failed: {e}\n", style="bold red")
def cmd_wallet_list(args: argparse.Namespace) -> None:
"""List all wallets."""
mgr, _ = _get_wallet_manager()
wallets = mgr.list_wallets()
if not wallets:
_cprint("\n No wallets found. Create one with: hermes wallet create --chain <chain>\n")
return
if _RICH:
console = Console()
table = Table(title="Wallets", show_lines=False)
table.add_column("ID", style="dim")
table.add_column("Label", style="cyan")
table.add_column("Chain", style="magenta")
table.add_column("Type")
table.add_column("Address", style="green")
table.add_column("Balance", justify="right")
for w in wallets:
try:
bal = mgr.get_balance(w.wallet_id)
balance_str = f"{bal.balance:.6f} {bal.symbol}"
except Exception:
balance_str = "?"
type_style = "yellow" if w.wallet_type == "agent" else "blue"
table.add_row(
w.wallet_id,
w.label,
w.chain,
f"[{type_style}]{w.wallet_type}[/{type_style}]",
w.address,
balance_str,
)
console.print()
console.print(table)
console.print()
else:
for w in wallets:
_cprint(f" {w.wallet_id} {w.label} ({w.chain}, {w.wallet_type}) {w.address}")
_cprint("")
def cmd_wallet_balance(args: argparse.Namespace) -> None:
"""Check wallet balance."""
mgr, _ = _get_wallet_manager()
try:
wallet = mgr.resolve_wallet(wallet_id=args.wallet_id, chain=args.chain)
bal = mgr.get_balance(wallet.wallet_id)
_cprint(f"\n {wallet.label} ({wallet.chain})")
_cprint(f" Address: {wallet.address}")
_cprint(f" Balance: {bal.balance:.9f} {bal.symbol}\n", style="bold green")
except Exception as e:
_cprint(f"\n{e}\n", style="bold red")
def cmd_wallet_send(args: argparse.Namespace) -> None:
"""Send tokens (with interactive approval)."""
mgr, policy = _get_wallet_manager()
to_address = args.to
try:
amount = Decimal(args.amount)
except InvalidOperation:
_cprint(f"\n ✗ Invalid amount: {args.amount}\n", style="bold red")
return
try:
wallet = mgr.resolve_wallet(wallet_id=args.wallet_id, chain=args.chain)
except Exception as e:
_cprint(f"\n{e}\n", style="bold red")
return
provider = mgr.get_provider(wallet.chain)
symbol = provider.config.symbol
# Show confirmation
_cprint(f"\n 📤 Send Transaction")
_cprint(f" From: {wallet.label} ({wallet.address})")
_cprint(f" To: {to_address}")
_cprint(f" Amount: {amount} {symbol}")
_cprint(f" Chain: {provider.config.display_name}")
# Estimate fee
try:
fee = mgr.estimate_fee(wallet.wallet_id, to_address, amount)
_cprint(f" Fee: ~{fee.estimated_fee:.6f} {fee.symbol}")
except Exception:
_cprint(f" Fee: (estimate unavailable)")
_cprint("")
# Confirm
try:
confirm = input(" Confirm? [y/N] ").strip().lower()
except (EOFError, KeyboardInterrupt):
confirm = "n"
if confirm not in ("y", "yes"):
_cprint("\n ✗ Cancelled\n", style="yellow")
return
# Evaluate policy first
try:
from wallet.policy import TxRequest, PolicyVerdict
tx_req = TxRequest(
wallet_id=wallet.wallet_id,
wallet_type=wallet.wallet_type,
chain=wallet.chain,
to_address=to_address,
amount=amount,
symbol=symbol,
)
eval_result = policy.evaluate(tx_req)
if eval_result.verdict == PolicyVerdict.BLOCK:
_cprint(f"\n ✗ Blocked by policy: {eval_result.reason}\n", style="bold red")
return
# CLI owner explicitly approved by confirming above, so approval-gated
# txs may proceed here. We still preserve the policy result in history.
result = mgr.send(
wallet.wallet_id,
to_address,
amount,
decided_by="owner_cli",
policy_result=json.dumps({
"verdict": eval_result.verdict.value,
"checked": eval_result.checked,
"failed": eval_result.failed,
"approved_via": "owner_cli",
}),
)
if result.status == "failed":
_cprint(f"\n ✗ Transaction failed: {result.error}\n", style="bold red")
return
policy.record_transaction(tx_req)
_cprint(f"\n ✓ Transaction submitted!", style="bold green")
_cprint(f" TX hash: {result.tx_hash}")
if result.explorer_url:
_cprint(f" Explorer: {result.explorer_url}")
_cprint("")
except Exception as e:
_cprint(f"\n ✗ Error: {e}\n", style="bold red")
def cmd_wallet_fund(args: argparse.Namespace) -> None:
"""Show deposit address for a wallet."""
mgr, _ = _get_wallet_manager()
try:
wallet = mgr.resolve_wallet(wallet_id=args.wallet_id, chain=args.chain)
provider = mgr.get_provider(wallet.chain)
_cprint(f"\n 💰 Fund Wallet: {wallet.label}")
_cprint(f" Chain: {provider.config.display_name}")
_cprint(f"\n Send {provider.config.symbol} to:")
_cprint(f" {wallet.address}", style="bold green")
if provider.config.is_testnet:
_cprint(f"\n ⚠️ This is a testnet wallet — use testnet faucets")
_cprint("")
except Exception as e:
_cprint(f"\n{e}\n", style="bold red")
def cmd_wallet_history(args: argparse.Namespace) -> None:
"""Show transaction history."""
mgr, _ = _get_wallet_manager()
records = mgr.get_tx_history(wallet_id=args.wallet_id, limit=args.limit)
if not records:
_cprint("\n No transactions yet.\n")
return
if _RICH:
console = Console()
table = Table(title="Transaction History", show_lines=False)
table.add_column("Time", style="dim")
table.add_column("Chain")
table.add_column("To", style="cyan")
table.add_column("Amount", justify="right")
table.add_column("Status")
table.add_column("TX Hash", style="dim")
_status_style = {"submitted": "yellow", "confirmed": "green", "failed": "red", "rejected": "red"}
for r in records:
ts = r.requested_at[:19].replace("T", " ") if r.requested_at else ""
status_s = _status_style.get(r.status, "white")
to_short = r.to_address[:10] + "..." + r.to_address[-6:] if len(r.to_address) > 20 else r.to_address
hash_short = r.tx_hash[:12] + "..." if r.tx_hash and len(r.tx_hash) > 15 else r.tx_hash or ""
table.add_row(ts, r.chain, to_short, f"{r.amount} {r.symbol}",
f"[{status_s}]{r.status}[/{status_s}]", hash_short)
console.print()
console.print(table)
console.print()
else:
for r in records:
_cprint(f" {r.requested_at[:19]} {r.chain} {r.amount} {r.symbol}{r.to_address[:16]}... {r.status}")
_cprint("")
def cmd_wallet_freeze(args: argparse.Namespace) -> None:
"""Activate kill switch — block all transactions."""
_, policy = _get_wallet_manager()
policy.freeze()
_cprint("\n 🔒 Wallet FROZEN — all transactions are blocked.", style="bold red")
_cprint(" Run 'hermes wallet unfreeze' to resume.\n")
def cmd_wallet_unfreeze(args: argparse.Namespace) -> None:
"""Deactivate kill switch."""
_, policy = _get_wallet_manager()
policy.unfreeze()
_cprint("\n 🔓 Wallet unfrozen — transactions are allowed.\n", style="green")
def cmd_wallet_status(args: argparse.Namespace) -> None:
"""Show wallet status overview."""
mgr, policy = _get_wallet_manager()
wallets = mgr.list_wallets()
_cprint(f"\n 💰 Wallet Status")
_cprint(f" Wallets: {len(wallets)}")
_cprint(f" Chains: {', '.join(mgr.supported_chains) or 'none'}")
_cprint(f" Frozen: {'YES ⚠️' if policy.is_frozen else 'No'}")
_cprint(f" TX history: {len(mgr.get_tx_history())}")
_cprint("")
# =========================================================================
# Argparse registration
# =========================================================================
def register_subparser(subparsers: argparse._SubParsersAction) -> None:
"""Register the ``hermes wallet`` subcommand tree."""
wallet_parser = subparsers.add_parser(
"wallet",
help="Manage crypto wallets",
description="Create, fund, and manage crypto wallets with policy-controlled transactions.",
)
wallet_parser.set_defaults(func=cmd_wallet_status)
w_sub = wallet_parser.add_subparsers(dest="wallet_command")
# create
create_p = w_sub.add_parser("create", help="Create a new wallet")
create_p.add_argument("--chain", "-c", required=True, help="Chain (ethereum, base, solana, etc.)")
create_p.add_argument("--label", "-l", default="", help="Wallet label")
create_p.set_defaults(func=cmd_wallet_create)
# create-agent
agent_p = w_sub.add_parser("create-agent", help="Create an agent wallet (auto-approve within limits)")
agent_p.add_argument("--chain", "-c", required=True, help="Chain")
agent_p.add_argument("--label", "-l", default="", help="Wallet label")
agent_p.set_defaults(func=cmd_wallet_create_agent)
# import
import_p = w_sub.add_parser("import", help="Import wallet from exported private key (migration)")
import_p.add_argument("--chain", "-c", required=True, help="Chain")
import_p.add_argument("--label", "-l", default="", help="Wallet label")
import_p.add_argument("--type", "-t", default="user", choices=["user", "agent"],
help="Wallet type (default: user)")
import_p.set_defaults(func=cmd_wallet_import)
# export
export_p = w_sub.add_parser("export", help="Export private key for migration to another machine")
export_p.add_argument("--wallet-id", "-w", default=None, help="Wallet ID")
export_p.add_argument("--chain", "-c", default=None, help="Chain")
export_p.set_defaults(func=cmd_wallet_export)
# list
w_sub.add_parser("list", aliases=["ls"], help="List all wallets").set_defaults(func=cmd_wallet_list)
# balance
bal_p = w_sub.add_parser("balance", aliases=["bal"], help="Check wallet balance")
bal_p.add_argument("--wallet-id", "-w", default=None, help="Wallet ID")
bal_p.add_argument("--chain", "-c", default=None, help="Chain")
bal_p.set_defaults(func=cmd_wallet_balance)
# send
send_p = w_sub.add_parser("send", help="Send tokens")
send_p.add_argument("to", help="Recipient address")
send_p.add_argument("amount", help="Amount in native token units")
send_p.add_argument("--wallet-id", "-w", default=None, help="Wallet ID")
send_p.add_argument("--chain", "-c", default=None, help="Chain")
send_p.set_defaults(func=cmd_wallet_send)
# fund
fund_p = w_sub.add_parser("fund", help="Show deposit address")
fund_p.add_argument("--wallet-id", "-w", default=None, help="Wallet ID")
fund_p.add_argument("--chain", "-c", default=None, help="Chain")
fund_p.set_defaults(func=cmd_wallet_fund)
# history
hist_p = w_sub.add_parser("history", help="Transaction history")
hist_p.add_argument("--wallet-id", "-w", default=None, help="Wallet ID")
hist_p.add_argument("--limit", "-n", type=int, default=20, help="Max entries")
hist_p.set_defaults(func=cmd_wallet_history)
# freeze / unfreeze
w_sub.add_parser("freeze", help="Kill switch — block all transactions").set_defaults(func=cmd_wallet_freeze)
w_sub.add_parser("unfreeze", help="Resume transactions after freeze").set_defaults(func=cmd_wallet_unfreeze)
# status
w_sub.add_parser("status", help="Show wallet status").set_defaults(func=cmd_wallet_status)

58
wallet/file_state.py Normal file
View File

@@ -0,0 +1,58 @@
"""Small helpers for cross-process JSON state persistence.
Provides a minimal lock + read/modify/write utility for wallet state files.
Uses ``fcntl.flock`` on POSIX and gracefully degrades to best-effort writes on
platforms without flock support.
"""
from __future__ import annotations
import json
import os
from contextlib import contextmanager
from pathlib import Path
from typing import Callable, TypeVar
T = TypeVar("T")
try:
import fcntl # type: ignore
_HAS_FCNTL = True
except ImportError: # pragma: no cover - Windows fallback
_HAS_FCNTL = False
@contextmanager
def locked_file(path: Path):
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "a+b") as f:
if _HAS_FCNTL:
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
try:
yield f
finally:
f.flush()
os.fsync(f.fileno())
if _HAS_FCNTL:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
def read_json(path: Path, default):
if not path.exists():
return default
try:
return json.loads(path.read_text())
except Exception:
return default
def update_json(path: Path, default, merge_fn: Callable[[object], T]) -> T:
"""Lock, load current JSON, compute new state, atomically replace file."""
lock_path = path.with_suffix(path.suffix + ".lock")
with locked_file(lock_path):
current = read_json(path, default)
new_state = merge_fn(current)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(new_state, indent=2))
os.replace(tmp, path)
return new_state

414
wallet/manager.py Normal file
View File

@@ -0,0 +1,414 @@
"""Wallet manager — creates, stores, and operates wallets.
Wallets are stored in the keystore as sealed secrets. The manager
is the ONLY code that reads private keys; it passes them to chain
providers for signing and never exposes them outside this module.
Wallet metadata is stored alongside the key in the keystore:
wallet:meta:<wallet_id> → JSON metadata (label, chain, type, address)
wallet:<chain>:<address> → encrypted private key (sealed)
"""
import json
import logging
import os
import uuid
from dataclasses import dataclass, asdict, field
from datetime import datetime, timezone
from decimal import Decimal
from pathlib import Path
from typing import Dict, List, Optional
from keystore.client import KeystoreClient
from wallet.chains import ChainProvider, Balance, TransactionResult, GasEstimate
from wallet.file_state import read_json, update_json
logger = logging.getLogger(__name__)
@dataclass
class WalletInfo:
"""Public wallet information (no private keys)."""
wallet_id: str
label: str
chain: str
address: str
wallet_type: str # "user" | "agent"
created_at: str
@dataclass
class TxRecord:
"""Local transaction log entry."""
tx_id: str
wallet_id: str
chain: str
to_address: str
amount: str # Decimal as string for precision
symbol: str
tx_hash: str
status: str # "submitted" | "confirmed" | "failed" | "rejected"
policy_result: str # JSON string
requested_at: str
decided_by: str # "policy_auto" | "owner_cli" | "owner_gateway"
class WalletError(Exception):
"""Base wallet exception."""
class WalletNotFound(WalletError):
"""Wallet ID doesn't exist."""
class WalletManager:
"""Manages wallet lifecycle and transaction execution.
All private key access goes through the keystore with requester="wallet".
The agent process never calls this directly — it goes through the
wallet tools which use session tokens.
"""
def __init__(self, keystore: KeystoreClient, state_dir: Optional[Path] = None):
self._ks = keystore
self._providers: Dict[str, ChainProvider] = {}
self._state_dir = Path(state_dir) if state_dir else Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) / "wallet"
self._state_dir.mkdir(parents=True, exist_ok=True)
self._tx_log_path = self._state_dir / "tx_log.json"
self._tx_log: List[TxRecord] = self._load_tx_log()
# ------------------------------------------------------------------
# Chain provider management
# ------------------------------------------------------------------
def register_provider(self, chain_id: str, provider: ChainProvider) -> None:
"""Register a chain provider."""
self._providers[chain_id] = provider
def get_provider(self, chain_id: str) -> ChainProvider:
"""Get the provider for a chain. Raises WalletError if not registered."""
if chain_id not in self._providers:
raise WalletError(
f"No provider registered for chain '{chain_id}'. "
f"Available: {', '.join(self._providers.keys()) or 'none'}"
)
return self._providers[chain_id]
@property
def supported_chains(self) -> List[str]:
return list(self._providers.keys())
# ------------------------------------------------------------------
# Wallet CRUD
# ------------------------------------------------------------------
def create_wallet(
self,
chain: str,
label: str = "",
wallet_type: str = "user",
) -> WalletInfo:
"""Create a new wallet (generates a fresh keypair).
Returns public wallet info. The private key is stored as a sealed
secret in the keystore and never returned.
"""
provider = self.get_provider(chain)
# Generate keypair
if hasattr(provider, "generate_keypair"):
address, private_key = provider.generate_keypair()
else:
raise WalletError(f"Chain '{chain}' doesn't support key generation")
existing = self._find_wallet_by_chain_address(chain, address)
if existing:
raise WalletError(
f"Wallet already exists for {chain}:{address} (wallet_id={existing.wallet_id}). "
"Use 'hermes wallet list' or 'hermes wallet export' for migration instead."
)
wallet_id = f"w_{uuid.uuid4().hex[:12]}"
now = datetime.now(timezone.utc).isoformat()
if not label:
label = f"{provider.config.display_name} Wallet"
# Store private key as sealed secret
key_name = f"wallet:{chain}:{address}"
self._ks.set_secret(key_name, private_key, category="sealed",
description=f"Private key for {label}")
# Store metadata
meta = {
"wallet_id": wallet_id,
"label": label,
"chain": chain,
"address": address,
"wallet_type": wallet_type,
"created_at": now,
}
meta_name = f"wallet:meta:{wallet_id}"
self._ks.set_secret(meta_name, json.dumps(meta), category="sealed",
description=f"Metadata for {label}")
logger.info("Created %s wallet '%s' on %s: %s", wallet_type, label, chain, address)
return WalletInfo(
wallet_id=wallet_id,
label=label,
chain=chain,
address=address,
wallet_type=wallet_type,
created_at=now,
)
def import_wallet(
self,
chain: str,
private_key: str,
label: str = "",
wallet_type: str = "user",
) -> WalletInfo:
"""Import an existing wallet from a private key."""
provider = self.get_provider(chain)
# Derive address from key
if hasattr(provider, "address_from_key"):
address = provider.address_from_key(private_key)
else:
raise WalletError(f"Chain '{chain}' doesn't support key import")
existing = self._find_wallet_by_chain_address(chain, address)
if existing:
raise WalletError(
f"Wallet already exists for {chain}:{address} (wallet_id={existing.wallet_id})."
)
wallet_id = f"w_{uuid.uuid4().hex[:12]}"
now = datetime.now(timezone.utc).isoformat()
if not label:
label = f"Imported {provider.config.display_name} Wallet"
# Store private key
key_name = f"wallet:{chain}:{address}"
self._ks.set_secret(key_name, private_key, category="sealed",
description=f"Private key for {label}")
# Store metadata
meta = {
"wallet_id": wallet_id,
"label": label,
"chain": chain,
"address": address,
"wallet_type": wallet_type,
"created_at": now,
}
meta_name = f"wallet:meta:{wallet_id}"
self._ks.set_secret(meta_name, json.dumps(meta), category="sealed",
description=f"Metadata for {label}")
logger.info("Imported wallet '%s' on %s: %s", label, chain, address)
return WalletInfo(
wallet_id=wallet_id,
label=label,
chain=chain,
address=address,
wallet_type=wallet_type,
created_at=now,
)
def list_wallets(self) -> List[WalletInfo]:
"""List all wallets (public info only)."""
wallets = []
for secret in self._ks.list_secrets():
if secret.name.startswith("wallet:meta:"):
meta_json = self._ks.get_secret(secret.name, requester="wallet")
if meta_json:
try:
meta = json.loads(meta_json)
wallets.append(WalletInfo(**meta))
except (json.JSONDecodeError, TypeError) as e:
logger.warning("Corrupt wallet metadata: %s: %s", secret.name, e)
return wallets
def get_wallet(self, wallet_id: str) -> WalletInfo:
"""Get wallet info by ID."""
meta_json = self._ks.get_secret(f"wallet:meta:{wallet_id}", requester="wallet")
if not meta_json:
raise WalletNotFound(f"Wallet '{wallet_id}' not found")
meta = json.loads(meta_json)
return WalletInfo(**meta)
def delete_wallet(self, wallet_id: str) -> bool:
"""Delete a wallet and its private key."""
try:
wallet = self.get_wallet(wallet_id)
except WalletNotFound:
return False
# Delete metadata first
self._ks.delete_secret(f"wallet:meta:{wallet_id}")
# Delete private key only if no other wallet metadata points at it
still_referenced = self._find_wallet_by_chain_address(wallet.chain, wallet.address)
if not still_referenced:
key_name = f"wallet:{wallet.chain}:{wallet.address}"
self._ks.delete_secret(key_name)
logger.info("Deleted wallet '%s' (%s)", wallet.label, wallet.address)
return True
def export_private_key(self, wallet_id: str) -> str:
"""Export a wallet's private key for migration.
This is a CLI-only operation — NEVER exposed via agent tools.
Returns the hex-encoded private key.
Raises WalletNotFound or WalletError on failure.
"""
wallet = self.get_wallet(wallet_id)
key_name = f"wallet:{wallet.chain}:{wallet.address}"
private_key = self._ks.get_secret(key_name, requester="cli_export")
if not private_key:
raise WalletError(f"Failed to retrieve private key for wallet '{wallet_id}'")
return private_key
# ------------------------------------------------------------------
# Balance & Transactions
# ------------------------------------------------------------------
def get_balance(self, wallet_id: str) -> Balance:
"""Get the balance for a wallet."""
wallet = self.get_wallet(wallet_id)
provider = self.get_provider(wallet.chain)
return provider.get_balance(wallet.address)
def estimate_fee(self, wallet_id: str, to_address: str, amount: Decimal) -> GasEstimate:
"""Estimate transaction fee."""
wallet = self.get_wallet(wallet_id)
provider = self.get_provider(wallet.chain)
return provider.estimate_fee(wallet.address, to_address, amount)
def send(
self,
wallet_id: str,
to_address: str,
amount: Decimal,
decided_by: str = "owner_cli",
policy_result: str = "{}",
) -> TransactionResult:
"""Execute a native token transfer.
This is the ONLY method that reads the private key from the keystore.
It should only be called after policy evaluation and approval.
"""
wallet = self.get_wallet(wallet_id)
provider = self.get_provider(wallet.chain)
# Validate recipient
if not provider.validate_address(to_address):
return TransactionResult(
tx_hash="", chain=wallet.chain, status="failed",
error=f"Invalid address for {wallet.chain}: {to_address}",
)
# Read private key (sealed — only wallet requester can access)
key_name = f"wallet:{wallet.chain}:{wallet.address}"
private_key = self._ks.get_secret(key_name, requester="wallet")
if not private_key:
return TransactionResult(
tx_hash="", chain=wallet.chain, status="failed",
error="Failed to retrieve private key from keystore",
)
# Execute transaction
result = provider.send_transaction(private_key, to_address, amount)
# Log transaction
tx_record = TxRecord(
tx_id=f"tx_{uuid.uuid4().hex[:12]}",
wallet_id=wallet_id,
chain=wallet.chain,
to_address=to_address,
amount=str(amount),
symbol=provider.config.symbol,
tx_hash=result.tx_hash,
status=result.status,
policy_result=policy_result,
requested_at=datetime.now(timezone.utc).isoformat(),
decided_by=decided_by,
)
self._append_tx_log(tx_record)
return result
def get_tx_history(self, wallet_id: Optional[str] = None, limit: int = 20) -> List[TxRecord]:
"""Get transaction history from durable local log."""
# Refresh from disk so multiple processes see latest state.
self._tx_log = self._load_tx_log()
records = self._tx_log
if wallet_id:
records = [r for r in records if r.wallet_id == wallet_id]
return records[-limit:]
def _load_tx_log(self) -> List[TxRecord]:
try:
data = read_json(self._tx_log_path, [])
return [TxRecord(**item) for item in data]
except Exception as e:
logger.warning("Failed to load wallet tx log: %s", e)
return []
def _append_tx_log(self, record: TxRecord) -> None:
try:
def _merge(current):
current = current or []
current.append(asdict(record))
return current
merged = update_json(self._tx_log_path, [], _merge)
self._tx_log = [TxRecord(**item) for item in merged]
except Exception as e:
logger.warning("Failed to append wallet tx log: %s", e)
def _find_wallet_by_chain_address(self, chain: str, address: str) -> Optional[WalletInfo]:
for secret in self._ks.list_secrets():
if secret.name.startswith("wallet:meta:"):
meta_json = self._ks.get_secret(secret.name, requester="wallet")
if not meta_json:
continue
try:
meta = json.loads(meta_json)
except Exception:
continue
if meta.get("chain") == chain and meta.get("address") == address:
return WalletInfo(**meta)
return None
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def resolve_wallet(self, wallet_id: Optional[str] = None, chain: Optional[str] = None) -> WalletInfo:
"""Resolve a wallet — by ID, by chain (first match), or the only wallet."""
if wallet_id:
return self.get_wallet(wallet_id)
wallets = self.list_wallets()
if not wallets:
raise WalletNotFound("No wallets found. Create one with 'hermes wallet create'")
if chain:
matching = [w for w in wallets if w.chain == chain]
if not matching:
raise WalletNotFound(f"No wallet found for chain '{chain}'")
return matching[0]
if len(wallets) == 1:
return wallets[0]
raise WalletError(
f"Multiple wallets found. Specify --wallet-id or --chain. "
f"Wallets: {', '.join(f'{w.label} ({w.chain})' for w in wallets)}"
)

328
wallet/policy.py Normal file
View File

@@ -0,0 +1,328 @@
"""Policy engine — evaluates transactions against configurable rules.
Policies are checked in order. The first ``block`` or ``require_approval``
result wins. If all policies pass, the transaction is auto-approved.
For v1, policies are in-memory (loaded from config.yaml). A future
version will persist per-wallet policies in the keystore.
"""
import json
import logging
import os
import time
from collections import defaultdict
from dataclasses import dataclass, field
from decimal import Decimal
from enum import Enum
from pathlib import Path
from typing import Dict, List, Optional
from wallet.file_state import read_json, update_json
logger = logging.getLogger(__name__)
class PolicyVerdict(str, Enum):
ALLOW = "allow"
REQUIRE_APPROVAL = "require_approval"
BLOCK = "block"
@dataclass
class PolicyResult:
"""Result of evaluating all policies for a transaction."""
verdict: PolicyVerdict
reason: str = ""
checked: List[str] = field(default_factory=list) # Policy names that passed
failed: str = "" # Policy name that blocked/required approval
@dataclass
class TxRequest:
"""A pending transaction to evaluate."""
wallet_id: str
wallet_type: str # "user" | "agent"
chain: str
to_address: str
amount: Decimal
symbol: str
# ---------------------------------------------------------------------------
# Individual policy checks
# ---------------------------------------------------------------------------
def _check_spending_limit(tx: TxRequest, config: dict) -> Optional[PolicyVerdict]:
"""Block if single transaction exceeds max amount."""
max_amount = Decimal(str(config.get("max_native", "0")))
if max_amount > 0 and tx.amount > max_amount:
return PolicyVerdict.BLOCK
return None
def _check_daily_limit(tx: TxRequest, config: dict, daily_totals: Dict[str, Decimal]) -> Optional[PolicyVerdict]:
"""Block if daily aggregate exceeds limit."""
max_daily = Decimal(str(config.get("max_native", "0")))
if max_daily <= 0:
return None
today_key = f"{tx.wallet_id}:{time.strftime('%Y-%m-%d')}"
current_total = daily_totals.get(today_key, Decimal("0"))
if current_total + tx.amount > max_daily:
return PolicyVerdict.BLOCK
return None
def _check_rate_limit(tx: TxRequest, config: dict, tx_timestamps: Dict[str, list]) -> Optional[PolicyVerdict]:
"""Block if too many transactions in the time window."""
max_txns = config.get("max_txns", 0)
window = config.get("window_seconds", 3600)
if max_txns <= 0:
return None
key = tx.wallet_id
now = time.time()
timestamps = tx_timestamps.get(key, [])
# Prune old timestamps
timestamps = [t for t in timestamps if now - t < window]
tx_timestamps[key] = timestamps
if len(timestamps) >= max_txns:
return PolicyVerdict.BLOCK
return None
def _check_cooldown(tx: TxRequest, config: dict, last_tx_time: Dict[str, float]) -> Optional[PolicyVerdict]:
"""Block if not enough time since last transaction."""
min_seconds = config.get("min_seconds", 0)
if min_seconds <= 0:
return None
key = tx.wallet_id
last = last_tx_time.get(key, 0)
if time.time() - last < min_seconds:
return PolicyVerdict.BLOCK
return None
def _check_allowed_recipients(tx: TxRequest, config: dict) -> Optional[PolicyVerdict]:
"""Block if recipient not in allowlist (when configured)."""
addresses = config.get("addresses", [])
if not addresses:
return None # No allowlist = allow all
if tx.to_address.lower() not in [a.lower() for a in addresses]:
return PolicyVerdict.BLOCK
return None
def _check_blocked_recipients(tx: TxRequest, config: dict) -> Optional[PolicyVerdict]:
"""Block if recipient is in blocklist."""
addresses = config.get("addresses", [])
if tx.to_address.lower() in [a.lower() for a in addresses]:
return PolicyVerdict.BLOCK
return None
def _check_require_approval(tx: TxRequest, config: dict) -> Optional[PolicyVerdict]:
"""Require owner approval if amount exceeds threshold."""
above = Decimal(str(config.get("above_native", "-1")))
if above < 0:
return None # Not configured
if tx.amount > above:
return PolicyVerdict.REQUIRE_APPROVAL
return None
# ---------------------------------------------------------------------------
# Policy engine
# ---------------------------------------------------------------------------
# Default policies for agent wallets (can be tightened, not loosened)
AGENT_WALLET_DEFAULTS = {
"spending_limit": {"max_native": "1.0"}, # Max per tx (in native token)
"daily_limit": {"max_native": "5.0"}, # Max per day
"rate_limit": {"max_txns": 5, "window_seconds": 3600},
"cooldown": {"min_seconds": 30},
"require_approval": {"above_native": "0.5"}, # Require approval above this
}
# User wallets always require approval by default
USER_WALLET_DEFAULTS = {
"require_approval": {"above_native": "0"}, # Always require approval
}
class PolicyEngine:
"""Evaluates transactions against a set of policies.
State for freeze/rate-limit/daily-limit is persisted to a JSON file so
CLI invocations and approval replays share the same safeguards.
"""
def __init__(self, policies: Optional[Dict[str, dict]] = None, state_path: Optional[Path] = None):
self._policies = policies or {}
self._state_path = Path(state_path) if state_path else Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) / "wallet" / "policy_state.json"
self._state_path.parent.mkdir(parents=True, exist_ok=True)
# Tracking state for rate-based policies
self._daily_totals: Dict[str, Decimal] = defaultdict(Decimal)
self._tx_timestamps: Dict[str, list] = defaultdict(list)
self._last_tx_time: Dict[str, float] = {}
self._frozen = False
self._load_state()
@property
def is_frozen(self) -> bool:
return self._frozen
def freeze(self) -> None:
"""Kill switch — block all transactions."""
self._frozen = True
self._save_state()
logger.warning("Wallet FROZEN — all transactions blocked")
def unfreeze(self) -> None:
"""Unfreeze — resume normal operation."""
self._frozen = False
self._save_state()
logger.info("Wallet unfrozen")
def evaluate(self, tx: TxRequest) -> PolicyResult:
"""Evaluate a transaction against all policies.
Returns PolicyResult with the final verdict.
"""
# Refresh persisted state so multiple processes share freeze/rate/daily/cooldown.
self._load_state()
if self._frozen:
return PolicyResult(
verdict=PolicyVerdict.BLOCK,
reason="Wallet is frozen (kill switch active)",
failed="freeze",
)
# Select policy set based on wallet type
if tx.wallet_type == "agent":
policies = {**AGENT_WALLET_DEFAULTS, **self._policies}
else:
policies = {**USER_WALLET_DEFAULTS, **self._policies}
checked = []
_CHECKS = {
"spending_limit": lambda cfg: _check_spending_limit(tx, cfg),
"daily_limit": lambda cfg: _check_daily_limit(tx, cfg, self._daily_totals),
"rate_limit": lambda cfg: _check_rate_limit(tx, cfg, self._tx_timestamps),
"cooldown": lambda cfg: _check_cooldown(tx, cfg, self._last_tx_time),
"allowed_recipients": lambda cfg: _check_allowed_recipients(tx, cfg),
"blocked_recipients": lambda cfg: _check_blocked_recipients(tx, cfg),
"require_approval": lambda cfg: _check_require_approval(tx, cfg),
}
# Hard blocks always run before approval policies.
policy_names = [p for p in policies.keys() if p != "require_approval"]
if "require_approval" in policies:
policy_names.append("require_approval")
pending_approval = None
for policy_name in policy_names:
config = policies[policy_name]
check_fn = _CHECKS.get(policy_name)
if not check_fn:
continue
result = check_fn(config)
if result == PolicyVerdict.BLOCK:
return PolicyResult(
verdict=PolicyVerdict.BLOCK,
reason=f"Blocked by {policy_name} policy",
checked=checked,
failed=policy_name,
)
elif result == PolicyVerdict.REQUIRE_APPROVAL:
pending_approval = PolicyResult(
verdict=PolicyVerdict.REQUIRE_APPROVAL,
reason=f"Requires approval ({policy_name} policy)",
checked=checked.copy(),
failed=policy_name,
)
else:
checked.append(policy_name)
if pending_approval is not None:
return pending_approval
return PolicyResult(
verdict=PolicyVerdict.ALLOW,
reason="All policies passed",
checked=checked,
)
def record_transaction(self, tx: TxRequest) -> None:
"""Update tracking state after a successful transaction.
Uses locked read-modify-write so updates from separate processes merge
instead of clobbering each other.
"""
now = time.time()
today_key = f"{tx.wallet_id}:{time.strftime('%Y-%m-%d')}"
def _merge(existing):
existing = existing or {}
daily = dict(existing.get("daily_totals", {}) or {})
prev = Decimal(str(daily.get(today_key, "0")))
daily[today_key] = str(prev + tx.amount)
timestamps = dict(existing.get("tx_timestamps", {}) or {})
vals = list(timestamps.get(tx.wallet_id, []) or [])
vals = [t for t in vals if now - t < 86400]
vals.append(now)
timestamps[tx.wallet_id] = vals
last = dict(existing.get("last_tx_time", {}) or {})
last[tx.wallet_id] = now
return {
"frozen": bool(existing.get("frozen", self._frozen)),
"daily_totals": daily,
"tx_timestamps": timestamps,
"last_tx_time": last,
}
new_state = update_json(self._state_path, {}, _merge)
self._frozen = bool(new_state.get("frozen", False))
self._daily_totals = defaultdict(Decimal, {k: Decimal(str(v)) for k, v in new_state.get("daily_totals", {}).items()})
self._tx_timestamps = defaultdict(list, new_state.get("tx_timestamps", {}) or {})
self._last_tx_time = new_state.get("last_tx_time", {}) or {}
def _load_state(self) -> None:
try:
data = read_json(self._state_path, {})
self._frozen = bool(data.get("frozen", False))
self._daily_totals = defaultdict(
Decimal,
{k: Decimal(str(v)) for k, v in (data.get("daily_totals", {}) or {}).items()},
)
self._tx_timestamps = defaultdict(list, data.get("tx_timestamps", {}) or {})
self._last_tx_time = data.get("last_tx_time", {}) or {}
except Exception as e:
logger.warning("Failed to load wallet policy state: %s", e)
def _save_state(self) -> None:
try:
frozen = self._frozen
daily = {k: str(v) for k, v in self._daily_totals.items()}
timestamps = {k: list(v) for k, v in self._tx_timestamps.items()}
last = dict(self._last_tx_time)
def _merge(existing):
existing = existing or {}
# freeze/unfreeze should not destroy other fields; keep latest known
return {
"frozen": frozen,
"daily_totals": daily or dict(existing.get("daily_totals", {}) or {}),
"tx_timestamps": timestamps or dict(existing.get("tx_timestamps", {}) or {}),
"last_tx_time": last or dict(existing.get("last_tx_time", {}) or {}),
}
update_json(self._state_path, {}, _merge)
except Exception as e:
logger.warning("Failed to save wallet policy state: %s", e)

101
wallet/runtime.py Normal file
View File

@@ -0,0 +1,101 @@
"""Shared wallet runtime.
Provides a single configured WalletManager + PolicyEngine per process so all
entry points (CLI, tools, approvals, gateway) share the same provider setup,
policy configuration, and persisted state.
"""
from __future__ import annotations
import json
import logging
import os
from pathlib import Path
from typing import Optional, Tuple
logger = logging.getLogger(__name__)
_runtime: Optional[tuple] = None
def _hermes_home() -> Path:
return Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
def _wallet_state_dir() -> Path:
p = _hermes_home() / "wallet"
p.mkdir(parents=True, exist_ok=True)
return p
def _load_wallet_config() -> dict:
try:
from hermes_cli.config import load_config
cfg = load_config() or {}
return cfg.get("wallet", {}) or {}
except Exception:
return {}
def _policy_overrides_from_config(wallet_cfg: dict) -> dict:
# Documented config is not yet fully per-wallet. Support a minimal global map now.
overrides = wallet_cfg.get("policies", {}) or {}
agent_cfg = wallet_cfg.get("agent_wallet", {}) or {}
# Map a few friendly config keys into policy override shape.
mapped = dict(overrides)
if agent_cfg.get("max_per_tx_native") is not None:
mapped.setdefault("spending_limit", {})["max_native"] = str(agent_cfg["max_per_tx_native"])
if agent_cfg.get("daily_limit_native") is not None:
mapped.setdefault("daily_limit", {})["max_native"] = str(agent_cfg["daily_limit_native"])
if agent_cfg.get("auto_approve_below_native") is not None:
mapped.setdefault("require_approval", {})["above_native"] = str(agent_cfg["auto_approve_below_native"])
return mapped
def get_runtime():
global _runtime
if _runtime is not None:
return _runtime
from keystore.client import get_keystore
from wallet.manager import WalletManager
from wallet.policy import PolicyEngine
ks = get_keystore()
if not ks.is_unlocked:
try:
if not ks.ensure_unlocked(interactive=False):
return None, None
except Exception:
return None, None
mgr = WalletManager(ks, state_dir=_wallet_state_dir())
wallet_cfg = _load_wallet_config()
policy = PolicyEngine(
policies=_policy_overrides_from_config(wallet_cfg),
state_path=_wallet_state_dir() / "policy_state.json",
)
rpc_overrides = wallet_cfg.get("rpc_endpoints", {}) or {}
try:
from wallet.chains.evm import EVMProvider, EVM_CHAINS
for chain_id, config in EVM_CHAINS.items():
mgr.register_provider(chain_id, EVMProvider(config, rpc_url_override=rpc_overrides.get(chain_id, "")))
except ImportError:
pass
try:
from wallet.chains.solana import SolanaProvider, SOLANA_CHAINS
for chain_id, config in SOLANA_CHAINS.items():
mgr.register_provider(chain_id, SolanaProvider(config, rpc_url_override=rpc_overrides.get(chain_id, "")))
except ImportError:
pass
_runtime = (mgr, policy)
return _runtime
def reset_runtime() -> None:
global _runtime
_runtime = None

View File

@@ -0,0 +1,276 @@
---
sidebar_position: 18
---
# Crypto Wallet
Give your agent its own crypto wallet. Hermes can hold funds, check balances, and send native tokens on Solana and EVM chains — with encrypted key storage and policy-controlled spending limits.
The agent **never has access to private keys**. Keys are encrypted at rest in a local keystore, and every transaction goes through a policy engine that enforces spending limits, rate limits, and owner approval thresholds.
## Installation
The wallet is an optional extra — install what you need:
```bash
# EVM chains (Ethereum, Base, Polygon, Arbitrum, Optimism)
pip install 'hermes-agent[wallet]'
# + Solana support
pip install 'hermes-agent[wallet-solana]'
```
## Setup
### 1. Initialize the encrypted keystore
The keystore holds all your secrets (API keys, wallet private keys) encrypted with a master passphrase.
```bash
hermes keystore init
```
You'll be prompted to create a passphrase. This is needed each time Hermes starts. To avoid typing it every time:
```bash
# Save to your OS credential store (macOS Keychain, Windows Credential Locker,
# GNOME/KDE Secret Service, or Linux keyctl when available)
hermes keystore remember
```
For **headless/Docker/systemd** deployments where no credential store or TTY is available,
you can set `HERMES_KEYSTORE_PASSPHRASE` as an environment variable. This is a conscious
security tradeoff — the passphrase is visible in the process environment — and should only
be used for unattended deployments:
```bash
# Headless/Docker/systemd only — not recommended for interactive use
export HERMES_KEYSTORE_PASSPHRASE="your-passphrase"
```
Hermes intentionally does **not** fall back to a machine-derived encrypted file
for remembered passphrases. In the current same-user execution model, that would
be derivable by the local agent process and would weaken the keystore boundary.
```
### 2. Create a wallet
```bash
# Create a user wallet (all sends require your approval)
hermes wallet create --chain solana
# Or create an agent wallet (auto-approves within policy limits)
hermes wallet create-agent --chain solana --label "Trading Bot"
```
:::tip Fresh wallets recommended
We recommend creating fresh wallets for your agent rather than importing personal wallets. Send the agent some tokens to get started — keep your personal funds separate.
:::
### 3. Fund the wallet
```bash
hermes wallet fund
```
This displays the deposit address. Send tokens to it from your personal wallet or an exchange.
### 4. Enable the wallet toolset
Add `wallet` to your toolsets in `~/.hermes/config.yaml`:
```yaml
toolsets:
- hermes-cli
- wallet
```
Or pass it at runtime:
```bash
hermes chat -t hermes-cli,wallet
```
## Agent Tools
Once the wallet toolset is enabled, the agent gets these tools:
| Tool | Description |
|------|-------------|
| `wallet_list` | List all wallets with addresses and balances |
| `wallet_balance` | Check balance of a specific wallet |
| `wallet_address` | Get a wallet's deposit address (for sharing / receiving) |
| `wallet_send` | Send native tokens — goes through the policy engine |
| `wallet_estimate_gas` | Estimate transaction fees |
| `wallet_history` | View recent transaction history |
| `wallet_networks` | List supported blockchain networks |
The agent can check its own balances, share its address to receive funds, estimate fees, and initiate transfers. It **cannot** read private keys, bypass spending policies, or disable the kill switch.
## CLI Commands
```
hermes wallet create Create a new wallet (fresh keypair)
hermes wallet create-agent Create an agent wallet (auto-approve within limits)
hermes wallet import Import wallet from exported private key (migration)
hermes wallet export Export private key for migration to another machine
hermes wallet list List all wallets with balances
hermes wallet balance Check a wallet's balance
hermes wallet send <to> <amount> Send tokens (interactive confirmation)
hermes wallet fund Show deposit address for receiving tokens
hermes wallet history View transaction history
hermes wallet freeze Kill switch — block ALL transactions
hermes wallet unfreeze Resume after freeze
hermes wallet status Overview of wallet state
```
## Keystore Commands
The keystore manages all encrypted secrets (API keys, wallet keys):
```
hermes keystore init Create a new encrypted keystore
hermes keystore list List stored secrets (names only, no values)
hermes keystore set <name> Add or update a secret
hermes keystore show <name> Decrypt and display a secret
hermes keystore delete <name> Remove a secret
hermes keystore set-category Change a secret's access category
hermes keystore migrate Import secrets from .env file
hermes keystore remember Cache passphrase in OS credential store
hermes keystore forget Remove cached passphrase
hermes keystore change-passphrase Re-encrypt with a new passphrase
hermes keystore audit Show access log
hermes keystore status Show keystore status
```
## Security Model
### Encryption
- Master key derived from your passphrase via **Argon2id** (memory-hard KDF, 64MB)
- Each secret encrypted with **XSalsa20-Poly1305** via libsodium SecretBox (AEAD, random nonce per write)
- Master key held in memory only — never written to disk
- Keystore DB file permissions: `0600`, directory: `0700`
### Secret Categories
| Category | Who can access | Examples |
|----------|---------------|----------|
| `injectable` | Agent (via `os.environ`) | API keys — `OPENROUTER_API_KEY` |
| `gated` | Agent on request (logged) | `GITHUB_TOKEN` |
| `sealed` | **Never** the agent | Wallet private keys |
| `user_only` | CLI only | `SUDO_PASSWORD` |
### Policy Engine
Every transaction is evaluated against a configurable set of policies:
| Policy | Description | Default (agent wallet) |
|--------|-------------|----------------------|
| `spending_limit` | Max per transaction | 1.0 native token |
| `daily_limit` | Aggregate daily cap | 5.0 native token |
| `rate_limit` | Max transactions per window | 5 per hour |
| `cooldown` | Minimum time between txns | 30 seconds |
| `require_approval` | Owner approval above threshold | 0.5 native token |
| `allowed_recipients` | Address whitelist | — |
| `blocked_recipients` | Address blacklist | — |
**User wallets** require owner approval for all transactions by default.
**Agent wallets** auto-approve within limits — transactions above the threshold trigger an owner approval prompt.
### Kill Switch
```bash
hermes wallet freeze
```
Instantly blocks all transactions across all wallets. No policy exceptions. Resume with `hermes wallet unfreeze`.
## Transaction Approval
When a transaction requires approval:
**CLI mode:** An interactive prompt appears with the transaction details and approve/deny choices — identical to the dangerous command approval prompt.
**Gateway mode (Telegram/Discord/etc.):** The transaction summary is shown with instructions to reply `/approve` or `/deny`.
## Supported Networks
### Mainnets
- Ethereum (ETH)
- Base (ETH)
- Polygon (POL)
- Arbitrum One (ETH)
- Optimism (ETH)
- Solana (SOL)
### Testnets
- Ethereum Sepolia
- Base Sepolia
- Solana Devnet
### Custom RPC Endpoints
Override default RPC endpoints in `config.yaml`:
```yaml
wallet:
rpc_endpoints:
solana: "https://my-custom-rpc.example.com"
ethereum: "https://eth-mainnet.alchemyapi.io/v2/YOUR_KEY"
```
## Migration Between Machines
To move a wallet to a new machine:
**On the source machine:**
```bash
hermes wallet export --chain solana
# Re-enter your keystore passphrase
# Private key is displayed — copy it securely
```
**On the destination machine:**
```bash
hermes keystore init # Set up keystore (if not already done)
hermes wallet import --chain solana --type agent
# Paste the private key when prompted
```
:::warning
The exported private key gives full control of the wallet. Never share it, transmit it over unencrypted channels, or store it in plaintext.
:::
## Configuration
Full wallet configuration in `~/.hermes/config.yaml`:
```yaml
wallet:
enabled: true
default_chain: solana
# Override default RPC endpoints
rpc_endpoints:
solana: "https://api.mainnet-beta.solana.com"
ethereum: "https://eth.llamarpc.com"
# Minimal policy overrides currently supported at runtime
# (global/shared state, not per-wallet yet)
agent_wallet:
enabled: true
auto_approve_below_native: "0.5" # maps to require_approval.above_native
daily_limit_native: "5.0" # maps to daily_limit.max_native
max_per_tx_native: "1.0" # maps to spending_limit.max_native
```
:::note
Per-wallet policy management and richer policy configuration are not fully surfaced yet. Today Hermes supports:
- runtime RPC endpoint overrides via `wallet.rpc_endpoints`
- a minimal set of global agent-wallet policy overrides via `wallet.agent_wallet`
- durable freeze/rate-limit/daily-limit state across CLI invocations
More granular per-wallet policy editing is planned follow-up work.
:::