solvent-agent 0.2.0__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. {solvent_agent-0.2.0/solvent_agent.egg-info → solvent_agent-0.2.2}/PKG-INFO +2 -2
  2. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/README.md +1 -1
  3. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/__init__.py +1 -1
  4. solvent_agent-0.2.2/solvent/access.py +128 -0
  5. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/agent.py +15 -4
  6. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/chat.py +2 -1
  7. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/cli.py +4 -3
  8. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/dashboard.py +55 -40
  9. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/export.py +44 -2
  10. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/finance.py +2 -1
  11. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/gateway.py +27 -9
  12. solvent_agent-0.2.2/solvent/money.py +36 -0
  13. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/server.py +223 -31
  14. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/stages.py +49 -28
  15. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/stripe_client.py +55 -4
  16. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/treasury.py +39 -22
  17. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/webhook_log.py +67 -11
  18. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/worker.py +2 -1
  19. {solvent_agent-0.2.0 → solvent_agent-0.2.2/solvent_agent.egg-info}/PKG-INFO +2 -2
  20. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent_agent.egg-info/SOURCES.txt +5 -0
  21. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_dashboard_ops_xss.py +48 -0
  22. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_export.py +48 -0
  23. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_gateway.py +30 -0
  24. solvent_agent-0.2.2/tests/test_money.py +78 -0
  25. solvent_agent-0.2.2/tests/test_route_security.py +519 -0
  26. solvent_agent-0.2.2/tests/test_server_job_submission.py +152 -0
  27. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_stages.py +14 -0
  28. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_treasury.py +9 -0
  29. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_webhook_log.py +43 -4
  30. solvent_agent-0.2.2/tests/test_webhook_security.py +329 -0
  31. solvent_agent-0.2.0/tests/test_server_job_submission.py +0 -76
  32. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/LICENSE +0 -0
  33. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/pyproject.toml +0 -0
  34. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/setup.cfg +0 -0
  35. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/__main__.py +0 -0
  36. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/_filelock.py +0 -0
  37. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/alerts.py +0 -0
  38. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/backlog.py +0 -0
  39. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/calibration.py +0 -0
  40. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/capacity.py +0 -0
  41. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/channels/__init__.py +0 -0
  42. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/channels/telegram.py +0 -0
  43. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/checkout.py +0 -0
  44. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/config.py +0 -0
  45. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/config_cmd.py +0 -0
  46. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/customers.py +0 -0
  47. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/dashboard_chat.py +0 -0
  48. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/delivery.py +0 -0
  49. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/doctor.py +0 -0
  50. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/event_hub.py +0 -0
  51. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/guardrail_cmd.py +0 -0
  52. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/guardrails.py +0 -0
  53. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/init.py +0 -0
  54. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/intake.py +0 -0
  55. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/job_cmd.py +0 -0
  56. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/jobs.py +0 -0
  57. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/logs.py +0 -0
  58. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/memory.py +0 -0
  59. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/nemotron.py +0 -0
  60. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/notifications.py +0 -0
  61. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/observability.py +0 -0
  62. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/onboarding.py +0 -0
  63. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/optimize.py +0 -0
  64. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/pairing.py +0 -0
  65. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/paths.py +0 -0
  66. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/pricing.py +0 -0
  67. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/products.py +0 -0
  68. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/py.typed +0 -0
  69. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/qr.py +0 -0
  70. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/quality.py +0 -0
  71. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/queue.py +0 -0
  72. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/quote_cmd.py +0 -0
  73. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/rate_limit.py +0 -0
  74. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/receipt.py +0 -0
  75. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/reconcile.py +0 -0
  76. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/review.py +0 -0
  77. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/security.py +0 -0
  78. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/service.py +0 -0
  79. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/simulate.py +0 -0
  80. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/status.py +0 -0
  81. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/templates/workspace/AGENTS.md +0 -0
  82. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/templates/workspace/BRAIN.md +0 -0
  83. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/templates/workspace/SOUL.md +0 -0
  84. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/templates/workspace/skills/commission-research/SKILL.md +0 -0
  85. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/tools.py +0 -0
  86. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/upgrade.py +0 -0
  87. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent/workspace.py +0 -0
  88. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent_agent.egg-info/dependency_links.txt +0 -0
  89. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent_agent.egg-info/entry_points.txt +0 -0
  90. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent_agent.egg-info/requires.txt +0 -0
  91. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/solvent_agent.egg-info/top_level.txt +0 -0
  92. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_agent.py +0 -0
  93. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_alerts.py +0 -0
  94. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_backlog.py +0 -0
  95. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_calibration.py +0 -0
  96. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_capacity.py +0 -0
  97. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_chat_tools.py +0 -0
  98. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_checkout.py +0 -0
  99. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_cli_routing.py +0 -0
  100. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_cogs_reconciliation.py +0 -0
  101. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_config.py +0 -0
  102. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_config_cmd.py +0 -0
  103. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_counter_offer.py +0 -0
  104. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_customers.py +0 -0
  105. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_dashboard_chat.py +0 -0
  106. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_dashboard_financials.py +0 -0
  107. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_dashboard_security.py +0 -0
  108. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_delivery.py +0 -0
  109. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_doctor.py +0 -0
  110. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_event_hub.py +0 -0
  111. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_filelock.py +0 -0
  112. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_finance.py +0 -0
  113. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_guardrails.py +0 -0
  114. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_init.py +0 -0
  115. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_intake.py +0 -0
  116. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_job_cmd.py +0 -0
  117. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_jobs.py +0 -0
  118. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_logs.py +0 -0
  119. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_memory.py +0 -0
  120. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_metrics_block.py +0 -0
  121. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_nemotron_parse.py +0 -0
  122. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_notifications.py +0 -0
  123. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_observability.py +0 -0
  124. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_onboarding.py +0 -0
  125. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_optimize.py +0 -0
  126. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_packaging.py +0 -0
  127. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_paths.py +0 -0
  128. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_pricing.py +0 -0
  129. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_products.py +0 -0
  130. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_qr.py +0 -0
  131. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_quality.py +0 -0
  132. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_queue.py +0 -0
  133. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_rate_limit.py +0 -0
  134. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_receipt.py +0 -0
  135. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_reconcile.py +0 -0
  136. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_research_brief.py +0 -0
  137. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_retry.py +0 -0
  138. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_review.py +0 -0
  139. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_runtime_config_paths.py +0 -0
  140. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_security.py +0 -0
  141. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_server.py +0 -0
  142. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_simulate.py +0 -0
  143. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_spend_policy.py +0 -0
  144. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_status.py +0 -0
  145. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_stripe_client.py +0 -0
  146. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_telegram_pairing.py +0 -0
  147. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_tools.py +0 -0
  148. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_upgrade.py +0 -0
  149. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_upgrade_hint.py +0 -0
  150. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_worker.py +0 -0
  151. {solvent_agent-0.2.0 → solvent_agent-0.2.2}/tests/test_workspace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: solvent-agent
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: A self-funding analyst agent: earns on Stripe, fulfils with NVIDIA Nemotron, spends within guardrails.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/ianalloway/solvent-agent
@@ -602,7 +602,7 @@ With both keys set:
602
602
  | `SOLVENT_HOME` | Where runtime data and `.solvent` configuration (treasury DB, policies, reports, dashboard, logs) are stored. Defaults to the repo when run from a checkout, else `~/.solvent` |
