mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-30 07:51:45 +08:00
Compare commits
17 Commits
fix/plugin
...
feat/keyst
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2bb11ab4a | ||
|
|
24852c6789 | ||
|
|
4f419585b1 | ||
|
|
22aadaa56f | ||
|
|
79d7cec37a | ||
|
|
712bdfb949 | ||
|
|
5b16fa8621 | ||
|
|
fe325c1b40 | ||
|
|
d83ea4883b | ||
|
|
07808ca7f5 | ||
|
|
253c7abbe9 | ||
|
|
3fef2fd3ee | ||
|
|
7e1a05b475 | ||
|
|
53acc4c238 | ||
|
|
182ee2e08e | ||
|
|
ffefd57719 | ||
|
|
8fd434037e |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -58,3 +58,8 @@ mini-swe-agent/
|
||||
# Nix
|
||||
.direnv/
|
||||
result
|
||||
|
||||
# Keystore (encrypted secrets — never commit)
|
||||
keystore/secrets.db
|
||||
keystore/.credential
|
||||
*.db-journal
|
||||
|
||||
1118
.plans/wallet-architecture.md
Normal file
1118
.plans/wallet-architecture.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
|
||||
33
README.md
33
README.md
@@ -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
31
cli.py
@@ -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
83
docs/wallet.md
Normal 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`.
|
||||
103
gateway/run.py
103
gateway/run.py
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
15
keystore/__init__.py
Normal 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
111
keystore/categories.py
Normal 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
507
keystore/cli.py
Normal 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
439
keystore/client.py
Normal 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
|
||||
367
keystore/credential_store.py
Normal file
367
keystore/credential_store.py
Normal 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
672
keystore/store.py
Normal 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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
189
tests/gateway/test_keystore_injection.py
Normal file
189
tests/gateway/test_keystore_injection.py
Normal 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"
|
||||
0
tests/keystore/__init__.py
Normal file
0
tests/keystore/__init__.py
Normal file
46
tests/keystore/test_categories.py
Normal file
46
tests/keystore/test_categories.py
Normal 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}"
|
||||
197
tests/keystore/test_client.py
Normal file
197
tests/keystore/test_client.py
Normal 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
|
||||
172
tests/keystore/test_credential_store.py
Normal file
172
tests/keystore/test_credential_store.py
Normal 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
|
||||
32
tests/keystore/test_credential_store_security.py
Normal file
32
tests/keystore/test_credential_store_security.py
Normal 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
|
||||
270
tests/keystore/test_store.py
Normal file
270
tests/keystore/test_store.py
Normal 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
0
tests/wallet/__init__.py
Normal file
214
tests/wallet/test_manager.py
Normal file
214
tests/wallet/test_manager.py
Normal 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()
|
||||
166
tests/wallet/test_persistence.py
Normal file
166
tests/wallet/test_persistence.py
Normal 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
150
tests/wallet/test_policy.py
Normal 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
547
tools/wallet_tool.py
Normal 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="🌐",
|
||||
)
|
||||
@@ -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
722
uv.lock
generated
@@ -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
20
wallet/__init__.py
Normal 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
132
wallet/approval.py
Normal 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
102
wallet/chains/__init__.py
Normal 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
241
wallet/chains/evm.py
Normal 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
177
wallet/chains/solana.py
Normal 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
486
wallet/cli.py
Normal 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
58
wallet/file_state.py
Normal 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
414
wallet/manager.py
Normal 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
328
wallet/policy.py
Normal 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
101
wallet/runtime.py
Normal 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
|
||||
276
website/docs/user-guide/features/wallet.md
Normal file
276
website/docs/user-guide/features/wallet.md
Normal 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.
|
||||
:::
|
||||
Reference in New Issue
Block a user