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.
- agents/__init__.py +31 -0
- agents/background_monitor.py +453 -0
- agents/learning.py +460 -0
- agents/planner.py +421 -0
- agents/transcriber.py +341 -0
- civilization/__init__.py +51 -0
- civilization/api/__init__.py +7 -0
- civilization/api/civilization_api.py +361 -0
- civilization/graph/__init__.py +19 -0
- civilization/graph/knowledge_linker.py +348 -0
- civilization/graph/org_graph.py +348 -0
- civilization/graph/process_graph.py +386 -0
- civilization/ingestion/__init__.py +20 -0
- civilization/ingestion/chunker.py +273 -0
- civilization/ingestion/document_ingestor.py +358 -0
- civilization/ingestion/embedder.py +262 -0
- civilization/ingestion/extractor.py +224 -0
- civilization/interview/__init__.py +19 -0
- civilization/interview/gap_detector.py +380 -0
- civilization/interview/interviewer.py +323 -0
- civilization/interview/questioner.py +363 -0
- civilization/retrieval/__init__.py +17 -0
- civilization/retrieval/civilization_rag.py +316 -0
- civilization/retrieval/context_injector.py +342 -0
- civilization/retrieval/relevance_ranker.py +287 -0
- civilization/schema.py +299 -0
- civilization/store.py +177 -0
- civilization/sync/__init__.py +16 -0
- civilization/sync/confluence_sync.py +317 -0
- civilization/sync/gdrive_sync.py +262 -0
- civilization/sync/notion_sync.py +237 -0
- civilization/versioning/__init__.py +19 -0
- civilization/versioning/diff_engine.py +351 -0
- civilization/versioning/staleness_detector.py +303 -0
- civilization/versioning/version_manager.py +293 -0
- cli/__init__.py +1 -0
- cli/doctor.py +334 -0
- cli/hyperclaw.py +1357 -0
- cli/onboarding.py +469 -0
- core/__init__.py +1 -0
- core/connectors/__init__.py +119 -0
- core/connectors/infrastructure.py +772 -0
- core/connectors/messaging.py +586 -0
- core/connectors/productivity.py +764 -0
- core/connectors/smarthome.py +528 -0
- core/connectors/social.py +730 -0
- core/hyperrouter/__init__.py +23 -0
- core/hyperrouter/bandit.py +218 -0
- core/hyperrouter/fast_loop.py +68 -0
- core/hyperrouter/slow_loop.py +143 -0
- core/hyperstate/__init__.py +24 -0
- core/hyperstate/certifier.py +154 -0
- core/hyperstate/schema.py +160 -0
- core/hyperstate/state_manager.py +103 -0
- core/hyperstate/store.py +163 -0
- core/providers/__init__.py +121 -0
- core/providers/embeddings.py +389 -0
- core/providers/image.py +534 -0
- core/providers/llm.py +1935 -0
- core/providers/search.py +608 -0
- core/providers/voice.py +474 -0
- hyperagents-1.2.3rc5.post3.dist-info/METADATA +469 -0
- hyperagents-1.2.3rc5.post3.dist-info/RECORD +361 -0
- hyperagents-1.2.3rc5.post3.dist-info/WHEEL +5 -0
- hyperagents-1.2.3rc5.post3.dist-info/entry_points.txt +6 -0
- hyperagents-1.2.3rc5.post3.dist-info/licenses/LICENSE +21 -0
- hyperagents-1.2.3rc5.post3.dist-info/top_level.txt +13 -0
- hyperclaw/__init__.py +67 -0
- hyperclaw/__main__.py +220 -0
- hyperclaw/agent.py +775 -0
- hyperclaw/agent_coordinator.py +577 -0
- hyperclaw/anthropic_params.py +225 -0
- hyperclaw/api_utils.py +85 -0
- hyperclaw/approval_telegram.py +209 -0
- hyperclaw/approval_terminal.py +80 -0
- hyperclaw/attachments.py +177 -0
- hyperclaw/branding.py +55 -0
- hyperclaw/brief/__init__.py +11 -0
- hyperclaw/brief/cli.py +204 -0
- hyperclaw/brief/collect.py +146 -0
- hyperclaw/brief/compose.py +333 -0
- hyperclaw/brief/diff.py +110 -0
- hyperclaw/brief/errors.py +99 -0
- hyperclaw/brief/google.py +269 -0
- hyperclaw/brief/google_auth.py +294 -0
- hyperclaw/brief/links.py +124 -0
- hyperclaw/brief/service.py +424 -0
- hyperclaw/brief/store.py +205 -0
- hyperclaw/brief/tasks.py +200 -0
- hyperclaw/chat_scope.py +115 -0
- hyperclaw/cli_tui.py +497 -0
- hyperclaw/client_guard.py +717 -0
- hyperclaw/connection_check.py +113 -0
- hyperclaw/cost_guard.py +1728 -0
- hyperclaw/default_config/agents.yaml +534 -0
- hyperclaw/default_config/hyperclaw.yaml +27 -0
- hyperclaw/default_config/models.yaml +161 -0
- hyperclaw/deployment.py +39 -0
- hyperclaw/digest/__init__.py +9 -0
- hyperclaw/digest/cli.py +117 -0
- hyperclaw/digest/errors.py +100 -0
- hyperclaw/digest/gitlab.py +187 -0
- hyperclaw/digest/http.py +113 -0
- hyperclaw/digest/redact.py +32 -0
- hyperclaw/digest/service.py +271 -0
- hyperclaw/digest/slack.py +223 -0
- hyperclaw/digest/state.py +124 -0
- hyperclaw/digest/summarize.py +198 -0
- hyperclaw/digest/tokens.py +143 -0
- hyperclaw/distribution.py +46 -0
- hyperclaw/documents.py +351 -0
- hyperclaw/feeds.py +339 -0
- hyperclaw/google_workspace/__init__.py +5 -0
- hyperclaw/google_workspace/api.py +151 -0
- hyperclaw/google_workspace/auth.py +397 -0
- hyperclaw/google_workspace/calendar.py +157 -0
- hyperclaw/google_workspace/cli.py +72 -0
- hyperclaw/google_workspace/drive.py +236 -0
- hyperclaw/google_workspace/errors.py +58 -0
- hyperclaw/google_workspace/gmail.py +295 -0
- hyperclaw/google_workspace/meet.py +122 -0
- hyperclaw/google_workspace/tools.py +319 -0
- hyperclaw/hyperspeed.py +57 -0
- hyperclaw/integrations_layer.py +1427 -0
- hyperclaw/live_state.py +188 -0
- hyperclaw/managed_config.py +245 -0
- hyperclaw/media_hub.py +179 -0
- hyperclaw/memory_manager.py +584 -0
- hyperclaw/model_catalog.py +551 -0
- hyperclaw/model_policy.py +211 -0
- hyperclaw/model_router.py +595 -0
- hyperclaw/model_selector.py +72 -0
- hyperclaw/naming.py +284 -0
- hyperclaw/notices.py +65 -0
- hyperclaw/oai_stream.py +106 -0
- hyperclaw/orchestrator.py +591 -0
- hyperclaw/outbox.py +82 -0
- hyperclaw/persona.py +367 -0
- hyperclaw/prometheus.py +219 -0
- hyperclaw/prompt_assembly.py +157 -0
- hyperclaw/provider_setup.py +146 -0
- hyperclaw/providers.py +340 -0
- hyperclaw/qualification.py +341 -0
- hyperclaw/qualification_keys/hyper-d292fd127cef5a3e.pub +1 -0
- hyperclaw/qualification_records/anthropic--claude-sonnet-5-5.json +14 -0
- hyperclaw/reasoning_display.py +35 -0
- hyperclaw/scheduler.py +454 -0
- hyperclaw/server.py +847 -0
- hyperclaw/setup.py +627 -0
- hyperclaw/skills.py +717 -0
- hyperclaw/solomon.py +442 -0
- hyperclaw/swarm.py +533 -0
- hyperclaw/taint.py +832 -0
- hyperclaw/team/__init__.py +28 -0
- hyperclaw/team/dispatcher.py +431 -0
- hyperclaw/team/genesis.py +106 -0
- hyperclaw/team/lead.py +110 -0
- hyperclaw/team/model.py +135 -0
- hyperclaw/team/morning.py +238 -0
- hyperclaw/team/repos.py +66 -0
- hyperclaw/team/roster.py +264 -0
- hyperclaw/team/specialist.py +235 -0
- hyperclaw/team/status.py +163 -0
- hyperclaw/team/watchers.py +112 -0
- hyperclaw/teamsetup/__init__.py +17 -0
- hyperclaw/teamsetup/checks.py +208 -0
- hyperclaw/teamsetup/item.py +391 -0
- hyperclaw/teamsetup/oauth_loopback.py +84 -0
- hyperclaw/teamsetup/onboard.py +550 -0
- hyperclaw/teamsetup/profile.py +82 -0
- hyperclaw/teamsetup/secrets.py +313 -0
- hyperclaw/teamsetup/selfcheck.py +295 -0
- hyperclaw/teamsetup/service.py +495 -0
- hyperclaw/teamsetup/slack_oauth.py +442 -0
- hyperclaw/teamsetup/tty.py +62 -0
- hyperclaw/telegram_bot.py +1106 -0
- hyperclaw/telegram_intake.py +126 -0
- hyperclaw/telegram_pairing.py +360 -0
- hyperclaw/terminal_provider.py +323 -0
- hyperclaw/tools/__init__.py +1 -0
- hyperclaw/tools/approval.py +949 -0
- hyperclaw/tools/args.py +212 -0
- hyperclaw/tools/catalog.py +244 -0
- hyperclaw/tools/classify.py +266 -0
- hyperclaw/tools/effects.py +724 -0
- hyperclaw/tools/egress.py +210 -0
- hyperclaw/tools/envelope.py +188 -0
- hyperclaw/tools/guard.py +367 -0
- hyperclaw/tools/ledger.py +339 -0
- hyperclaw/tools/receipts.py +70 -0
- hyperclaw/tools/registry.py +318 -0
- hyperclaw/tools/service_guard.py +151 -0
- hyperclaw/tools/surfaces.py +54 -0
- hyperclaw/tools/workspace.py +87 -0
- hyperclaw/trading/__init__.py +77 -0
- hyperclaw/truth_guard.py +791 -0
- hyperclaw/tts.py +124 -0
- hyperclaw/tui.py +4255 -0
- hyperclaw/tui_bridge.py +938 -0
- hyperclaw/unattended.py +60 -0
- hyperclaw/updates/__init__.py +54 -0
- hyperclaw/updates/check.py +131 -0
- hyperclaw/updates/index.py +178 -0
- hyperclaw/updates/install.py +110 -0
- hyperclaw/updates/managed.py +161 -0
- hyperclaw/updates/manifest.py +138 -0
- hyperclaw/updates/notices.py +86 -0
- hyperclaw/updates/paths.py +53 -0
- hyperclaw/updates/release.py +146 -0
- hyperclaw/updates/upgrade.py +253 -0
- hyperclaw/updates/versioning.py +104 -0
- hyperclaw/userconfig.py +119 -0
- hyperclaw/xai.py +83 -0
- integrations/__init__.py +31 -0
- integrations/automation/__init__.py +1 -0
- integrations/automation/make.py +87 -0
- integrations/automation/zapier.py +87 -0
- integrations/base.py +476 -0
- integrations/communication/__init__.py +1 -0
- integrations/communication/twilio.py +279 -0
- integrations/communication/zoom.py +290 -0
- integrations/data/__init__.py +1 -0
- integrations/data/google_analytics.py +135 -0
- integrations/data/postgres.py +182 -0
- integrations/data/supabase_connector.py +202 -0
- integrations/data/weather_feeds.py +450 -0
- integrations/developer/__init__.py +9 -0
- integrations/developer/github.py +406 -0
- integrations/developer/gitlab.py +298 -0
- integrations/enterprise/__init__.py +13 -0
- integrations/enterprise/confluence.py +266 -0
- integrations/enterprise/hubspot.py +293 -0
- integrations/enterprise/jira.py +314 -0
- integrations/enterprise/salesforce.py +286 -0
- integrations/finance/__init__.py +1 -0
- integrations/finance/polymarket_client.py +247 -0
- integrations/finance/quickbooks.py +281 -0
- integrations/finance/stripe.py +261 -0
- integrations/gateway.py +238 -0
- integrations/messaging/__init__.py +32 -0
- integrations/messaging/discord.py +159 -0
- integrations/messaging/email.py +401 -0
- integrations/messaging/imessage.py +443 -0
- integrations/messaging/signal.py +175 -0
- integrations/messaging/slack.py +217 -0
- integrations/messaging/sms.py +175 -0
- integrations/messaging/teams.py +208 -0
- integrations/messaging/telegram.py +260 -0
- integrations/messaging/whatsapp.py +203 -0
- integrations/productivity/__init__.py +17 -0
- integrations/productivity/airtable.py +217 -0
- integrations/productivity/asana.py +248 -0
- integrations/productivity/google/__init__.py +19 -0
- integrations/productivity/google/base.py +89 -0
- integrations/productivity/google/calendar.py +272 -0
- integrations/productivity/google/docs.py +202 -0
- integrations/productivity/google/drive.py +268 -0
- integrations/productivity/google/gmail.py +279 -0
- integrations/productivity/google/meet.py +218 -0
- integrations/productivity/google/sheets.py +222 -0
- integrations/productivity/google/tasks.py +199 -0
- integrations/productivity/linear.py +279 -0
- integrations/productivity/microsoft/__init__.py +13 -0
- integrations/productivity/microsoft/base.py +93 -0
- integrations/productivity/microsoft/calendar.py +207 -0
- integrations/productivity/microsoft/onedrive.py +222 -0
- integrations/productivity/microsoft/outlook.py +241 -0
- integrations/productivity/microsoft/sharepoint.py +211 -0
- integrations/productivity/notion.py +243 -0
- integrations/productivity/todoist.py +250 -0
- integrations/productivity/trello.py +244 -0
- integrations/storage/__init__.py +1 -0
- integrations/storage/box.py +279 -0
- integrations/storage/dropbox.py +215 -0
- integrations/telegram_inbound.py +179 -0
- integrations/telegram_poller.py +151 -0
- memory/__init__.py +1 -0
- memory/causal_graph.py +125 -0
- memory/impact_tracker.py +88 -0
- memory/vector_memory.py +436 -0
- models/__init__.py +12 -0
- models/chatjimmy_client.py +210 -0
- models/claude_client.py +235 -0
- models/claude_code_subagent.py +192 -0
- models/openai_client.py +120 -0
- models/router.py +162 -0
- recursive/discovery_loop.py +118 -0
- recursive/optimization_loop.py +135 -0
- recursive/validation_loop.py +38 -0
- security/__init__.py +1 -0
- security/audit_logger.py +106 -0
- security/filesystem_guard.py +100 -0
- security/hypershield.py +99 -0
- security/network_guard.py +97 -0
- security/policy_engine.py +198 -0
- swarm/__init__.py +1 -0
- swarm/agents/__init__.py +0 -0
- swarm/agents/base.py +230 -0
- swarm/agents/business/__init__.py +0 -0
- swarm/agents/business/counsel.py +31 -0
- swarm/agents/business/herald.py +32 -0
- swarm/agents/business/ledger.py +26 -0
- swarm/agents/business/nexus.py +30 -0
- swarm/agents/business/ops.py +30 -0
- swarm/agents/business/pipeline.py +28 -0
- swarm/agents/business/revenue.py +30 -0
- swarm/agents/business/sovereign.py +31 -0
- swarm/agents/business/strategos.py +27 -0
- swarm/agents/business/talent.py +27 -0
- swarm/agents/business/venture.py +32 -0
- swarm/agents/comms/__init__.py +1 -0
- swarm/agents/comms/cipher.py +32 -0
- swarm/agents/comms/echo_comms.py +31 -0
- swarm/agents/comms/envoy.py +31 -0
- swarm/agents/comms/herald_scheduler.py +33 -0
- swarm/agents/comms/pulse.py +29 -0
- swarm/agents/creative/__init__.py +0 -0
- swarm/agents/creative/author.py +27 -0
- swarm/agents/creative/lens.py +25 -0
- swarm/agents/executive/__init__.py +1 -0
- swarm/agents/intelligence/__init__.py +1 -0
- swarm/agents/intelligence/arbiter.py +30 -0
- swarm/agents/intelligence/sentinel.py +30 -0
- swarm/agents/media/__init__.py +0 -0
- swarm/agents/personal/__init__.py +0 -0
- swarm/agents/personal/atlas.py +27 -0
- swarm/agents/personal/hearth.py +26 -0
- swarm/agents/personal/midas.py +27 -0
- swarm/agents/personal/navigator.py +27 -0
- swarm/agents/personal/nourish.py +31 -0
- swarm/agents/personal/vitals.py +33 -0
- swarm/agents/recursive/__init__.py +0 -0
- swarm/agents/recursive/alchemist.py +73 -0
- swarm/agents/recursive/calibrator.py +45 -0
- swarm/agents/recursive/scout.py +97 -0
- swarm/agents/scientific/__init__.py +0 -0
- swarm/agents/scientific/cosmos.py +36 -0
- swarm/agents/scientific/gaia.py +26 -0
- swarm/agents/scientific/medicus.py +33 -0
- swarm/agents/scientific/oracle_agent.py +39 -0
- swarm/agents/scientific/scribe.py +39 -0
- swarm/agents/talent/__init__.py +0 -0
- swarm/agents/talent/deal.py +28 -0
- swarm/agents/talent/roster.py +29 -0
- swarm/agents/talent/scout.py +29 -0
- swarm/agents/talent/stage.py +27 -0
- swarm/agents/tech/__init__.py +0 -0
- swarm/agents/tech/aegis.py +28 -0
- swarm/agents/tech/bridge.py +28 -0
- swarm/agents/tech/forge.py +28 -0
- swarm/agents/trading/__init__.py +1 -0
- swarm/agents/trading/global_prediction_engine.py +611 -0
- swarm/agents/trading/polymarket_trader.py +165 -0
- swarm/agents/trading/prediction_strategist.py +218 -0
- swarm/autogen_bridge.py +68 -0
- swarm/bid_protocol.py +196 -0
- swarm/dispatcher.py +527 -0
- swarm/nexus.py +181 -0
- swarm/registry.py +112 -0
- telegram_listener.py +403 -0
- 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
|