603
603
  | `NVIDIA_API_KEY` | Live Nemotron inference (`nvapi-...`) |
604
604
  | `STRIPE_API_KEY` | Stripe test key (`sk_test_...`) |
605
- | `STRIPE_WEBHOOK_SECRET` | Optional webhook verification |
605
+ | `STRIPE_WEBHOOK_SECRET` | Required to accept `/webhooks/stripe` events (signature is verified before anything is stored) |
606
606
  | `STRIPE_PAYMENT_POLL_TIMEOUT` | Seconds to wait for payment (default `120`) |
607
607
  | `STRIPE_PAYMENT_POLL_INTERVAL` | Poll interval in seconds (default `2`) |
608
608
  | `SOLVENT_FORCE_STRIPE_SIMULATE` | Force offline simulate mode even with a key |
@@ -561,7 +561,7 @@ With both keys set:
561
561
  | `SOLVENT_HOME` | Where runtime data and `.solvent` configuration (treasury DB, policies, reports, dashboard, logs) are stored. Defaults to the repo when run from a checkout, else `~/.solvent` |
562
562
  | `NVIDIA_API_KEY` | Live Nemotron inference (`nvapi-...`) |
563
563
  | `STRIPE_API_KEY` | Stripe test key (`sk_test_...`) |
564
- | `STRIPE_WEBHOOK_SECRET` | Optional webhook verification |
564
+ | `STRIPE_WEBHOOK_SECRET` | Required to accept `/webhooks/stripe` events (signature is verified before anything is stored) |
565
565
  | `STRIPE_PAYMENT_POLL_TIMEOUT` | Seconds to wait for payment (default `120`) |
566
566
  | `STRIPE_PAYMENT_POLL_INTERVAL` | Poll interval in seconds (default `2`) |
567
567
  | `SOLVENT_FORCE_STRIPE_SIMULATE` | Force offline simulate mode even with a key |
