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

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

Validation: 134 targeted tests passing
2026-03-29 08:38:29 +10:00

59 lines
1.6 KiB
Python

"""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