hyperagents 1.2.3rc5.post3__py3-none-any.whl

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 (361) hide show
  1. agents/__init__.py +31 -0
  2. agents/background_monitor.py +453 -0
  3. agents/learning.py +460 -0
  4. agents/planner.py +421 -0
  5. agents/transcriber.py +341 -0
  6. civilization/__init__.py +51 -0
  7. civilization/api/__init__.py +7 -0
  8. civilization/api/civilization_api.py +361 -0
  9. civilization/graph/__init__.py +19 -0
  10. civilization/graph/knowledge_linker.py +348 -0
  11. civilization/graph/org_graph.py +348 -0
  12. civilization/graph/process_graph.py +386 -0
  13. civilization/ingestion/__init__.py +20 -0
  14. civilization/ingestion/chunker.py +273 -0
  15. civilization/ingestion/document_ingestor.py +358 -0
  16. civilization/ingestion/embedder.py +262 -0
  17. civilization/ingestion/extractor.py +224 -0
  18. civilization/interview/__init__.py +19 -0
  19. civilization/interview/gap_detector.py +380 -0
  20. civilization/interview/interviewer.py +323 -0
  21. civilization/interview/questioner.py +363 -0
  22. civilization/retrieval/__init__.py +17 -0
  23. civilization/retrieval/civilization_rag.py +316 -0
  24. civilization/retrieval/context_injector.py +342 -0
  25. civilization/retrieval/relevance_ranker.py +287 -0
  26. civilization/schema.py +299 -0
  27. civilization/store.py +177 -0
  28. civilization/sync/__init__.py +16 -0
  29. civilization/sync/confluence_sync.py +317 -0
  30. civilization/sync/gdrive_sync.py +262 -0
  31. civilization/sync/notion_sync.py +237 -0
  32. civilization/versioning/__init__.py +19 -0
  33. civilization/versioning/diff_engine.py +351 -0
  34. civilization/versioning/staleness_detector.py +303 -0
  35. civilization/versioning/version_manager.py +293 -0
  36. cli/__init__.py +1 -0
  37. cli/doctor.py +334 -0
  38. cli/hyperclaw.py +1357 -0
  39. cli/onboarding.py +469 -0
  40. core/__init__.py +1 -0
  41. core/connectors/__init__.py +119 -0
  42. core/connectors/infrastructure.py +772 -0
  43. core/connectors/messaging.py +586 -0
  44. core/connectors/productivity.py +764 -0
  45. core/connectors/smarthome.py +528 -0
  46. core/connectors/social.py +730 -0
  47. core/hyperrouter/__init__.py +23 -0
  48. core/hyperrouter/bandit.py +218 -0
  49. core/hyperrouter/fast_loop.py +68 -0
  50. core/hyperrouter/slow_loop.py +143 -0
  51. core/hyperstate/__init__.py +24 -0
  52. core/hyperstate/certifier.py +154 -0
  53. core/hyperstate/schema.py +160 -0
  54. core/hyperstate/state_manager.py +103 -0
  55. core/hyperstate/store.py +163 -0
  56. core/providers/__init__.py +121 -0
  57. core/providers/embeddings.py +389 -0
  58. core/providers/image.py +534 -0
  59. core/providers/llm.py +1935 -0
  60. core/providers/search.py +608 -0
  61. core/providers/voice.py +474 -0
  62. hyperagents-1.2.3rc5.post3.dist-info/METADATA +469 -0
  63. hyperagents-1.2.3rc5.post3.dist-info/RECORD +361 -0
  64. hyperagents-1.2.3rc5.post3.dist-info/WHEEL +5 -0
  65. hyperagents-1.2.3rc5.post3.dist-info/entry_points.txt +6 -0
  66. hyperagents-1.2.3rc5.post3.dist-info/licenses/LICENSE +21 -0
  67. hyperagents-1.2.3rc5.post3.dist-info/top_level.txt +13 -0
  68. hyperclaw/__init__.py +67 -0
  69. hyperclaw/__main__.py +220 -0
  70. hyperclaw/agent.py +775 -0
  71. hyperclaw/agent_coordinator.py +577 -0
  72. hyperclaw/anthropic_params.py +225 -0
  73. hyperclaw/api_utils.py +85 -0
  74. hyperclaw/approval_telegram.py +209 -0
  75. hyperclaw/approval_terminal.py +80 -0
  76. hyperclaw/attachments.py +177 -0
  77. hyperclaw/branding.py +55 -0
  78. hyperclaw/brief/__init__.py +11 -0
  79. hyperclaw/brief/cli.py +204 -0
  80. hyperclaw/brief/collect.py +146 -0
  81. hyperclaw/brief/compose.py +333 -0
  82. hyperclaw/brief/diff.py +110 -0
  83. hyperclaw/brief/errors.py +99 -0
  84. hyperclaw/brief/google.py +269 -0
  85. hyperclaw/brief/google_auth.py +294 -0
  86. hyperclaw/brief/links.py +124 -0
  87. hyperclaw/brief/service.py +424 -0
  88. hyperclaw/brief/store.py +205 -0
  89. hyperclaw/brief/tasks.py +200 -0
  90. hyperclaw/chat_scope.py +115 -0
  91. hyperclaw/cli_tui.py +497 -0
  92. hyperclaw/client_guard.py +717 -0
  93. hyperclaw/connection_check.py +113 -0
  94. hyperclaw/cost_guard.py +1728 -0
  95. hyperclaw/default_config/agents.yaml +534 -0
  96. hyperclaw/default_config/hyperclaw.yaml +27 -0
  97. hyperclaw/default_config/models.yaml +161 -0
  98. hyperclaw/deployment.py +39 -0
  99. hyperclaw/digest/__init__.py +9 -0
  100. hyperclaw/digest/cli.py +117 -0
  101. hyperclaw/digest/errors.py +100 -0
  102. hyperclaw/digest/gitlab.py +187 -0
  103. hyperclaw/digest/http.py +113 -0
  104. hyperclaw/digest/redact.py +32 -0
  105. hyperclaw/digest/service.py +271 -0
  106. hyperclaw/digest/slack.py +223 -0
  107. hyperclaw/digest/state.py +124 -0
  108. hyperclaw/digest/summarize.py +198 -0
  109. hyperclaw/digest/tokens.py +143 -0
  110. hyperclaw/distribution.py +46 -0
  111. hyperclaw/documents.py +351 -0
  112. hyperclaw/feeds.py +339 -0
  113. hyperclaw/google_workspace/__init__.py +5 -0
  114. hyperclaw/google_workspace/api.py +151 -0
  115. hyperclaw/google_workspace/auth.py +397 -0
  116. hyperclaw/google_workspace/calendar.py +157 -0
  117. hyperclaw/google_workspace/cli.py +72 -0
  118. hyperclaw/google_workspace/drive.py +236 -0
  119. hyperclaw/google_workspace/errors.py +58 -0
  120. hyperclaw/google_workspace/gmail.py +295 -0
  121. hyperclaw/google_workspace/meet.py +122 -0
  122. hyperclaw/google_workspace/tools.py +319 -0
  123. hyperclaw/hyperspeed.py +57 -0
  124. hyperclaw/integrations_layer.py +1427 -0
  125. hyperclaw/live_state.py +188 -0
  126. hyperclaw/managed_config.py +245 -0
  127. hyperclaw/media_hub.py +179 -0
  128. hyperclaw/memory_manager.py +584 -0
  129. hyperclaw/model_catalog.py +551 -0
  130. hyperclaw/model_policy.py +211 -0
  131. hyperclaw/model_router.py +595 -0
  132. hyperclaw/model_selector.py +72 -0
  133. hyperclaw/naming.py +284 -0
  134. hyperclaw/notices.py +65 -0
  135. hyperclaw/oai_stream.py +106 -0
  136. hyperclaw/orchestrator.py +591 -0
  137. hyperclaw/outbox.py +82 -0
  138. hyperclaw/persona.py +367 -0
  139. hyperclaw/prometheus.py +219 -0
  140. hyperclaw/prompt_assembly.py +157 -0
  141. hyperclaw/provider_setup.py +146 -0
  142. hyperclaw/providers.py +340 -0
  143. hyperclaw/qualification.py +341 -0
  144. hyperclaw/qualification_keys/hyper-d292fd127cef5a3e.pub +1 -0
  145. hyperclaw/qualification_records/anthropic--claude-sonnet-5-5.json +14 -0
  146. hyperclaw/reasoning_display.py +35 -0
  147. hyperclaw/scheduler.py +454 -0
  148. hyperclaw/server.py +847 -0
  149. hyperclaw/setup.py +627 -0
  150. hyperclaw/skills.py +717 -0
  151. hyperclaw/solomon.py +442 -0
  152. hyperclaw/swarm.py +533 -0
  153. hyperclaw/taint.py +832 -0
  154. hyperclaw/team/__init__.py +28 -0
  155. hyperclaw/team/dispatcher.py +431 -0
  156. hyperclaw/team/genesis.py +106 -0
  157. hyperclaw/team/lead.py +110 -0
  158. hyperclaw/team/model.py +135 -0
  159. hyperclaw/team/morning.py +238 -0
  160. hyperclaw/team/repos.py +66 -0
  161. hyperclaw/team/roster.py +264 -0
  162. hyperclaw/team/specialist.py +235 -0
  163. hyperclaw/team/status.py +163 -0
  164. hyperclaw/team/watchers.py +112 -0
  165. hyperclaw/teamsetup/__init__.py +17 -0
  166. hyperclaw/teamsetup/checks.py +208 -0
  167. hyperclaw/teamsetup/item.py +391 -0
  168. hyperclaw/teamsetup/oauth_loopback.py +84 -0
  169. hyperclaw/teamsetup/onboard.py +550 -0
  170. hyperclaw/teamsetup/profile.py +82 -0
  171. hyperclaw/teamsetup/secrets.py +313 -0
  172. hyperclaw/teamsetup/selfcheck.py +295 -0
  173. hyperclaw/teamsetup/service.py +495 -0
  174. hyperclaw/teamsetup/slack_oauth.py +442 -0
  175. hyperclaw/teamsetup/tty.py +62 -0
  176. hyperclaw/telegram_bot.py +1106 -0
  177. hyperclaw/telegram_intake.py +126 -0
  178. hyperclaw/telegram_pairing.py +360 -0
  179. hyperclaw/terminal_provider.py +323 -0
  180. hyperclaw/tools/__init__.py +1 -0
  181. hyperclaw/tools/approval.py +949 -0
  182. hyperclaw/tools/args.py +212 -0
  183. hyperclaw/tools/catalog.py +244 -0
  184. hyperclaw/tools/classify.py +266 -0
  185. hyperclaw/tools/effects.py +724 -0
  186. hyperclaw/tools/egress.py +210 -0
  187. hyperclaw/tools/envelope.py +188 -0
  188. hyperclaw/tools/guard.py +367 -0
  189. hyperclaw/tools/ledger.py +339 -0
  190. hyperclaw/tools/receipts.py +70 -0
  191. hyperclaw/tools/registry.py +318 -0
  192. hyperclaw/tools/service_guard.py +151 -0
  193. hyperclaw/tools/surfaces.py +54 -0
  194. hyperclaw/tools/workspace.py +87 -0
  195. hyperclaw/trading/__init__.py +77 -0
  196. hyperclaw/truth_guard.py +791 -0
  197. hyperclaw/tts.py +124 -0
  198. hyperclaw/tui.py +4255 -0
  199. hyperclaw/tui_bridge.py +938 -0
  200. hyperclaw/unattended.py +60 -0
  201. hyperclaw/updates/__init__.py +54 -0
  202. hyperclaw/updates/check.py +131 -0
  203. hyperclaw/updates/index.py +178 -0
  204. hyperclaw/updates/install.py +110 -0
  205. hyperclaw/updates/managed.py +161 -0
  206. hyperclaw/updates/manifest.py +138 -0
  207. hyperclaw/updates/notices.py +86 -0
  208. hyperclaw/updates/paths.py +53 -0
  209. hyperclaw/updates/release.py +146 -0
  210. hyperclaw/updates/upgrade.py +253 -0
  211. hyperclaw/updates/versioning.py +104 -0
  212. hyperclaw/userconfig.py +119 -0
  213. hyperclaw/xai.py +83 -0
  214. integrations/__init__.py +31 -0
  215. integrations/automation/__init__.py +1 -0
  216. integrations/automation/make.py +87 -0
  217. integrations/automation/zapier.py +87 -0
  218. integrations/base.py +476 -0
  219. integrations/communication/__init__.py +1 -0
  220. integrations/communication/twilio.py +279 -0
  221. integrations/communication/zoom.py +290 -0
  222. integrations/data/__init__.py +1 -0
  223. integrations/data/google_analytics.py +135 -0
  224. integrations/data/postgres.py +182 -0
  225. integrations/data/supabase_connector.py +202 -0
  226. integrations/data/weather_feeds.py +450 -0
  227. integrations/developer/__init__.py +9 -0
  228. integrations/developer/github.py +406 -0
  229. integrations/developer/gitlab.py +298 -0
  230. integrations/enterprise/__init__.py +13 -0
  231. integrations/enterprise/confluence.py +266 -0
  232. integrations/enterprise/hubspot.py +293 -0
  233. integrations/enterprise/jira.py +314 -0
  234. integrations/enterprise/salesforce.py +286 -0
  235. integrations/finance/__init__.py +1 -0
  236. integrations/finance/polymarket_client.py +247 -0
  237. integrations/finance/quickbooks.py +281 -0
  238. integrations/finance/stripe.py +261 -0
  239. integrations/gateway.py +238 -0
  240. integrations/messaging/__init__.py +32 -0
  241. integrations/messaging/discord.py +159 -0
  242. integrations/messaging/email.py +401 -0
  243. integrations/messaging/imessage.py +443 -0
  244. integrations/messaging/signal.py +175 -0
  245. integrations/messaging/slack.py +217 -0
  246. integrations/messaging/sms.py +175 -0
  247. integrations/messaging/teams.py +208 -0
  248. integrations/messaging/telegram.py +260 -0
  249. integrations/messaging/whatsapp.py +203 -0
  250. integrations/productivity/__init__.py +17 -0
  251. integrations/productivity/airtable.py +217 -0
  252. integrations/productivity/asana.py +248 -0
  253. integrations/productivity/google/__init__.py +19 -0
  254. integrations/productivity/google/base.py +89 -0
  255. integrations/productivity/google/calendar.py +272 -0
  256. integrations/productivity/google/docs.py +202 -0
  257. integrations/productivity/google/drive.py +268 -0
  258. integrations/productivity/google/gmail.py +279 -0
  259. integrations/productivity/google/meet.py +218 -0
  260. integrations/productivity/google/sheets.py +222 -0
  261. integrations/productivity/google/tasks.py +199 -0
  262. integrations/productivity/linear.py +279 -0
  263. integrations/productivity/microsoft/__init__.py +13 -0
  264. integrations/productivity/microsoft/base.py +93 -0
  265. integrations/productivity/microsoft/calendar.py +207 -0
  266. integrations/productivity/microsoft/onedrive.py +222 -0
  267. integrations/productivity/microsoft/outlook.py +241 -0
  268. integrations/productivity/microsoft/sharepoint.py +211 -0
  269. integrations/productivity/notion.py +243 -0
  270. integrations/productivity/todoist.py +250 -0
  271. integrations/productivity/trello.py +244 -0
  272. integrations/storage/__init__.py +1 -0
  273. integrations/storage/box.py +279 -0
  274. integrations/storage/dropbox.py +215 -0
  275. integrations/telegram_inbound.py +179 -0
  276. integrations/telegram_poller.py +151 -0
  277. memory/__init__.py +1 -0
  278. memory/causal_graph.py +125 -0
  279. memory/impact_tracker.py +88 -0
  280. memory/vector_memory.py +436 -0
  281. models/__init__.py +12 -0
  282. models/chatjimmy_client.py +210 -0
  283. models/claude_client.py +235 -0
  284. models/claude_code_subagent.py +192 -0
  285. models/openai_client.py +120 -0
  286. models/router.py +162 -0
  287. recursive/discovery_loop.py +118 -0
  288. recursive/optimization_loop.py +135 -0
  289. recursive/validation_loop.py +38 -0
  290. security/__init__.py +1 -0
  291. security/audit_logger.py +106 -0
  292. security/filesystem_guard.py +100 -0
  293. security/hypershield.py +99 -0
  294. security/network_guard.py +97 -0
  295. security/policy_engine.py +198 -0
  296. swarm/__init__.py +1 -0
  297. swarm/agents/__init__.py +0 -0
  298. swarm/agents/base.py +230 -0
  299. swarm/agents/business/__init__.py +0 -0
  300. swarm/agents/business/counsel.py +31 -0
  301. swarm/agents/business/herald.py +32 -0
  302. swarm/agents/business/ledger.py +26 -0
  303. swarm/agents/business/nexus.py +30 -0
  304. swarm/agents/business/ops.py +30 -0
  305. swarm/agents/business/pipeline.py +28 -0
  306. swarm/agents/business/revenue.py +30 -0
  307. swarm/agents/business/sovereign.py +31 -0
  308. swarm/agents/business/strategos.py +27 -0
  309. swarm/agents/business/talent.py +27 -0
  310. swarm/agents/business/venture.py +32 -0
  311. swarm/agents/comms/__init__.py +1 -0
  312. swarm/agents/comms/cipher.py +32 -0
  313. swarm/agents/comms/echo_comms.py +31 -0
  314. swarm/agents/comms/envoy.py +31 -0
  315. swarm/agents/comms/herald_scheduler.py +33 -0
  316. swarm/agents/comms/pulse.py +29 -0
  317. swarm/agents/creative/__init__.py +0 -0
  318. swarm/agents/creative/author.py +27 -0
  319. swarm/agents/creative/lens.py +25 -0
  320. swarm/agents/executive/__init__.py +1 -0
  321. swarm/agents/intelligence/__init__.py +1 -0
  322. swarm/agents/intelligence/arbiter.py +30 -0
  323. swarm/agents/intelligence/sentinel.py +30 -0
  324. swarm/agents/media/__init__.py +0 -0
  325. swarm/agents/personal/__init__.py +0 -0
  326. swarm/agents/personal/atlas.py +27 -0
  327. swarm/agents/personal/hearth.py +26 -0
  328. swarm/agents/personal/midas.py +27 -0
  329. swarm/agents/personal/navigator.py +27 -0
  330. swarm/agents/personal/nourish.py +31 -0
  331. swarm/agents/personal/vitals.py +33 -0
  332. swarm/agents/recursive/__init__.py +0 -0
  333. swarm/agents/recursive/alchemist.py +73 -0
  334. swarm/agents/recursive/calibrator.py +45 -0
  335. swarm/agents/recursive/scout.py +97 -0
  336. swarm/agents/scientific/__init__.py +0 -0
  337. swarm/agents/scientific/cosmos.py +36 -0
  338. swarm/agents/scientific/gaia.py +26 -0
  339. swarm/agents/scientific/medicus.py +33 -0
  340. swarm/agents/scientific/oracle_agent.py +39 -0
  341. swarm/agents/scientific/scribe.py +39 -0
  342. swarm/agents/talent/__init__.py +0 -0
  343. swarm/agents/talent/deal.py +28 -0
  344. swarm/agents/talent/roster.py +29 -0
  345. swarm/agents/talent/scout.py +29 -0
  346. swarm/agents/talent/stage.py +27 -0
  347. swarm/agents/tech/__init__.py +0 -0
  348. swarm/agents/tech/aegis.py +28 -0
  349. swarm/agents/tech/bridge.py +28 -0
  350. swarm/agents/tech/forge.py +28 -0
  351. swarm/agents/trading/__init__.py +1 -0
  352. swarm/agents/trading/global_prediction_engine.py +611 -0
  353. swarm/agents/trading/polymarket_trader.py +165 -0
  354. swarm/agents/trading/prediction_strategist.py +218 -0
  355. swarm/autogen_bridge.py +68 -0
  356. swarm/bid_protocol.py +196 -0
  357. swarm/dispatcher.py +527 -0
  358. swarm/nexus.py +181 -0
  359. swarm/registry.py +112 -0
  360. telegram_listener.py +403 -0
  361. ui/tui/app.py +214 -0