@@ -1,3 +1,3 @@
1
1
  """SOLVENT — a self-funding analyst agent. See run_demo.py."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.2.2"
@@ -0,0 +1,128 @@
1
+ """Request-origin and abuse-limiting helpers for the HTTP server.
2
+
3
+ Two small, framework-free pieces that ``server.py`` builds its gates on:
4
+
5
+ * :func:`is_local_request` -- the "is this really the operator on this machine"
6
+ check behind the local-only routes. Trusting the peer address alone is wrong
7
+ behind a reverse proxy (every proxied request arrives from 127.0.0.1), so a
8
+ request that carries proxy headers is never treated as local.
9
+ * :class:`FailureLimiter` -- a tiny in-memory failed-attempt limiter used to slow
10
+ down guessing of single-use pairing tokens.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import ipaddress
16
+ import os
17
+ import threading
18
+ import time
19
+ from collections import deque
20
+ from collections.abc import Mapping
21
+ from typing import Any
22
+
23
+ #: Environment variable that controls how "local" is decided.
24
+ #:
25
+ #: * ``auto`` (default): the peer address is loopback **and** the request carries
26
+ #: no proxy/forwarding header.
27
+ #: * ``never``: nothing is local. Local-only routes then need a delivery token
28
+ #: (or the dashboard token). Use this when a reverse proxy fronts the server.
29
+ #: * ``peer``: legacy behaviour -- trust the peer address only. Only safe when
30
+ #: the server is *not* behind a proxy.
31
+ LOCAL_ACCESS_ENV = "SOLVENT_LOCAL_ACCESS"
32
+
33
+ #: Headers that a reverse proxy / CDN adds. If any is present the request did not
34
+ #: originate on this machine, whatever the peer address says.
35
+ PROXY_HEADERS = (
36
+ "forwarded",
37
+ "x-forwarded-for",
38
+ "x-forwarded-host",
39
+ "x-forwarded-proto",
40
+ "x-forwarded-port",
41
+ "x-forwarded-server",
42
+ "x-real-ip",
43
+ "x-client-ip",
44
+ "x-cluster-client-ip",
45
+ "cf-connecting-ip",
46
+ "true-client-ip",
47
+ "via",
48
+ )
49
+
50
+ # "testclient" is the peer name Starlette's TestClient reports.
51
+ _LOCAL_NAMES = frozenset({"localhost", "testclient"})
52
+
53
+
54
+ def _is_loopback(host: str) -> bool:
55
+ if host.lower() in _LOCAL_NAMES:
56
+ return True
57
+ try:
58
+ ip = ipaddress.ip_address(host.split("%", 1)[0])
59
+ except ValueError:
60
+ return False
61
+ if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
62
+ ip = ip.ipv4_mapped
63
+ return ip.is_loopback
64
+
65
+
66
+ def local_access_mode(env: Mapping[str, str] | None = None) -> str:
67
+ """The configured local-access mode: ``auto`` (default), ``never`` or ``peer``."""
68
+ raw = (env if env is not None else os.environ).get(LOCAL_ACCESS_ENV, "auto")
69
+ mode = raw.strip().lower()
70
+ return mode if mode in ("auto", "never", "peer") else "auto"
71
+
72
+
73
+ def peer_host(request: Any) -> str:
74
+ """The peer (socket) address of a request, or ``""``."""
75
+ client = getattr(request, "client", None)
76
+ return str(getattr(client, "host", "") or "") if client else ""
77
+
78
+
79
+ def has_proxy_headers(request: Any) -> bool:
80
+ headers = getattr(request, "headers", None) or {}
81
+ return any(name in headers for name in PROXY_HEADERS)
82
+
83
+
84
+ def is_local_request(request: Any, env: Mapping[str, str] | None = None) -> bool:
85
+ """True only for a request that genuinely originates on this machine."""
86
+ if request is None:
87
+ return False
88
+ mode = local_access_mode(env)
89
+ if mode == "never":
90
+ return False
91
+ if not _is_loopback(peer_host(request)):
92
+ return False
93
+ if mode == "peer":
94
+ return True
95
+ return not has_proxy_headers(request)
96
+
97
+
98
+ class FailureLimiter:
99
+ """Block a key after ``max_failures`` failures inside a sliding ``window`` (seconds)."""
100
+
101
+ def __init__(self, max_failures: int, window: float, max_keys: int = 10_000):
102
+ self.max_failures = max_failures
103
+ self.window = window
104
+ self.max_keys = max_keys
105
+ self._hits: dict[str, deque[float]] = {}
106
+ self._lock = threading.Lock()
107
+
108
+ def _prune(self, key: str, now: float) -> deque[float]:
109
+ hits = self._hits.setdefault(key, deque())
110
+ while hits and now - hits[0] > self.window:
111
+ hits.popleft()
112
+ if not hits:
113
+ self._hits.pop(key, None)
114
+ return hits
115
+
116
+ def blocked(self, key: str, now: float | None = None) -> bool:
117
+ now = time.monotonic() if now is None else now
118
+ with self._lock:
119
+ return len(self._prune(key, now)) >= self.max_failures
120
+
121
+ def record_failure(self, key: str, now: float | None = None) -> None:
122
+ now = time.monotonic() if now is None else now
123
+ with self._lock:
124
+ if key not in self._hits and len(self._hits) >= self.max_keys:
125
+ # Bound memory: drop the stalest key.
126
+ oldest = min(self._hits, key=lambda k: self._hits[k][-1] if self._hits[k] else 0)
127
+ self._hits.pop(oldest, None)
128
+ self._hits.setdefault(key, deque()).append(now)
@@ -22,7 +22,7 @@ from collections.abc import Callable
22
22
  from .calibration import calibration_factor
23
23
  from .guardrails import Guardrails
24
24
  from .pricing import PricingPolicy
25
- from .stages import StageRunner, _job_id_of, validate_and_coerce_job
25
+ from .stages import StageRunner, _job_id_of, coerce_job, screen_and_record
26
26
  from .stripe_client import StripeClient
27
27
  from .treasury import Treasury
28
28
 
@@ -79,10 +79,21 @@ class Solvent:
79
79
  return self._runner.advance_job(job_id)
80
80
 
81
81
  def enqueue_job(self, job: dict) -> dict:
82
- """Validate and persist a job for async worker processing."""
83
- validated, err = validate_and_coerce_job(job, self.t)
82
+ """Validate and persist a new job for async worker processing.
83
+
84
+ Invalid input is declined before anything is written. A valid job then
85
+ claims its id with an atomic insert, so a submission can never
86
+ overwrite or restart an existing job.
87
+ """
88
+ coerced, err = coerce_job(job)
89
+ if err:
90
+ return self._emit(stage="declined", job_id=_job_id_of(job, None), reason=err)
91
+ assert coerced is not None
92
+ if not self.t.insert_job(coerced["id"], "received"):
93
+ return {"stage": "declined", "job_id": coerced["id"], "reason": "job id already exists"}
94
+ validated, err = screen_and_record(coerced, self.t)
84
95
  if err:
85
- return self._emit(stage="declined", job_id=_job_id_of(job, validated), reason=err)
96
+ return self._emit(stage="declined", job_id=coerced["id"], reason=err)
86
97
  assert validated is not None
87
98
  q = self._runner._stage_quote(validated)
88
99
  if q.get("stage") == "declined" or not q.get("accept"):
@@ -11,6 +11,7 @@ import uuid
11
11
  from . import nemotron, tools
12
12
  from .agent import Solvent
13
13
  from .memory import SessionMemory
14
+ from .money import dollars_to_cents
14
15
  from .pricing import quote
15
16
  from .security import InputValidationError, PromptInjectionError, sanitise_prompt_input
16
17
  from .treasury import fmt
@@ -145,7 +146,7 @@ def _merge_commission_slots(agent: Solvent, session_id: str, text: str) -> dict:
145
146
  pending.setdefault("topic", text.strip()[:200])
146
147
  m = _BUDGET_RE.search(text)
147
148
  if m:
148
- pending["budget_cents"] = int(float(m.group(1)) * 100)
149
+ pending["budget_cents"] = dollars_to_cents(m.group(1))
149
150
  em = _EMAIL_RE.search(text)
150
151
  if em:
151
152
  pending["customer_email"] = em.group(0)
@@ -29,6 +29,7 @@ from solvent.config import (
29
29
  load_config,
30
30
  )
31
31
  from solvent.jobs import SAMPLE_JOBS
32
+ from solvent.money import dollars_to_cents
32
33
  from solvent.onboarding import run_wizard, should_skip_onboarding, wants_reconfigure
33
34
  from solvent.treasury import fmt
34
35
 
@@ -205,7 +206,7 @@ def run_interactive_mode(seed_cents: int = 10_000, fresh: bool = True):
205
206
  continue
206
207
  try:
207
208
  fund_amt = float(parts[1])
208
- fund_cents = int(fund_amt * 100)
209
+ fund_cents = dollars_to_cents(fund_amt)
209
210
  if fund_cents <= 0:
210
211
  print(f"{C_RED}Fund amount must be positive.{C_RESET}\n")
211
212
  continue
@@ -233,7 +234,7 @@ def run_interactive_mode(seed_cents: int = 10_000, fresh: bool = True):
233
234
 
234
235
  budget_str = input(f"{C_CYAN}Client Budget in USD (e.g. 50.00):{C_RESET} $").strip()
235
236
  try:
236
- budget_cents = int(float(budget_str) * 100)
237
+ budget_cents = dollars_to_cents(budget_str)
237
238
  if budget_cents <= 0:
238
239
  print(f"{C_RED}Budget must be greater than 0.{C_RESET}\n")
239
240
  continue
@@ -341,7 +342,7 @@ def main():
341
342
  cfg = resolve_config(args)
342
343
  apply_config(cfg)
343
344
 
344
- seed_cents = int(args.seed * 100)
345
+ seed_cents = dollars_to_cents(args.seed)
345
346
  fresh = not args.keep_balance
346
347
 