@@ -0,0 +1,949 @@
1
+ """
2
+ The approval layer (tool standard wave 3): Send / Edit / Cancel.
3
+
4
+ Every send, post or write the model asks for (every tool that is not
5
+ read-only: `ask`-tier calls, and on the terminal shell / code / desktop
6
+ control too; on every surface: terminal, Telegram, server/dashboard,
7
+ sub-agents) is NOT run. It is
8
+ stored in a durable queue, bound to a hash of the exact payload, and shown to
9
+ the owner with its exact content. Only the owner can then:
10
+
11
+ - Send -> the stored payload runs, exactly once (compare-and-set on the
12
+ queue row, and the effect ledger key on top), and only if the
13
+ hash the owner approved still matches the stored payload;
14
+ - Edit -> the owner changes the content; the old item is superseded and a
15
+ new one (new id, new hash) is shown again;
16
+ - Cancel -> nothing runs.
17
+
18
+ The same queue holds:
19
+ - task/plan changes (bench 15.6: "task changes are proposed, not applied");
20
+ - calls from sub-agents (bench 18.5: they inherit the owner's approval limits;
21
+ an agent can never approve);
22
+ - fetches by a tool that runs without asking (read tools, download,
23
+ open_url) of a URL that is not verbatim in the user's own words, or that
24
+ points at a private address (tools/egress.py);
25
+ - service recovery actions on HyperClaw's own allowlisted services (17.4/17.5,
26
+ tools/service_guard.py).
27
+
28
+ Store: $HYPERCLAW_ROOT/approvals/pending.sqlite3 (HYPERCLAW_APPROVAL_QUEUE
29
+ overrides), mode 0600, directory 0700. State changes are compare-and-set
30
+ updates on `pending_actions`; `approval_events` is append-only (triggers
31
+ refuse UPDATE and DELETE). Restart-safe: a pending item survives a restart and
32
+ can still be approved; an item that was executing when the process died is
33
+ marked `uncertain` and never re-run. Stale items expire
34
+ (HYPERCLAW_APPROVAL_TTL_S, default 24 h).
35
+
36
+ Trusted per-contact auto-send is a config hook (`auto_send_allowed`), OFF by
37
+ default: $HYPERCLAW_ROOT/approval.json {"auto_send": {"enabled": false,
38
+ "contacts": [], "domains": []}}. Nothing is trusted until the owner writes
39
+ that file.
40
+
41
+ Design notes: the queue-and-approve shape (approve only WHERE status is
42
+ 'pending', reject, list pending) and the recipient-domain classification of
43
+ the auto-send hook follow an earlier internal assistant's approval tiers; the
44
+ domain list there was hard-coded and is config-driven and empty here.
45
+ """
46
+ from __future__ import annotations
47
+
48
+ import hashlib
49
+ import json
50
+ import logging
51
+ import os
52
+ import secrets
53
+ import sqlite3
54
+ import threading
55
+ import time
56
+ from dataclasses import dataclass, field
57
+ from pathlib import Path
58
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
59
+
60
+ from . import egress
61
+ from . import service_guard
62
+
63
+ log = logging.getLogger("hyperclaw.tools.approval")
64
+
65
+ DEFAULT_TTL_S = 24 * 3600
66
+
67
+ # Item statuses.
68
+ PENDING = "pending"
69
+ EXECUTING = "executing"
70
+ DONE = "done"
71
+ FAILED = "failed"
72
+ UNCERTAIN = "uncertain"
73
+ CANCELLED = "cancelled"
74
+ SUPERSEDED = "superseded"
75
+ EXPIRED = "expired"
76
+ FINAL = (DONE, FAILED, UNCERTAIN, CANCELLED, SUPERSEDED, EXPIRED)
77
+
78
+ # Gate kinds (why an item needs the owner).
79
+ KIND_ASK = "ask"
80
+ KIND_PLAN = "plan_change"
81
+ KIND_SERVICE = "service"
82
+ KIND_URL = "unverified_url"
83
+ KIND_PRIVATE = "private_target"
84
+
85
+ PLAN_TOOLS = frozenset({"plan_create", "plan_update"})
86
+
87
+ # Reads whose output is someone else's content (email, web, documents). After
88
+ # one of these ran in a turn, text the model "found" there is data: a service
89
+ # action needs the user's own words (17.5).
90
+ PURE_COMPUTE = frozenset({"math", "hash", "uuid_generate", "base64_encode", "json_format",
91
+ "regex_test", "time_info", "unit_convert", "memory_stats",
92
+ "plan_status", "plan_next", "plan_list", "agent_status",
93
+ "swarm_roster", "learning_stats"})
94
+
95
+ # The one field a plain-text Edit replaces, per tool (else the owner sends JSON).
96
+ EDIT_FIELD = {
97
+ "telegram": "message", "slack": "message", "discord": "message", "imessage": "message",
98
+ "email_send": "body", "email_draft": "body", "http_request": "body",
99
+ "calendar_create": "title", "plan_create": "goal", "bash": "command",
100
+ "python_exec": "code", "applescript": "script", "web_fetch": "url",
101
+ "github": "body", "sql_query": "query", "memory_forget": "query",
102
+ "image_generate": "prompt", "document_create": "notes",
103
+ # Writes and local actions (every non-read tool is gated).
104
+ "write_file": "content", "edit_file": "new_text", "clipboard_write": "text",
105
+ "memory_store": "content", "download": "url", "qr_code": "data",
106
+ "learning_log": "detail", "learning_feedback": "feedback", "agent_watch": "target",
107
+ "agent_start": "name",
108
+ "notification": "message", "speak": "text",
109
+ # Desktop / app control (terminal only).
110
+ "type_text": "text", "open_url": "url", "open_file": "path", "open_app": "app_name",
111
+ # Google Workspace (hyperclaw/google_workspace/tools.py EDIT_FIELD).
112
+ "gmail_send": "body", "gmail_create_draft": "body",
113
+ "calendar_create_event": "title", "calendar_update_event": "title",
114
+ }
115
+
116
+ # What a terminal-only action does, in one line, above its exact content.
117
+ ACTION_LINE = {
118
+ "bash": "Run this exact command line in a shell on this computer",
119
+ "python_exec": "Run this exact Python code on this computer",
120
+ "applescript": "Run this exact AppleScript on this computer (it can control any app)",
121
+ "type_text": "Type this exact text into whichever app is in front",
122
+ "key_press": "Press these exact keys in whichever app is in front",
123
+ "mouse_click": "Click the mouse at this exact screen position",
124
+ "open_app": "Open this app",
125
+ "open_file": "Open this file on this computer",
126
+ "open_url": "Open this exact address in the browser",
127
+ "record_audio": "Record from this computer's microphone",
128
+ "notification": "Show this notification on this computer",
129
+ "speak": "Say this text out loud on this computer",
130
+ "agent_stop": "Stop this background monitor",
131
+ "agent_start": ("Start background watchers. They only ever message YOU, one line each "
132
+ "(who, what, one line); they never open links, reply, post or act. "
133
+ "Anything else waits for your Send"),
134
+ "agent_watch": "Add this to a background monitor's watch list",
135
+ "transcribe_audio": "Send this audio file to the transcription service and save the text",
136
+ "transcribe_video": "Send this video's audio to the transcription service and save the text",
137
+ "learning_reflect": "Summarise recent activity and save the summary in the learning log",
138
+ "vision": "Upload this image file to the image-analysis model and ask this question",
139
+ # Google Workspace (hyperclaw/google_workspace/tools.py ACTION_LINE).
140
+ "gmail_send": "Send this exact email from your Gmail",
141
+ "gmail_create_draft": "Save this exact email as a Gmail draft (it is not sent)",
142
+ "calendar_create_event": "Create this event in your Google Calendar",
143
+ "calendar_update_event": "Change this event in your Google Calendar",
144
+ }
145
+
146
+ _VERB = {"send": "send", "write": "make a change", "spend": "spend", "delete": "delete",
147
+ "system": "run an action", "read": "fetch"}
148
+
149
+ _SCHEMA = """
150
+ CREATE TABLE IF NOT EXISTS pending_actions (
151
+ id TEXT PRIMARY KEY,
152
+ created REAL NOT NULL,
153
+ expires REAL NOT NULL,
154
+ conversation TEXT NOT NULL,
155
+ surface TEXT NOT NULL,
156
+ scope TEXT NOT NULL,
157
+ requester TEXT NOT NULL,
158
+ tool TEXT NOT NULL,
159
+ args TEXT NOT NULL,
160
+ payload_hash TEXT NOT NULL,
161
+ kind TEXT NOT NULL,
162
+ reason TEXT,
163
+ untrusted INTEGER NOT NULL DEFAULT 0,
164
+ status TEXT NOT NULL,
165
+ decided_by TEXT,
166
+ decided_at REAL,
167
+ edit_of TEXT,
168
+ result TEXT,
169
+ receipt_id TEXT,
170
+ notified TEXT
171
+ );
172
+ CREATE INDEX IF NOT EXISTS pending_actions_status ON pending_actions (status, conversation);
173
+ CREATE TABLE IF NOT EXISTS approval_events (
174
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
175
+ ts REAL NOT NULL,
176
+ action_id TEXT NOT NULL,
177
+ event TEXT NOT NULL,
178
+ actor TEXT,
179
+ payload_hash TEXT,
180
+ detail TEXT
181
+ );
182
+ CREATE TRIGGER IF NOT EXISTS approval_events_no_update BEFORE UPDATE ON approval_events
183
+ BEGIN SELECT RAISE(ABORT, 'approval_events is append-only'); END;
184
+ CREATE TRIGGER IF NOT EXISTS approval_events_no_delete BEFORE DELETE ON approval_events
185
+ BEGIN SELECT RAISE(ABORT, 'approval_events is append-only'); END;
186
+ """
187
+
188
+
189
+ # ───────────────────────── identity ─────────────────────────
190
+
191
+ @dataclass(frozen=True)
192
+ class Actor:
193
+ """Who is deciding. Only role "owner" may Send, Edit or Cancel."""
194
+ role: str # "owner" | "guest" | "agent"
195
+ id: str = ""
196
+ surface: str = "" # where the decision is made ("tui", "telegram", "server")
197
+
198
+ @property
199
+ def is_owner(self) -> bool:
200
+ return self.role == "owner"
201
+
202
+ def label(self) -> str:
203
+ return f"{self.role}:{self.surface or '-'}:{self.id or '-'}"
204
+
205
+
206
+ TERMINAL_OWNER = Actor("owner", "local", "tui")
207
+
208
+
209
+ # ───────────────────────── payload identity ─────────────────────────
210
+
211
+ def canonical_args(args: Any) -> str:
212
+ return json.dumps(args if args is not None else {}, sort_keys=True, ensure_ascii=False,
213
+ separators=(",", ":"), default=str)
214
+
215
+
216
+ def payload_hash(tool: str, args: Any) -> str:
217
+ """sha256 over the exact tool and arguments (the approval binds to this)."""
218
+ return hashlib.sha256(f"{tool}\n{canonical_args(args)}".encode("utf-8")).hexdigest()
219
+
220
+
221
+ @dataclass
222
+ class PendingAction:
223
+ id: str
224
+ created: float
225
+ expires: float
226
+ conversation: str
227
+ surface: str
228
+ scope: str
229
+ requester: str
230
+ tool: str
231
+ args: Dict[str, Any]
232
+ payload_hash: str
233
+ kind: str
234
+ reason: str = ""
235
+ untrusted: bool = False
236
+ status: str = PENDING
237
+ decided_by: Optional[str] = None
238
+ decided_at: Optional[float] = None
239
+ edit_of: Optional[str] = None
240
+ result: Optional[str] = None
241
+ receipt_id: Optional[str] = None
242
+ notified: Optional[str] = None
243
+
244
+ @property
245
+ def short_hash(self) -> str:
246
+ return self.payload_hash[:16]
247
+
248
+ @classmethod
249
+ def from_row(cls, row: sqlite3.Row) -> "PendingAction":
250
+ d = dict(row)
251
+ d["args"] = json.loads(d["args"])
252
+ d["untrusted"] = bool(d.get("untrusted"))
253
+ return cls(**d)
254
+
255
+
256
+ def _env_float(name: str, default: float) -> float:
257
+ try:
258
+ return float(os.environ.get(name, default))
259
+ except (TypeError, ValueError):
260
+ return float(default)
261
+
262
+
263
+ def hyperclaw_root() -> Path:
264
+ return Path(os.environ.get("HYPERCLAW_ROOT", Path.home() / ".hyperclaw")).expanduser()
265
+
266
+
267
+ def default_path() -> Path:
268
+ explicit = os.environ.get("HYPERCLAW_APPROVAL_QUEUE")
269
+ if explicit:
270
+ return Path(explicit).expanduser()
271
+ return hyperclaw_root() / "approvals" / "pending.sqlite3"
272
+
273
+
274
+ class ApprovalError(Exception):
275
+ """A decision that was refused (not the owner, wrong hash, expired, ...)."""
276
+
277
+
278
+ # ───────────────────────── the durable queue ─────────────────────────
279
+
280
+ class ApprovalQueue:
281
+ def __init__(self, path: Optional[os.PathLike] = None):
282
+ self.path = Path(path) if path else default_path()
283
+ self._lock = threading.Lock()
284
+ self._ready = False
285
+
286
+ def _connect(self) -> sqlite3.Connection:
287
+ if not self._ready:
288
+ with self._lock:
289
+ if not self._ready:
290
+ self.path.parent.mkdir(parents=True, exist_ok=True)
291
+ try:
292
+ os.chmod(self.path.parent, 0o700)
293
+ except OSError:
294
+ pass
295
+ fd = os.open(self.path, os.O_CREAT | os.O_RDWR, 0o600)
296
+ os.close(fd)
297
+ con = sqlite3.connect(self.path, timeout=10, isolation_level=None)
298
+ try:
299
+ con.execute("PRAGMA journal_mode=WAL")
300
+ con.executescript(_SCHEMA)
301
+ finally:
302
+ con.close()
303
+ for p in (self.path, Path(f"{self.path}-wal"), Path(f"{self.path}-shm")):
304
+ try:
305
+ os.chmod(p, 0o600)
306
+ except OSError:
307
+ pass
308
+ self._ready = True
309
+ con = sqlite3.connect(self.path, timeout=10, isolation_level=None)
310
+ con.row_factory = sqlite3.Row
311
+ return con
312
+
313
+ @staticmethod
314
+ def ttl_s() -> float:
315
+ return _env_float("HYPERCLAW_APPROVAL_TTL_S", DEFAULT_TTL_S)
316
+
317
+ @staticmethod
318
+ def _event(con, action_id: str, event: str, actor: str = "", payload_hash: str = "",
319
+ detail: str = "") -> None:
320
+ con.execute("INSERT INTO approval_events (ts, action_id, event, actor, payload_hash, "
321
+ "detail) VALUES (?,?,?,?,?,?)",
322
+ (time.time(), action_id, event, actor, payload_hash, (detail or "")[:500]))
323
+
324
+ # ── propose ──
325
+ def propose(self, *, tool: str, args: Dict[str, Any], conversation: str, surface: str,
326
+ scope: str = "owner", requester: str = "owner", kind: str = KIND_ASK,
327
+ reason: str = "", untrusted: bool = False,
328
+ edit_of: Optional[str] = None) -> PendingAction:
329
+ """Queue one call. An identical pending call in the same conversation
330
+ is returned as is (no second card for the same payload)."""
331
+ h = payload_hash(tool, args)
332
+ now = time.time()
333
+ con = self._connect()
334
+ try:
335
+ con.execute("BEGIN IMMEDIATE")
336
+ self._expire(con, now)
337
+ row = con.execute("SELECT * FROM pending_actions WHERE conversation=? AND "
338
+ "payload_hash=? AND status=? ORDER BY created DESC LIMIT 1",
339
+ (conversation, h, PENDING)).fetchone()
340
+ if row is not None and edit_of is None:
341
+ con.execute("COMMIT")
342
+ return PendingAction.from_row(row)
343
+ aid = secrets.token_hex(6)
344
+ con.execute(
345
+ "INSERT INTO pending_actions (id, created, expires, conversation, surface, scope, "
346
+ "requester, tool, args, payload_hash, kind, reason, untrusted, status, edit_of) "
347
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
348
+ (aid, now, now + self.ttl_s(), conversation, surface, scope, requester, tool,
349
+ canonical_args(args), h, kind, reason, int(bool(untrusted)), PENDING, edit_of))
350
+ self._event(con, aid, "proposed", requester, h, reason)
351
+ con.execute("COMMIT")
352
+ except Exception:
353
+ _rollback(con)
354
+ raise
355
+ finally:
356
+ con.close()
357
+ return self.get(aid)
358
+
359
+ # ── readers ──
360
+ def get(self, action_id: str) -> Optional[PendingAction]:
361
+ con = self._connect()
362
+ try:
363
+ row = con.execute("SELECT * FROM pending_actions WHERE id=?",
364
+ (str(action_id),)).fetchone()
365
+ return PendingAction.from_row(row) if row else None
366
+ finally:
367
+ con.close()
368
+
369
+ def pending(self, conversation: Optional[str] = None,
370
+ unnotified_only: bool = False) -> List[PendingAction]:
371
+ con = self._connect()
372
+ try:
373
+ con.execute("BEGIN IMMEDIATE")
374
+ self._expire(con, time.time())
375
+ con.execute("COMMIT")
376
+ q = "SELECT * FROM pending_actions WHERE status=?"
377
+ params: list = [PENDING]
378
+ if conversation:
379
+ q += " AND conversation=?"
380
+ params.append(conversation)
381
+ if unnotified_only:
382
+ q += " AND notified IS NULL"
383
+ q += " ORDER BY created"
384
+ return [PendingAction.from_row(r) for r in con.execute(q, params).fetchall()]
385
+ finally:
386
+ con.close()
387
+
388
+ def events(self, action_id: Optional[str] = None) -> List[Dict[str, Any]]:
389
+ con = self._connect()
390
+ try:
391
+ if action_id:
392
+ cur = con.execute("SELECT * FROM approval_events WHERE action_id=? ORDER BY id",
393
+ (action_id,))
394
+ else:
395
+ cur = con.execute("SELECT * FROM approval_events ORDER BY id")
396
+ return [dict(r) for r in cur.fetchall()]
397
+ finally:
398
+ con.close()
399
+
400
+ def mark_notified(self, action_id: str, note: str) -> None:
401
+ con = self._connect()
402
+ try:
403
+ con.execute("UPDATE pending_actions SET notified=? WHERE id=?", (note, action_id))
404
+ finally:
405
+ con.close()
406
+
407
+ # ── decisions ──
408
+ def _expire(self, con, now: float) -> None:
409
+ rows = con.execute("SELECT id, payload_hash FROM pending_actions WHERE status=? AND "
410
+ "expires<=?", (PENDING, now)).fetchall()
411
+ for r in rows:
412
+ con.execute("UPDATE pending_actions SET status=?, decided_at=? WHERE id=? AND "
413
+ "status=?", (EXPIRED, now, r["id"], PENDING))
414
+ self._event(con, r["id"], "expired", "", r["payload_hash"])
415
+
416
+ def expire_stale(self) -> int:
417
+ con = self._connect()
418
+ try:
419
+ con.execute("BEGIN IMMEDIATE")
420
+ before = con.total_changes
421
+ self._expire(con, time.time())
422
+ n = con.total_changes - before
423
+ con.execute("COMMIT")
424
+ return n // 2 # one update + one event row per item
425
+ finally:
426
+ con.close()
427
+
428
+ def recover_interrupted(self) -> List[str]:
429
+ """At startup: an item left `executing` (the process died mid-call)
430
+ may or may not have happened. Mark it uncertain; never re-run it."""
431
+ con = self._connect()
432
+ try:
433
+ con.execute("BEGIN IMMEDIATE")
434
+ rows = con.execute("SELECT id, payload_hash FROM pending_actions WHERE status=?",
435
+ (EXECUTING,)).fetchall()
436
+ for r in rows:
437
+ con.execute("UPDATE pending_actions SET status=?, result=? WHERE id=? AND "
438
+ "status=?", (UNCERTAIN, "interrupted while running; outcome "
439
+ "unknown", r["id"], EXECUTING))
440
+ self._event(con, r["id"], "uncertain", "", r["payload_hash"], "interrupted")
441
+ con.execute("COMMIT")
442
+ return [r["id"] for r in rows]
443
+ finally:
444
+ con.close()
445
+
446
+ def _decidable(self, con, action_id: str, actor: Actor) -> PendingAction:
447
+ if not isinstance(actor, Actor) or not actor.is_owner:
448
+ who = actor.label() if isinstance(actor, Actor) else repr(actor)
449
+ self._event(con, str(action_id), "refused", who, "", "not the owner")
450
+ raise ApprovalError("Only the owner can approve, edit or cancel this.")
451
+ self._expire(con, time.time())
452
+ row = con.execute("SELECT * FROM pending_actions WHERE id=?",
453
+ (str(action_id),)).fetchone()
454
+ if row is None:
455
+ raise ApprovalError("No such pending action.")
456
+ item = PendingAction.from_row(row)
457
+ if item.status != PENDING:
458
+ raise ApprovalError(f"This action is already {item.status}; nothing was run.")
459
+ if payload_hash(item.tool, item.args) != item.payload_hash:
460
+ self._event(con, item.id, "refused", actor.label(), item.payload_hash,
461
+ "stored payload does not match its hash")
462
+ raise ApprovalError("The stored payload does not match what was approved; refused.")
463
+ if item.tool in _terminal_only() and actor.surface != "tui":
464
+ raise ApprovalError("This action can only be approved in the HyperAgents "
465
+ "terminal on the computer.")
466
+ return item
467
+
468
+ def claim(self, action_id: str, approved_hash: str, actor: Actor) -> PendingAction:
469
+ """Owner pressed Send for the payload whose hash is `approved_hash`.
470
+ Moves the item pending -> executing exactly once."""
471
+ con = self._connect()
472
+ try:
473
+ con.execute("BEGIN IMMEDIATE")
474
+ try:
475
+ item = self._decidable(con, action_id, actor)
476
+ h = str(approved_hash or "")
477
+ if len(h) < 16 or not item.payload_hash.startswith(h):
478
+ self._event(con, item.id, "refused", actor.label(), h, "payload hash mismatch")
479
+ raise ApprovalError("The approved content does not match the queued "
480
+ "payload (it was changed); nothing was run.")
481
+ cur = con.execute("UPDATE pending_actions SET status=?, decided_by=?, "
482
+ "decided_at=? WHERE id=? AND status=? AND payload_hash=?",
483
+ (EXECUTING, actor.label(), time.time(), item.id, PENDING,
484
+ item.payload_hash))
485
+ if cur.rowcount != 1:
486
+ raise ApprovalError("This action was already decided; nothing was run.")
487
+ self._event(con, item.id, "approved", actor.label(), item.payload_hash)
488
+ con.execute("COMMIT")
489
+ except ApprovalError:
490
+ con.execute("COMMIT") # keep the refusal event
491
+ raise
492
+ except ApprovalError:
493
+ raise
494
+ except Exception:
495
+ _rollback(con)
496
+ raise
497
+ finally:
498
+ con.close()
499
+ item.status = EXECUTING
500
+ item.decided_by = actor.label()
501
+ return item
502
+
503
+ def finish(self, action_id: str, status: str, result: str = "",
504
+ receipt_id: Optional[str] = None) -> None:
505
+ con = self._connect()
506
+ try:
507
+ con.execute("BEGIN IMMEDIATE")
508
+ con.execute("UPDATE pending_actions SET status=?, result=?, receipt_id=? WHERE id=? "
509
+ "AND status=?", (status, (result or "")[:4000], receipt_id, action_id,
510
+ EXECUTING))
511
+ self._event(con, action_id, status, "", "", (result or "")[:300])
512
+ con.execute("COMMIT")
513
+ finally:
514
+ con.close()
515
+
516
+ def cancel(self, action_id: str, actor: Actor) -> PendingAction:
517
+ con = self._connect()
518
+ try:
519
+ con.execute("BEGIN IMMEDIATE")
520
+ try:
521
+ item = self._decidable(con, action_id, actor)
522
+ con.execute("UPDATE pending_actions SET status=?, decided_by=?, decided_at=? "
523
+ "WHERE id=? AND status=?", (CANCELLED, actor.label(), time.time(),
524
+ item.id, PENDING))
525
+ self._event(con, item.id, "cancelled", actor.label(), item.payload_hash)
526
+ con.execute("COMMIT")
527
+ except ApprovalError:
528
+ con.execute("COMMIT")
529
+ raise
530
+ finally:
531
+ con.close()
532
+ item.status = CANCELLED
533
+ return item
534
+
535
+ def supersede(self, action_id: str, new_args: Dict[str, Any], actor: Actor) -> PendingAction:
536
+ """Edit: the old item is superseded; the edited payload is a new
537
+ pending item (new id, new hash) that must be shown and sent again."""
538
+ con = self._connect()
539
+ try:
540
+ con.execute("BEGIN IMMEDIATE")
541
+ try:
542
+ item = self._decidable(con, action_id, actor)
543
+ con.execute("UPDATE pending_actions SET status=?, decided_by=?, decided_at=? "
544
+ "WHERE id=? AND status=?", (SUPERSEDED, actor.label(), time.time(),
545
+ item.id, PENDING))
546
+ self._event(con, item.id, "edited", actor.label(), item.payload_hash)
547
+ con.execute("COMMIT")
548
+ except ApprovalError:
549
+ con.execute("COMMIT")
550
+ raise
551
+ finally:
552
+ con.close()
553
+ return self.propose(tool=item.tool, args=new_args, conversation=item.conversation,
554
+ surface=item.surface, scope=item.scope, requester=item.requester,
555
+ kind=item.kind, reason=item.reason, untrusted=item.untrusted,
556
+ edit_of=item.id)
557
+
558
+
559
+ def _rollback(con) -> None:
560
+ try:
561
+ con.execute("ROLLBACK")
562
+ except sqlite3.Error:
563
+ pass
564
+
565
+
566
+ def _terminal_only() -> frozenset:
567
+ from .surfaces import NEVER_FROM_CHAT
568
+ return NEVER_FROM_CHAT
569
+
570
+
571
+ _QUEUES: Dict[str, ApprovalQueue] = {}
572
+ _QUEUES_LOCK = threading.Lock()
573
+
574
+
575
+ def get_queue() -> ApprovalQueue:
576
+ """The queue for the current HYPERCLAW_ROOT (read at call time)."""
577
+ p = str(default_path())
578
+ with _QUEUES_LOCK:
579
+ if p not in _QUEUES:
580
+ _QUEUES[p] = ApprovalQueue(p)
581
+ return _QUEUES[p]
582
+
583
+
584
+ # ───────────────────────── trusted auto-send hook (OFF by default) ─────────────────────────
585
+
586
+ def _approval_config() -> dict:
587
+ p = Path(os.environ.get("HYPERCLAW_APPROVAL_CONFIG", hyperclaw_root() / "approval.json"))
588
+ try:
589
+ with open(p, encoding="utf-8") as f:
590
+ cfg = json.load(f)
591
+ return cfg if isinstance(cfg, dict) else {}
592
+ except (OSError, ValueError):
593
+ return {}
594
+
595
+
596
+ def _recipients(tool: str, args: dict) -> List[str]:
597
+ vals: List[str] = []
598
+ for k in ("to", "cc", "bcc", "recipient"):
599
+ v = args.get(k)
600
+ if isinstance(v, str):
601
+ vals.extend(x.strip().strip("<>").lower() for x in v.replace(";", ",").split(","))
602
+ elif isinstance(v, (list, tuple)):
603
+ vals.extend(str(x).strip().strip("<>").lower() for x in v)
604
+ return [v for v in vals if v]
605
+
606
+
607
+ AUTO_SEND_TOOLS = frozenset({"email_send", "imessage"})
608
+
609
+
610
+ def auto_send_allowed(tool: str, args: dict) -> bool:
611
+ """Trusted per-contact auto-send. OFF unless the owner's approval.json
612
+ sets auto_send.enabled = true AND every recipient of this message is a
613
+ listed contact or in a listed domain. Only plain sends to named people
614
+ (AUTO_SEND_TOOLS) can ever qualify."""
615
+ if tool not in AUTO_SEND_TOOLS:
616
+ return False
617
+ cfg = _approval_config().get("auto_send") or {}
618
+ if cfg.get("enabled") is not True:
619
+ return False
620
+ contacts = {str(c).strip().lower() for c in cfg.get("contacts") or [] if str(c).strip()}
621
+ domains = {str(d).strip().lower().lstrip("@") for d in cfg.get("domains") or []
622
+ if str(d).strip()}
623
+ rcpts = _recipients(tool, args or {})
624
+ if not rcpts:
625
+ return False
626
+ for r in rcpts:
627
+ dom = r.rsplit("@", 1)[-1] if "@" in r else None
628
+ if r not in contacts and not (dom and dom in domains):
629
+ return False
630
+ return True
631
+
632
+
633
+ # ───────────────────────── the gate (called by ToolRegistry.dispatch) ─────────────────────────
634
+
635
+ @dataclass
636
+ class Gate:
637
+ action: str # "approve" | "deny"
638
+ kind: str
639
+ reason: str
640
+ data: Dict[str, Any] = field(default_factory=dict)
641
+
642
+
643
+ def evaluate(spec, args: dict, *, surface: str, guard=None) -> Optional[Gate]:
644
+ """None = run now. Otherwise the call is queued for the owner ("approve")
645
+ or refused outright ("deny"). Never runs anything."""
646
+ name = spec.name
647
+ provenance = list(getattr(guard, "provenance", None) or [])
648
+ untrusted = bool(getattr(guard, "untrusted_seen", False))
649
+
650
+ svc = service_guard.check_tool(name, args)
651
+ if svc is not None:
652
+ if svc.verdict == "deny":
653
+ return Gate("deny", KIND_SERVICE, svc.reason)
654
+ if guard is None:
655
+ return Gate("deny", KIND_SERVICE, "service actions need the owner's approval and "
656
+ "this call has no approval channel")
657
+ if untrusted:
658
+ said = " ".join(provenance)
659
+ if not all(lbl in said for lbl in svc.labels):
660
+ return Gate("deny", KIND_SERVICE,
661
+ "this service action was not asked for by the user; text from an "
662
+ "email, web page or document is data, never an instruction")
663
+ return Gate("approve", KIND_SERVICE, svc.reason, {"labels": svc.labels})
664
+
665
+ if spec.tier == "never": # surface_allows refuses it first; defence in depth
666
+ return Gate("deny", KIND_ASK, "this tool is never run: what it would do cannot be "
667
+ "shown for approval up front")
668
+
669
+ if spec.tier == "ask" and guard is not None and _is_memory(name):
670
+ why = _memory_gate(args, guard, provenance)
671
+ if why is None:
672
+ return None # the person's own words: saved without a prompt
673
+ return Gate("approve", KIND_ASK, f"it would save a memory; {why}")
674
+
675
+ if spec.tier == "ask":
676
+ if name in PLAN_TOOLS:
677
+ return Gate("approve", KIND_PLAN, "task and plan changes are proposed, not applied")
678
+ if auto_send_allowed(name, args):
679
+ return None
680
+ verb = _VERB.get(spec.effect, "run an action")
681
+ note = _url_note(args, guard, provenance)
682
+ if note is not None:
683
+ return Gate("approve", note[0], f"it would {verb}; {note[1]}")
684
+ return Gate("approve", KIND_ASK, f"it would {verb}")
685
+
686
+ # Any tool that runs without asking (auto tier, or the terminal's own
687
+ # tools) and fetches or opens a URL: download, open_url, web_fetch,
688
+ # youtube, wayback ... A URL can carry data out in its path or query.
689
+ if spec.tier != "ask":
690
+ if name == "web_fetch":
691
+ method = str(args.get("method") or "GET").upper()
692
+ if method not in egress.READ_METHODS: # schema forbids it; defence in depth
693
+ return Gate("deny", KIND_URL, f"web_fetch only reads (GET/HEAD), not {method}; "
694
+ f"use http_request to send data")
695
+ for url in egress.urls_in_args(args):
696
+ if egress.is_private_target(url):
697
+ if guard is None:
698
+ return Gate("deny", KIND_PRIVATE, f"{url} is a local or private address")
699
+ return Gate("approve", KIND_PRIVATE,
700
+ f"{url} is a local or private address (this computer or its network)")
701
+ if guard is not None and not egress.url_in_provenance(url, provenance):
702
+ return Gate("approve", KIND_URL,
703
+ "this exact address is not in anything the user wrote or gave; a "
704
+ "built or found address could carry data out")
705
+ # Shell / code execution and desktop or app control (tier never_from_chat,
706
+ # offered on the local terminal only) and any other tool that is not
707
+ # read-only: queued with its exact command / action, like every send.
708
+ if spec.effect != "read":
709
+ verb = _VERB.get(spec.effect, "run an action")
710
+ return Gate("approve", KIND_ASK, f"it would {verb} on this computer"
711
+ if spec.effect == "system" else f"it would {verb}")
712
+ return None
713
+
714
+
715
+ def _is_memory(name: str) -> bool:
716
+ from hyperclaw.taint import MEMORY_TOOLS
717
+ return name in MEMORY_TOOLS
718
+
719
+
720
+ def _memory_gate(args: dict, guard, provenance: List[str]) -> Optional[str]:
721
+ """Memory, per message (rc5): the owner's own words save without a
722
+ prompt; anything learned from email, pages or other people (or asked for
723
+ by an agent) waits for the person's tap, with the source named."""
724
+ if getattr(guard, "requester", "owner") != "owner":
725
+ return "an agent on your team asked to save it"
726
+ from hyperclaw import taint
727
+ why = taint.memory_decision(str(args.get("content") or ""),
728
+ session=getattr(guard, "conversation", None),
729
+ message=getattr(guard, "turn_id", None),
730
+ provenance=provenance,
731
+ read_untrusted=_read_pages(guard))
732
+ if why is None and isinstance(getattr(guard, "memory_saved_unprompted", None), list):
733
+ guard.memory_saved_unprompted.append(str(args.get("content") or "")[:80])
734
+ return why
735
+
736
+
737
+ def _read_pages(guard) -> bool:
738
+ from hyperclaw.taint import PAGE_TOOLS
739
+ return any(r.get("tool") in PAGE_TOOLS and r.get("status") == "ok"
740
+ for r in getattr(guard, "results", None) or [])
741
+
742
+
743
+ def _url_note(args: dict, guard, provenance: List[str]) -> Optional[Tuple[str, str]]:
744
+ """For a gated call that also reaches a URL: say so on the card."""
745
+ for url in egress.urls_in_args(args):
746
+ if egress.is_private_target(url):
747
+ return KIND_PRIVATE, (f"{url} is a local or private address (this computer or its "
748
+ f"network)")
749
+ if guard is not None and not egress.url_in_provenance(url, provenance):
750
+ return KIND_URL, ("this exact address is not in anything the user wrote or gave; a "
751
+ "built or found address could carry data out")
752
+ return None
753
+
754
+
755
+ # ───────────────────────── display ─────────────────────────
756
+
757
+ def _fmt_value(v: Any) -> str:
758
+ if isinstance(v, str):
759
+ return v
760
+ return json.dumps(v, ensure_ascii=False, indent=2, default=str)
761
+
762
+
763
+ def content_lines(item: PendingAction) -> List[str]:
764
+ """The exact content, field by field (secrets in headers masked)."""
765
+ a = dict(item.args or {})
766
+ lines: List[str] = []
767
+ if item.tool in ACTION_LINE:
768
+ lines.append(f"Action: {ACTION_LINE[item.tool]}")
769
+ if item.tool == "bash" and isinstance(a.get("command"), str):
770
+ lines.append("Command line (exactly this runs):\n" + a.pop("command"))
771
+ if item.tool in ("gmail_send", "gmail_create_draft"):
772
+ # The exact email: To / Cc / Bcc / Subject, then the whole body.
773
+ from hyperclaw.google_workspace.tools import mail_card_lines
774
+ return lines + mail_card_lines(a)
775
+ if item.tool in ("http_request", "web_fetch"):
776
+ lines.append(f"Method: {str(a.pop('method', 'GET') or 'GET').upper()}")
777
+ lines.append(f"URL: {a.pop('url', '')}")
778
+ headers = a.pop("headers", None)
779
+ lines.append("Headers: " + (_fmt_value(egress.mask_headers(headers)) if headers else "(none)"))
780
+ if "json_body" in a:
781
+ lines.append("Body (JSON):\n" + _fmt_value(a.pop("json_body")))
782
+ if "body" in a:
783
+ lines.append("Body:\n" + _fmt_value(a.pop("body")))
784
+ elif item.tool == "http_request" and not any(k in item.args for k in ("json_body",)):
785
+ lines.append("Body: (none)")
786
+ for k in sorted(a):
787
+ v = _fmt_value(a[k])
788
+ lines.append(f"{k}:\n{v}" if "\n" in v or len(v) > 60 else f"{k}: {v}")
789
+ return lines
790
+
791
+
792
+ def requester_label(requester: str) -> str:
793
+ """How the card names who asked: an agent on the person's team by its
794
+ current name ("Inbox (agent on your team)"); anything else as stored."""
795
+ if str(requester or "").startswith("agent:"):
796
+ try:
797
+ from hyperclaw.team.roster import display_requester
798
+ return display_requester(requester)
799
+ except Exception:
800
+ return str(requester)
801
+ return str(requester or "")
802
+
803
+
804
+ def render(item: PendingAction) -> str:
805
+ who = "" if item.requester in ("owner", "") else \
806
+ f"Requested by: {requester_label(item.requester)}\n"
807
+ warn = ("Note: this came up after reading an email, web page or document. Check it is "
808
+ "what you asked for.\n" if item.untrusted else "")
809
+ head = f"Approval needed [{item.id}]"
810
+ if str(item.requester or "").startswith("agent:"):
811
+ head += f" from {requester_label(item.requester).split(' (', 1)[0]}"
812
+ return (f"{head}\n"
813
+ f"Tool: {item.tool}\n{who}"
814
+ f"Why: {item.reason}\n{warn}"
815
+ f"--- exact content ---\n" + "\n".join(content_lines(item)) +
816
+ f"\n--- payload {item.short_hash} ---")
817
+
818
+
819
+ # ───────────────────────── Send / Edit / Cancel ─────────────────────────
820
+
821
+ def _default_registry():
822
+ from hyperclaw import tui
823
+ return tui.REGISTRY
824
+
825
+
826
+ def send(action_id: str, approved_hash: str, actor: Actor, *, registry=None,
827
+ run: Optional[Callable] = None, queue: Optional[ApprovalQueue] = None,
828
+ ledger=None):
829
+ """Owner pressed Send. Runs exactly the stored payload once; returns the
830
+ tool's envelope (or a refusal envelope; nothing runs on a refusal)."""
831
+ from .envelope import Status, make
832
+ from .guard import TurnGuard
833
+ q = queue or get_queue()
834
+ try:
835
+ item = q.claim(action_id, approved_hash, actor)
836
+ except ApprovalError as e:
837
+ return make(Status.PERMISSION_DENIED, f"Not sent: {e}")
838
+ reg = registry or _default_registry()
839
+ guard = TurnGuard(conversation=item.conversation, surface=item.surface, approved=True,
840
+ ledger=ledger)
841
+ try:
842
+ env = reg.dispatch(item.tool, item.args, surface=item.surface, scope=item.scope,
843
+ run=run, guard=guard, approved_hash=item.payload_hash)
844
+ except Exception as e: # dispatch never raises; belt and braces
845
+ env = make(Status.UNCERTAIN, f"{item.tool} raised {type(e).__name__}; outcome unknown")
846
+ status = {"ok": DONE, "uncertain": UNCERTAIN, "duplicate": DONE}.get(env.status, FAILED)
847
+ q.finish(item.id, status, env.message, env.receipt_id)
848
+ return env
849
+
850
+
851
+ def cancel(action_id: str, actor: Actor, *, queue: Optional[ApprovalQueue] = None):
852
+ from .envelope import Status, make
853
+ q = queue or get_queue()
854
+ try:
855
+ q.cancel(action_id, actor)
856
+ except ApprovalError as e:
857
+ return make(Status.PERMISSION_DENIED, f"Not cancelled: {e}")
858
+ return make(Status.PERMISSION_DENIED, f"Cancelled by the user: nothing was done "
859
+ f"[{action_id}].")
860
+
861
+
862
+ def edited_args(item: PendingAction, new_content: str,
863
+ arg_names: Optional[Iterable[str]] = None) -> Dict[str, Any]:
864
+ """The owner's edit. A JSON object whose keys are all arguments of the
865
+ tool replaces the arguments; anything else (plain text, or a JSON body
866
+ for http_request) replaces the tool's main content field."""
867
+ s = (new_content or "").strip()
868
+ names = set(arg_names or ())
869
+ if s.startswith("{"):
870
+ try:
871
+ obj = json.loads(s)
872
+ except ValueError:
873
+ obj = None
874
+ if isinstance(obj, dict) and obj and (not names or set(obj) <= names):
875
+ return obj
876
+ fld = EDIT_FIELD.get(item.tool)
877
+ if not fld:
878
+ raise ApprovalError(f"For {item.tool}, send the full arguments as a JSON object.")
879
+ out = dict(item.args)
880
+ if item.tool == "http_request" and "json_body" in out and "body" not in out:
881
+ try:
882
+ out["json_body"] = json.loads(s)
883
+ return out
884
+ except ValueError:
885
+ out.pop("json_body")
886
+ out[fld] = new_content
887
+ return out
888
+
889
+
890
+ def edit(action_id: str, new_content: str, actor: Actor, *, registry=None,
891
+ queue: Optional[ApprovalQueue] = None) -> PendingAction:
892
+ """Owner pressed Edit and supplied new content. Returns the NEW pending
893
+ item (to be shown again); raises ApprovalError when refused."""
894
+ from . import args as targs
895
+ q = queue or get_queue()
896
+ item = q.get(action_id)
897
+ if item is None:
898
+ raise ApprovalError("No such pending action.")
899
+ if not isinstance(actor, Actor) or not actor.is_owner:
900
+ raise ApprovalError("Only the owner can approve, edit or cancel this.")
901
+ reg = registry or _default_registry()
902
+ spec = reg.get(item.tool)
903
+ names = list((spec.input_schema().get("properties") or {}).keys()) if spec else None
904
+ new_args = edited_args(item, new_content, names)
905
+ if spec is not None:
906
+ try:
907
+ new_args = targs.validate(spec.args_model, new_args)
908
+ except targs.ArgsError as e:
909
+ raise ApprovalError(f"Those arguments are not valid for {item.tool}: {e}")
910
+ return q.supersede(action_id, new_args, actor)
911
+
912
+
913
+ def needs_approval_envelope(item: PendingAction):
914
+ from .envelope import Status, make
915
+ return make(Status.NEEDS_APPROVAL,
916
+ f"{item.tool} was NOT run. It is queued for the owner's approval "
917
+ f"[{item.id}]: {item.reason}. The owner sees the exact content and can Send, "
918
+ f"Edit or Cancel it.",
919
+ data={"approval_id": item.id, "payload_hash": item.short_hash,
920
+ "tool": item.tool, "kind": item.kind, "preview": render(item)},
921
+ receipt_id=item.id)
922
+
923
+
924
+ def pending_ids(envelopes: Iterable[Any]) -> List[str]:
925
+ out = []
926
+ for e in envelopes:
927
+ d = getattr(e, "data", None)
928
+ if getattr(e, "status", None) == "needs_approval" and isinstance(d, dict) \
929
+ and d.get("approval_id"):
930
+ out.append(d["approval_id"])
931
+ return out
932
+
933
+
934
+ def provenance_from_messages(messages: Iterable[Any], extra: Iterable[str] = ()) -> List[str]:
935
+ """The user's own words and the content they gave (pasted text, inlined
936
+ attachments) from a message list. Tool results are NOT provenance: what
937
+ the model read from an email, page or file is data."""
938
+ out: List[str] = [t for t in extra if isinstance(t, str) and t]
939
+ for m in messages or ():
940
+ if not isinstance(m, dict) or m.get("role") != "user":
941
+ continue
942
+ c = m.get("content")
943
+ if isinstance(c, str):
944
+ out.append(c)
945
+ elif isinstance(c, list):
946
+ for b in c:
947
+ if isinstance(b, dict) and b.get("type") == "text" and isinstance(b.get("text"), str):
948
+ out.append(b["text"])
949
+ return out