347
348
  if args.interactive:
@@ -38,6 +38,8 @@ def _script_json(value: object) -> str:
38
38
  .replace("<", "\\u003c")
39
39
  .replace(">", "\\u003e")
40
40
  .replace("&", "\\u0026")
41
+ .replace("\u2028", "\\u2028")
42
+ .replace("\u2029", "\\u2029")
41
43
  )
42
44
 
43
45
 
@@ -1367,15 +1369,6 @@ def render(snapshot: dict, log: list[dict], *, live: bool = False) -> Path:
1367
1369
  let jobsData = {_script_json(jobs_data)};
1368
1370
  let currentOpenJobId = null;
1369
1371
 
1370
- function esc(value) {{
1371
- return String(value ?? "")
1372
- .replace(/&/g, "&amp;")
1373
- .replace(/</g, "&lt;")
1374
- .replace(/>/g, "&gt;")
1375
- .replace(/"/g, "&quot;")
1376
- .replace(/'/g, "&#39;");
1377
- }}
1378
-
1379
1372
  function openBriefModal(jobId) {{
1380
1373
  document.getElementById("modal-title").innerText = "Research Brief Preview: " + jobId;
1381
1374
  const iframe = document.getElementById("brief-iframe");
@@ -1398,21 +1391,28 @@ def render(snapshot: dict, log: list[dict], *, live: bool = False) -> Path:
1398
1391
  return;
1399
1392
  }}
1400
1393
 
1401
- let html = "";
1394
+ container.replaceChildren();
1402
1395
  jobIds.forEach(jobId => {{
1403
1396
  const job = jobsData[jobId] || {{}};
1404
- const title = job.title || `Research Brief (${{jobId}})`;
1405
- html += `
1406
- <div style="display:flex; justify-content:space-between; align-items:center; background:rgba(255,255,255,0.02); border:1px solid var(--color-border); padding:10px 14px; border-radius:10px; margin-bottom: 8px;">
1407
- <div style="display:flex; flex-direction:column; gap:2px; max-width:70%;">
1408
- <span style="font-size:11px; font-family:var(--font-mono); color:var(--color-text-muted);">${{esc(jobId)}}</span>
1409
- <span style="font-size:13px; font-weight:600; color:var(--color-text-primary); text-overflow:ellipsis; overflow:hidden; white-space:nowrap;">${{esc(title)}}</span>
1410
- </div>
1411
- <button class="btn btn-outline btn-sm" onclick="openBriefModal(${{esc(JSON.stringify(jobId))}})" style="padding:4px 8px; font-size:11px;">Preview</button>
1412
- </div>
1413
- `;
1397
+ const row = document.createElement("div");
1398
+ row.style.cssText = "display:flex; justify-content:space-between; align-items:center; background:rgba(255,255,255,0.02); border:1px solid var(--color-border); padding:10px 14px; border-radius:10px; margin-bottom: 8px;";
1399
+ const labels = document.createElement("div");
1400
+ labels.style.cssText = "display:flex; flex-direction:column; gap:2px; max-width:70%;";
1401
+ const idEl = document.createElement("span");
1402
+ idEl.style.cssText = "font-size:11px; font-family:var(--font-mono); color:var(--color-text-muted);";
1403
+ idEl.textContent = jobId;
1404
+ const titleEl = document.createElement("span");
1405
+ titleEl.style.cssText = "font-size:13px; font-weight:600; color:var(--color-text-primary); text-overflow:ellipsis; overflow:hidden; white-space:nowrap;";
1406
+ titleEl.textContent = job.title || `Research Brief (${{jobId}})`;
1407
+ labels.append(idEl, titleEl);
1408
+ const button = document.createElement("button");
1409
+ button.className = "btn btn-outline btn-sm";
1410
+ button.style.cssText = "padding:4px 8px; font-size:11px;";
1411
+ button.textContent = "Preview";
1412
+ button.addEventListener("click", () => openBriefModal(jobId));
1413
+ row.append(labels, button);
1414
+ container.appendChild(row);
1414
1415
  }});
1415
- container.innerHTML = html;
1416
1416
  }}
1417
1417
 
1418
1418
  // Auto-scroll terminal log to the bottom
@@ -1431,28 +1431,43 @@ def render(snapshot: dict, log: list[dict], *, live: bool = False) -> Path:
1431
1431
 
1432
1432
  document.getElementById("drawer-title").innerText = "Job Details: " + jobId;
1433
1433
 
1434
- let metaHTML = `
1435
- <div class="meta-row"><strong>Title:</strong> <span>${{esc(job.title || 'N/A')}}</span></div>
1436
- <div class="meta-row"><strong>Status:</strong> <span class="badge badge-${{esc(job.status)}}">${{esc(String(job.status).toUpperCase())}}</span></div>
1437
- <div class="meta-row"><strong>Client Budget:</strong> <span>${{formatCents(job.price)}}</span></div>
1438
- <div class="meta-row"><strong>Incurred COGS:</strong> <span>${{formatCents(getExpensesTotal(job.expenses))}}</span></div>
1439
- <div class="meta-row"><strong>Net P&L:</strong> <span class="${{job.pnl >= 0 ? 'green' : 'red'}}">${{job.pnl >= 0 ? '+' : ''}}${{formatCents(job.pnl)}}</span></div>
1440
- <div class="meta-row"><strong>Est. Margin:</strong> <span>${{esc(job.margin_pct)}}%</span></div>
1441
- `;
1434
+ const meta = document.getElementById("drawer-metadata");
1435
+ meta.replaceChildren();
1436
+ const metaRow = (label, value, className) => {{
1437
+ const row = document.createElement("div");
1438
+ row.className = "meta-row";
1439
+ const strong = document.createElement("strong");
1440
+ strong.textContent = label;
1441
+ const span = document.createElement("span");
1442
+ if (className) span.className = className;
1443
+ span.textContent = value;
1444
+ row.append(strong, " ", span);
1445
+ meta.appendChild(row);
1446
+ }};
1447
+ const status = String(job.status);
1448
+ metaRow("Title:", job.title || "N/A");
1449
+ metaRow("Status:", status.toUpperCase(), "badge badge-" + status.replace(/[^a-z_]/gi, ""));
1450
+ metaRow("Client Budget:", formatCents(job.price));
1451
+ metaRow("Incurred COGS:", formatCents(getExpensesTotal(job.expenses)));
1452
+ metaRow("Net P&L:", (job.pnl >= 0 ? "+" : "") + formatCents(job.pnl), job.pnl >= 0 ? "green" : "red");
1453
+ metaRow("Est. Margin:", String(job.margin_pct) + "%");
1442
1454
 
1443
1455
  if (job.expenses.length > 0) {{
1444
- metaHTML += `
1445
- <h4 style="margin-top: 16px; margin-bottom: 8px; font-size:12px; color:var(--color-text-muted)">ITEMIZED EXPENSES</h4>
1446
- <ul class="drawer-expenses-list">
1447
- `;
1456
+ const heading = document.createElement("h4");
1457
+ heading.style.cssText = "margin-top: 16px; margin-bottom: 8px; font-size:12px; color:var(--color-text-muted)";
1458
+ heading.textContent = "ITEMIZED EXPENSES";
1459
+ const list = document.createElement("ul");
1460
+ list.className = "drawer-expenses-list";
1448
1461
  job.expenses.forEach(e => {{
1449
- metaHTML += `<li><strong>${{esc(e.vendor)}}:</strong> -${{formatCents(e.amount)}} (${{esc(e.memo)}})</li>`;
1462
+ const item = document.createElement("li");
1463
+ const vendor = document.createElement("strong");
1464
+ vendor.textContent = String(e.vendor) + ":";
1465
+ item.append(vendor, ` -${{formatCents(e.amount)}} (${{e.memo}})`);
1466
+ list.appendChild(item);
1450
1467
  }});
1451
- metaHTML += '</ul>';
1468
+ meta.append(heading, list);
1452
1469
  }}
1453
1470
 
1454
- document.getElementById("drawer-metadata").innerHTML = metaHTML;
1455
-
1456
1471
  const hasBrief = Object.prototype.hasOwnProperty.call(briefs || {{}}, jobId);
1457
1472
  const reportText = hasBrief
1458
1473
  ? "_Brief generated. Use the hosted brief link with a delivery token to view full contents._"
@@ -1503,13 +1518,13 @@ def render(snapshot: dict, log: list[dict], *, live: bool = False) -> Path:
1503
1518
  html = html.replace(/\\*(.*?)\\*/g, '<em>$1</em>');
1504
1519
 
1505
1520
  // Double line breaks to paragraphs
1506
- const paragraphs = html.split(/\n\n+/);
1521
+ const paragraphs = html.split(/\\n\\n+/);
1507
1522
  html = paragraphs.map(p => {{
1508
1523
  p = p.trim();
1509
1524
  if (!p) return '';
1510
1525
  if (p.startsWith('<h') || p.startsWith('<ul') || p.startsWith('<li')) return p;
1511
- return `<p>${{p.replace(/\n/g, '<br>')}}</p>`;
1512
- }}).join('\n');
1526
+ return `<p>${{p.replace(/\\n/g, '<br>')}}</p>`;
1527
+ }}).join("\\n");
1513
1528
 
1514
1529
  return html;
1515
1530
  }}
@@ -228,8 +228,44 @@ _TABLES = {
228
228
  }
229
229
 
230
230
 
231
+ #: Free-text columns that can carry customer- or vendor-supplied strings. Only
232
+ #: these are neutralised, so numeric columns stay numeric for the spreadsheet.
233
+ _UNTRUSTED_TEXT_COLUMNS = {
234
+ "ledger": frozenset(
235
+ {"memo", "job_id", "vendor", "stripe_ref", "stripe_session_id", "stripe_link_id"}
236
+ ),
237
+ "jobs": frozenset(
238
+ {"id", "status", "topic", "customer_email", "current_stage", "error_reason", "deliverable_url"}
239
+ ),
240
+ "metrics": frozenset({"job_id", "decline_reason", "block_rule"}),
241
+ "customers": frozenset({"email"}),
242
+ }
243
+
244
+ _FORMULA_TRIGGERS = ("=", "+", "-", "@", "\t", "\r")
245
+ _LEADING_NOISE = re.compile(r"^[\s\x00-\x1f\x7f\u200b-\u200f\u2060\ufeff]*")
246
+
247
+
248
+ def neutralise_cell(value: Any) -> Any:
249
+ """Stop a spreadsheet from evaluating a text cell as a formula.
250
+
251
+ A string whose first significant character (after any leading whitespace
252
+ or control characters) is ``= + - @``, or that starts with a tab or
253
+ carriage return, is prefixed with a single quote. Non-strings pass through.
254
+ """
255
+ if not isinstance(value, str) or not value:
256
+ return value
257
+ significant = _LEADING_NOISE.sub("", value, count=1)
258
+ if value.startswith(_FORMULA_TRIGGERS) or significant.startswith(_FORMULA_TRIGGERS):
259
+ return "'" + value
260
+ return value
261
+
262
+
231
263
  def write_csv(data: dict[str, Any], out_dir: Path) -> list[Path]:
232
- """Write one CSV per table; returns the files written."""
264
+ """Write one CSV per table; returns the files written.
265
+
266
+ Untrusted text columns are passed through `neutralise_cell` so a job topic
267
+ or memo like ``=HYPERLINK(...)`` is shown as text, not run as a formula.
268
+ """
233
269
  out_dir.mkdir(parents=True, exist_ok=True)
234
270
  written = []
235
271
  for table, columns in _TABLES.items():
@@ -237,8 +273,14 @@ def write_csv(data: dict[str, Any], out_dir: Path) -> list[Path]:
237
273
  with path.open("w", encoding="utf-8", newline="") as fh:
238
274
  writer = csv.DictWriter(fh, fieldnames=list(columns), extrasaction="ignore")
239
275
  writer.writeheader()
276
+ text_columns = _UNTRUSTED_TEXT_COLUMNS.get(table, frozenset())
240
277
  for row in data[table]:
241
- writer.writerow(row)
278
+ writer.writerow(
279
+ {
280
+ key: neutralise_cell(value) if key in text_columns else value
281
+ for key, value in row.items()
282
+ }
283
+ )
242
284
  written.append(path)
243
285
  return written
244
286
 
@@ -23,6 +23,7 @@ import sys
23
23
  from collections.abc import Iterable
24
24
 
25
25
  from .treasury import LedgerEntry, Treasury, fmt
26
+ from .money import dollars_to_cents
26
27
 
27
28
  _PERIODS = ("day", "week", "month")
28
29
 
@@ -387,7 +388,7 @@ def main() -> None:
387
388
  )
388
389
  if "--reserve" in args:
389
390
  try:
390
- reserve_cents = int(float(args[args.index("--reserve") + 1]) * 100)
391
+ reserve_cents = dollars_to_cents(args[args.index("--reserve") + 1])
391
392
  except (ValueError, IndexError):
392
393
  print(usage)
393
394
  sys.exit(1)
@@ -10,6 +10,7 @@ from . import pairing
10
10
  from .agent import Solvent
11
11
  from .chat import format_job_notification, handle_message
12
12
  from .memory import SessionMemory
13
+ from .money import dollars_to_cents
13
14
  from .rate_limit import RateLimiter
14
15
  from .treasury import fmt
15
16
 
@@ -37,6 +38,11 @@ def notify(channel: str, external_id: str, text: str) -> None:
37
38
  pass
38
39
 
39
40
 
41
+ #: Commands that answer from local state without calling the model; only these
42
+ #: skip the per-user rate limit. Everything else, /quote included, is counted.
43
+ RATE_EXEMPT_COMMANDS = frozenset({"/help", "/status", "/jobs"})
44
+
45
+
40
46
  def _rate_ok(user_key: str) -> bool:
41
47
  allowed, _reason = _rate_limiter.check(user_key)
42
48
  return allowed
@@ -55,10 +61,16 @@ class Gateway:
55
61
  *,
56
62
  user_label: str | None = None,
57
63
  ) -> str:
64
+ user_key = f"{channel}:{external_id}"
65
+ if _rate_limiter.is_banned(user_key):
66
+ return "You are temporarily blocked from messaging this bot."
67
+
58
68
  session = self.memory.get_or_create(channel, external_id, user_label)
59
- is_command = text.strip().startswith("/")
69
+ stripped = text.strip()
70
+ is_command = stripped.startswith("/")
71
+ command = stripped.split(maxsplit=1)[0].lower() if is_command else ""
60
72
 
61
- if not is_command and not _rate_ok(f"{channel}:{external_id}"):
73
+ if command not in RATE_EXEMPT_COMMANDS and not _rate_ok(user_key):
62
74
  return "Rate limit exceeded. Please wait before sending more messages."
63
75
 
64
76
  if channel == "telegram" and not pairing.is_allowed(external_id, user_label):
@@ -82,7 +94,7 @@ class Gateway:
82
94
  cmd = parts[0].lower()
83
95
  arg = parts[1] if len(parts) > 1 else ""
84
96
 
85
- if cmd == "/help":
97
+ if cmd in ("/help", "/start"):
86
98
  return (
87
99
  "SOLVENT research business bot.\n"
88
100
  "/status — treasury snapshot\n"
@@ -106,6 +118,13 @@ class Gateway:
106
118
  f"{j['id']}: {j.get('status')} — {(j.get('topic') or '')[:40]}" for j in jobs
107
119
  )
108
120
  if cmd == "/pair" and arg.strip().lower() == "qr":
121
+ if channel == "telegram" and pairing.dm_policy() == "open":
122
+ # With an open DM policy any Telegram user reaches this command;
123
+ # minting a pairing token is an operator action.
124
+ return (
125
+ "Pairing QR codes are not available over an open Telegram DM policy. "
126
+ "Use /pair qr from the dashboard chat, or set SOLVENT_TELEGRAM_DM_POLICY=pairing."
127
+ )
109
128
  token = self.agent.t.create_openclaw_token(ttl=600)
110
129
  from . import qr as _qr
111
130
 
@@ -122,13 +141,14 @@ class Gateway:
122
141
  port = 443
123
142
  return _qr.render_token(token, host=host, port=port)
124
143
 
125
- if cmd == "/quote" and "|" in arg:
144
+ if cmd == "/quote":
145
+ if "|" not in arg:
146
+ return "Usage: /quote topic | 50.00"
126
147
  topic, budget_s = [x.strip() for x in arg.split("|", 1)]
127
148
  try:
128
- cents = int(float(budget_s) * 100)
149
+ cents = dollars_to_cents(budget_s)
129
150
  except ValueError:
130
151
  return "Usage: /quote topic | 50.00"
131
- from .chat import handle_message
132
152
 
133
153
  prompt = f"Quote a brief on '{topic}' with budget_cents={cents}. Use quote_brief tool."
134
154
  return handle_message(
@@ -139,9 +159,7 @@ class Gateway:
139
159
  channel=channel,
140
160
  )
141
161
 
142
- return handle_message(
143
- session["id"], text, agent=self.agent, memory=self.memory, channel=channel
144
- )
162
+ return f"Unknown command {cmd[:32]}. Send /help for the list of commands."
145
163
 
146
164
  def on_job_event(self, event: dict) -> None:
147
165
  """Push job lifecycle updates to sessions watching a job."""
@@ -0,0 +1,36 @@
1
+ """money.py — one shared dollars -> cents conversion.
2
+
3
+ ``int(dollars * 100)`` truncates binary-float error toward zero, so
4
+ ``int(19.99 * 100)`` is ``1998``, one cent short of what the user typed.
5
+ Every place that turns a user-supplied dollar amount into integer cents should
6
+ call :func:`dollars_to_cents` instead.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
12
+
13
+
14
+ def dollars_to_cents(amount: int | float | str | Decimal) -> int:
15
+ """Convert a dollar amount to integer cents, rounding half away from zero.
16
+
17
+ The value is read through its decimal string form (``Decimal(str(amount))``)
18
+ rather than its binary float value, so ``19.99`` is ``1999`` and ``0.29`` is
19
+ ``29``. Sub-cent amounts round half up: ``1.005`` is ``101``. Negative
20
+ amounts mirror positive ones (``-1.005`` is ``-101``).
21
+
22
+ Raises:
23
+ ValueError: if *amount* is not a finite number (``nan``, ``inf``,
24
+ garbage text, ``None``, a bool). ``ValueError`` matches what
25
+ ``float()`` raised for bad input, so existing ``except ValueError``
26
+ handlers keep working.
27
+ """
28
+ if isinstance(amount, bool) or amount is None:
29
+ raise ValueError(f"not a dollar amount: {amount!r}")
30
+ try:
31
+ value = amount if isinstance(amount, Decimal) else Decimal(str(amount).strip())
32
+ except InvalidOperation:
33
+ raise ValueError(f"not a dollar amount: {amount!r}") from None
34
+ if not value.is_finite():
35
+ raise ValueError(f"not a finite dollar amount: {amount!r}")
36
+ return int((value * 100).quantize(Decimal(1), rounding=ROUND_HALF_UP))