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,318 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The tool registry (tool standard S1) and its dispatcher.
|
|
3
|
+
|
|
4
|
+
One entry per tool: name, description, args model (Pydantic, extra=forbid;
|
|
5
|
+
the advertised schema is generated from it), effect, scope, tier,
|
|
6
|
+
retry_budget and idempotent_on. The dispatcher is a dictionary lookup by
|
|
7
|
+
name and knows nothing about individual tools. Every outcome — unknown
|
|
8
|
+
tool, malformed or invalid arguments, a tool not offered on this surface,
|
|
9
|
+
a handler exception, a timeout, or the tool's own result — comes back as a
|
|
10
|
+
ToolResult envelope (S2); dispatch never raises.
|
|
11
|
+
|
|
12
|
+
Broker mapping (hyper-nimbus-gtm agent-mode design, READ/PROPOSE/ACT):
|
|
13
|
+
effect "read" is READ; every other effect is PROPOSE (in the approval wave an
|
|
14
|
+
`ask` tool returns needs_approval with a proposal instead of running); ACT is
|
|
15
|
+
never a tool here — execution after approval is a runtime transition. See
|
|
16
|
+
docs/TOOL-STANDARD.md.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import contextvars
|
|
21
|
+
import logging
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
|
|
24
|
+
|
|
25
|
+
from hyperclaw.chat_scope import OWNER, normalize as normalize_scope
|
|
26
|
+
|
|
27
|
+
from . import args as targs
|
|
28
|
+
from .classify import classify
|
|
29
|
+
from .envelope import (REASON_SURFACE, REASON_UNKNOWN_TOOL, Status, ToolResult, make)
|
|
30
|
+
from .surfaces import NEVER_FROM_CHAT, allowed_on_surface
|
|
31
|
+
|
|
32
|
+
log = logging.getLogger("hyperclaw.tools")
|
|
33
|
+
|
|
34
|
+
EFFECTS = ("read", "write", "send", "spend", "delete", "system")
|
|
35
|
+
TIERS = ("auto", "ask", "never_from_chat", "never")
|
|
36
|
+
PUBLIC = "public" # tool scope a guest chat may use; anything else is owner-only
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# The turn guard of the tool call running right now (team_delegate reads the
|
|
40
|
+
# lead's provenance and requester from it). Set only around a dispatch.
|
|
41
|
+
_ACTIVE_GUARD: contextvars.ContextVar = contextvars.ContextVar("hyperclaw_active_guard",
|
|
42
|
+
default=None)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def active_guard():
|
|
46
|
+
return _ACTIVE_GUARD.get()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ToolTimeout(Exception):
|
|
50
|
+
"""Raised by a `run` hook when a tool exceeded its time budget."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class ToolSpec:
|
|
55
|
+
name: str
|
|
56
|
+
description: str
|
|
57
|
+
args_model: type
|
|
58
|
+
effect: str
|
|
59
|
+
scope: str
|
|
60
|
+
tier: str
|
|
61
|
+
retry_budget: int
|
|
62
|
+
idempotent_on: Tuple[str, ...]
|
|
63
|
+
handler: Callable[[str, dict], Any] = field(compare=False, repr=False)
|
|
64
|
+
# Wave 2: the idempotent_on fields that name the TARGET (the rest are the
|
|
65
|
+
# payload). A single path field makes the target "file:<resolved path>".
|
|
66
|
+
target_on: Tuple[str, ...] = ()
|
|
67
|
+
|
|
68
|
+
def __post_init__(self):
|
|
69
|
+
if self.effect not in EFFECTS:
|
|
70
|
+
raise ValueError(f"{self.name}: bad effect {self.effect!r}")
|
|
71
|
+
if self.tier not in TIERS:
|
|
72
|
+
raise ValueError(f"{self.name}: bad tier {self.tier!r}")
|
|
73
|
+
if self.name in NEVER_FROM_CHAT and self.tier not in ("never_from_chat", "never"):
|
|
74
|
+
raise ValueError(f"{self.name} must be tier never_from_chat (or never)")
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def broker_class(self) -> str:
|
|
78
|
+
"""READ / PROPOSE in the agent-mode broker's terms (ACT is a transition)."""
|
|
79
|
+
return "READ" if self.effect == "read" else "PROPOSE"
|
|
80
|
+
|
|
81
|
+
def input_schema(self) -> dict:
|
|
82
|
+
return targs.input_schema(self.args_model)
|
|
83
|
+
|
|
84
|
+
def anthropic(self) -> dict:
|
|
85
|
+
return {"name": self.name, "description": self.description,
|
|
86
|
+
"input_schema": self.input_schema()}
|
|
87
|
+
|
|
88
|
+
def openai(self) -> dict:
|
|
89
|
+
return {"type": "function", "function": {
|
|
90
|
+
"name": self.name, "description": self.description,
|
|
91
|
+
"parameters": self.input_schema()}}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def surface_allows(spec: ToolSpec, surface: str, scope: str = OWNER) -> bool:
|
|
95
|
+
"""May `spec` be offered/run on `surface` for a chat of `scope`?"""
|
|
96
|
+
if spec.tier == "never": # refused everywhere: its effect cannot be shown up front
|
|
97
|
+
return False
|
|
98
|
+
if not allowed_on_surface(spec.name, surface):
|
|
99
|
+
return False
|
|
100
|
+
if spec.tier == "never_from_chat" and surface != "tui":
|
|
101
|
+
return False
|
|
102
|
+
if normalize_scope(scope) != OWNER and spec.scope != PUBLIC:
|
|
103
|
+
return False
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ToolRegistry:
|
|
108
|
+
def __init__(self, specs: Iterable[ToolSpec] = ()):
|
|
109
|
+
self._specs: Dict[str, ToolSpec] = {}
|
|
110
|
+
for s in specs:
|
|
111
|
+
self.register(s)
|
|
112
|
+
|
|
113
|
+
def register(self, spec: ToolSpec) -> None:
|
|
114
|
+
if spec.name in self._specs:
|
|
115
|
+
raise ValueError(f"duplicate tool {spec.name}")
|
|
116
|
+
self._specs[spec.name] = spec
|
|
117
|
+
|
|
118
|
+
def get(self, name: str) -> Optional[ToolSpec]:
|
|
119
|
+
return self._specs.get(name) if isinstance(name, str) else None
|
|
120
|
+
|
|
121
|
+
def names(self) -> List[str]:
|
|
122
|
+
return list(self._specs)
|
|
123
|
+
|
|
124
|
+
def __len__(self) -> int:
|
|
125
|
+
return len(self._specs)
|
|
126
|
+
|
|
127
|
+
def __contains__(self, name) -> bool:
|
|
128
|
+
return name in self._specs
|
|
129
|
+
|
|
130
|
+
def specs(self, surface: str = "tui", scope: str = OWNER) -> List[ToolSpec]:
|
|
131
|
+
return [s for s in self._specs.values() if surface_allows(s, surface, scope)]
|
|
132
|
+
|
|
133
|
+
def anthropic_tools(self, surface: str = "tui", scope: str = OWNER) -> List[dict]:
|
|
134
|
+
return [s.anthropic() for s in self.specs(surface, scope)]
|
|
135
|
+
|
|
136
|
+
def openai_tools(self, surface: str = "tui", scope: str = OWNER) -> List[dict]:
|
|
137
|
+
return [s.openai() for s in self.specs(surface, scope)]
|
|
138
|
+
|
|
139
|
+
def dispatch(self, name: str, raw_args: Any, *, surface: str = "tui",
|
|
140
|
+
scope: str = OWNER,
|
|
141
|
+
run: Optional[Callable[[ToolSpec, dict], Any]] = None,
|
|
142
|
+
guard: Any = None, approved_hash: Optional[str] = None) -> ToolResult:
|
|
143
|
+
"""Look `name` up and run it. Never raises; always an envelope.
|
|
144
|
+
|
|
145
|
+
raw_args: a dict, or the raw JSON string from an OpenAI-style call
|
|
146
|
+
(malformed JSON -> invalid_args, never {}).
|
|
147
|
+
run(spec, args): optional executor hook (e.g. a timeout wrapper);
|
|
148
|
+
raise ToolTimeout from it on a timeout.
|
|
149
|
+
guard: the turn's tools.guard.TurnGuard (wave 2: effect ledger,
|
|
150
|
+
retry budgets, turn cap). Every tool loop passes one; None only in
|
|
151
|
+
direct calls (tests, internal use).
|
|
152
|
+
approved_hash: set only by tools.approval.send() when the owner pressed
|
|
153
|
+
Send; the call runs only if its payload hash is exactly that one.
|
|
154
|
+
Never reachable from a model's tool call.
|
|
155
|
+
|
|
156
|
+
Taint guard (hyperclaw.taint): the call runs as part of the guard's
|
|
157
|
+
conversation, so connector reads are recorded in its ledger; a
|
|
158
|
+
memory/learning tool whose arguments carry connector content (or, for
|
|
159
|
+
learning, whose conversation is tainted) is refused before any
|
|
160
|
+
approval card, with the reason.
|
|
161
|
+
|
|
162
|
+
Wave 3 (approval layer): after validation, tools.approval.evaluate()
|
|
163
|
+
decides whether the call runs now, is queued for the owner (Send /
|
|
164
|
+
Edit / Cancel; returns needs_approval and runs nothing), or is
|
|
165
|
+
refused. Every tool that is not read-only is gated, on every surface
|
|
166
|
+
including the local terminal (bash, code, desktop control too). Model
|
|
167
|
+
tool loops always pass a guard; a call without one has no approval
|
|
168
|
+
channel, so a gated call is refused (nothing runs), on every surface.
|
|
169
|
+
"""
|
|
170
|
+
from hyperclaw import taint
|
|
171
|
+
token = _ACTIVE_GUARD.set(guard)
|
|
172
|
+
try:
|
|
173
|
+
with taint.conversation(getattr(guard, "conversation", None)), \
|
|
174
|
+
taint.message(getattr(guard, "turn_id", None)):
|
|
175
|
+
return self._dispatch(name, raw_args, surface=surface, scope=scope, run=run,
|
|
176
|
+
guard=guard, approved_hash=approved_hash)
|
|
177
|
+
finally:
|
|
178
|
+
_ACTIVE_GUARD.reset(token)
|
|
179
|
+
|
|
180
|
+
def _dispatch(self, name: str, raw_args: Any, *, surface: str, scope: str,
|
|
181
|
+
run: Optional[Callable[[ToolSpec, dict], Any]], guard: Any,
|
|
182
|
+
approved_hash: Optional[str]) -> ToolResult:
|
|
183
|
+
spec = self.get(name)
|
|
184
|
+
if spec is None:
|
|
185
|
+
return make(Status.NOT_FOUND, f"Tool '{name}' does not exist.",
|
|
186
|
+
reason=REASON_UNKNOWN_TOOL)
|
|
187
|
+
if not surface_allows(spec, surface, scope):
|
|
188
|
+
return make(Status.PERMISSION_DENIED,
|
|
189
|
+
f"Tool '{name}' is not available in this conversation; it was NOT run.",
|
|
190
|
+
reason=REASON_SURFACE)
|
|
191
|
+
if guard is not None:
|
|
192
|
+
refused = guard.precheck(spec)
|
|
193
|
+
if refused is not None:
|
|
194
|
+
return refused
|
|
195
|
+
try:
|
|
196
|
+
args = targs.validate(spec.args_model, raw_args)
|
|
197
|
+
except targs.ArgsError as e:
|
|
198
|
+
if guard is not None:
|
|
199
|
+
guard.on_invalid(spec)
|
|
200
|
+
return make(Status.INVALID_ARGS, f"{name}: {e}")
|
|
201
|
+
from hyperclaw import taint
|
|
202
|
+
tainted = taint.check_tool_call(name, args, getattr(guard, "conversation", None))
|
|
203
|
+
if tainted is not None:
|
|
204
|
+
return make(Status.PERMISSION_DENIED, f"{name} was NOT run. {tainted.user_message}")
|
|
205
|
+
budgets = getattr(guard, "check_budgets", None) if approved_hash is None else None
|
|
206
|
+
if budgets is not None:
|
|
207
|
+
try:
|
|
208
|
+
refused = budgets(spec, args)
|
|
209
|
+
except Exception as e: # fail closed
|
|
210
|
+
log.warning("guard budget check failed for %s", name, exc_info=True)
|
|
211
|
+
return make(Status.FAILED, f"{name} was NOT run: runtime guard error "
|
|
212
|
+
f"({type(e).__name__}).")
|
|
213
|
+
if refused is not None:
|
|
214
|
+
return refused
|
|
215
|
+
gated = self._approval_gate(spec, args, raw_args, surface, scope, guard, approved_hash)
|
|
216
|
+
if gated is not None:
|
|
217
|
+
return gated
|
|
218
|
+
ctx = None
|
|
219
|
+
if guard is not None:
|
|
220
|
+
try:
|
|
221
|
+
refused, ctx = guard.before(spec, args)
|
|
222
|
+
except Exception as e: # fail closed: a guard bug never runs the tool unchecked
|
|
223
|
+
log.warning("guard failed for %s", name, exc_info=True)
|
|
224
|
+
return make(Status.FAILED, f"{name} was NOT run: runtime guard error "
|
|
225
|
+
f"({type(e).__name__}).")
|
|
226
|
+
if refused is not None:
|
|
227
|
+
return refused
|
|
228
|
+
env = self._execute(spec, args, run)
|
|
229
|
+
if guard is not None:
|
|
230
|
+
try:
|
|
231
|
+
env = guard.after(spec, args, env, ctx)
|
|
232
|
+
except Exception:
|
|
233
|
+
log.warning("guard.after failed for %s", name, exc_info=True)
|
|
234
|
+
return env
|
|
235
|
+
|
|
236
|
+
@staticmethod
|
|
237
|
+
def _approval_gate(spec: ToolSpec, args: dict, raw_args: Any, surface: str, scope: str,
|
|
238
|
+
guard: Any, approved_hash: Optional[str]) -> Optional[ToolResult]:
|
|
239
|
+
from . import approval
|
|
240
|
+
name = spec.name
|
|
241
|
+
if approved_hash is not None:
|
|
242
|
+
if not isinstance(raw_args, dict) or \
|
|
243
|
+
approval.payload_hash(name, raw_args) != approved_hash or \
|
|
244
|
+
approval.payload_hash(name, args) != approved_hash:
|
|
245
|
+
return make(Status.PERMISSION_DENIED,
|
|
246
|
+
f"{name} was NOT run: its arguments differ from the approved payload.")
|
|
247
|
+
return None
|
|
248
|
+
try:
|
|
249
|
+
gate = approval.evaluate(spec, args, surface=surface, guard=guard)
|
|
250
|
+
except Exception as e: # fail closed
|
|
251
|
+
log.warning("approval gate failed for %s", name, exc_info=True)
|
|
252
|
+
return make(Status.FAILED, f"{name} was NOT run: approval check error "
|
|
253
|
+
f"({type(e).__name__}).")
|
|
254
|
+
if gate is None:
|
|
255
|
+
return None
|
|
256
|
+
if gate.action == "deny":
|
|
257
|
+
env = make(Status.PERMISSION_DENIED, f"{name} was NOT run: {gate.reason}.")
|
|
258
|
+
_log_gate(guard, spec, args, env)
|
|
259
|
+
return env
|
|
260
|
+
if guard is None: # no approval channel: refused on every surface, terminal included
|
|
261
|
+
return make(Status.PERMISSION_DENIED,
|
|
262
|
+
f"{name} was NOT run: it needs the owner's approval ({gate.reason}) and "
|
|
263
|
+
f"this call has no approval channel.")
|
|
264
|
+
try:
|
|
265
|
+
item = approval.get_queue().propose(
|
|
266
|
+
tool=name, args=args, conversation=guard.conversation, surface=surface,
|
|
267
|
+
scope=normalize_scope(scope), requester=getattr(guard, "requester", "owner"),
|
|
268
|
+
kind=gate.kind, reason=gate.reason,
|
|
269
|
+
untrusted=bool(getattr(guard, "untrusted_seen", False)))
|
|
270
|
+
except Exception as e:
|
|
271
|
+
log.warning("approval queue unavailable for %s", name, exc_info=True)
|
|
272
|
+
return make(Status.FAILED, f"{name} was NOT run: the approval queue is unavailable "
|
|
273
|
+
f"({type(e).__name__}).")
|
|
274
|
+
env = approval.needs_approval_envelope(item)
|
|
275
|
+
guard.queued.append(item.id)
|
|
276
|
+
_log_gate(guard, spec, args, env)
|
|
277
|
+
return env
|
|
278
|
+
|
|
279
|
+
@staticmethod
|
|
280
|
+
def _execute(spec: ToolSpec, args: dict, run) -> ToolResult:
|
|
281
|
+
name = spec.name
|
|
282
|
+
try:
|
|
283
|
+
raw = run(spec, args) if run else spec.handler(name, args)
|
|
284
|
+
except ToolTimeout as e:
|
|
285
|
+
if spec.effect != "read":
|
|
286
|
+
return make(Status.UNCERTAIN,
|
|
287
|
+
f"{name} timed out ({e}); it may still have completed.")
|
|
288
|
+
return make(Status.FAILED, f"{name} timed out ({e}).", retryable=True)
|
|
289
|
+
except Exception as e: # a tool bug: report it, never retry the model call
|
|
290
|
+
from hyperclaw.taint import TaintedInput
|
|
291
|
+
if isinstance(e, TaintedInput): # a sink refused connector content: say why
|
|
292
|
+
return make(Status.PERMISSION_DENIED, f"{name} was NOT run. {e.user_message}")
|
|
293
|
+
log.warning("tool %s raised %s", name, type(e).__name__, exc_info=True)
|
|
294
|
+
return make(Status.FAILED, f"Error: tool {name} failed: {type(e).__name__}: {e}")
|
|
295
|
+
env = classify(spec, raw, args)
|
|
296
|
+
# Restricted-client guard, connector-result boundary: under an outside
|
|
297
|
+
# model, what involves a restricted client is withheld before the
|
|
298
|
+
# result can enter any model request (hyperclaw.client_guard).
|
|
299
|
+
from hyperclaw import client_guard
|
|
300
|
+
try:
|
|
301
|
+
return client_guard.filter_tool_result(name, env)
|
|
302
|
+
except Exception as e: # fail closed: nothing unchecked reaches the model
|
|
303
|
+
log.warning("client guard failed for %s", name, exc_info=True)
|
|
304
|
+
if name in client_guard.CONNECTOR_TOOL_SOURCES:
|
|
305
|
+
return make(Status.FAILED, f"{name}: the restricted-client check could not run "
|
|
306
|
+
f"({type(e).__name__}), so nothing it read is shown.")
|
|
307
|
+
return env
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _log_gate(guard, spec: ToolSpec, args: dict, env: ToolResult) -> None:
|
|
311
|
+
"""Queued or refused calls go in the activity log too."""
|
|
312
|
+
if guard is None:
|
|
313
|
+
return
|
|
314
|
+
try:
|
|
315
|
+
from . import effects as fx
|
|
316
|
+
guard._log_refusal(spec, fx.call_effect(spec, args), args, env)
|
|
317
|
+
except Exception:
|
|
318
|
+
log.warning("could not log the approval decision for %s", spec.name, exc_info=True)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Service-control guard (benchmark 17.4 / 17.5).
|
|
3
|
+
|
|
4
|
+
Recovery and restart actions may only touch HyperClaw's own services, and
|
|
5
|
+
only with the owner's approval:
|
|
6
|
+
|
|
7
|
+
- A command that stops, starts, restarts, loads, unloads or kills a service or
|
|
8
|
+
process is a *service action*.
|
|
9
|
+
- Every label it names must match the allowlist (config-driven,
|
|
10
|
+
HYPERCLAW_SERVICE_ALLOWLIST, comma-separated globs; default
|
|
11
|
+
``com.hyperclaw.*``). Anything else — another app's messaging service, a
|
|
12
|
+
bare pid, ``pkill``/``killall`` patterns — is refused outright.
|
|
13
|
+
- An allowlisted action still goes to the approval queue (never auto).
|
|
14
|
+
- Text the model read from email, the web or a document is data: a service
|
|
15
|
+
action asked for after such a read is refused unless the user named that
|
|
16
|
+
service in their own words (tools/approval.py applies this rule).
|
|
17
|
+
|
|
18
|
+
This is a guard for the model's tool calls (bash, python_exec, applescript),
|
|
19
|
+
not a sandbox.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import fnmatch
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import shlex
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import List, Optional
|
|
29
|
+
|
|
30
|
+
DEFAULT_ALLOWLIST = ("com.hyperclaw.*",)
|
|
31
|
+
|
|
32
|
+
# Tools whose text argument can carry a service command.
|
|
33
|
+
COMMAND_ARGS = {"bash": "command", "python_exec": "code", "applescript": "script"}
|
|
34
|
+
|
|
35
|
+
_LAUNCHCTL_VERBS = {"kickstart", "bootout", "bootstrap", "load", "unload", "stop", "start",
|
|
36
|
+
"kill", "enable", "disable", "remove", "submit", "restart"}
|
|
37
|
+
_SYSTEMCTL_VERBS = {"restart", "stop", "start", "kill", "reload", "disable", "enable", "mask",
|
|
38
|
+
"unmask", "try-restart", "reload-or-restart"}
|
|
39
|
+
_KILLERS = {"kill", "pkill", "killall", "skill", "xkill"}
|
|
40
|
+
_SPLIT = re.compile(r"\|\||&&|[;|&\n]|\$\(|`")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def allowlist() -> tuple:
|
|
44
|
+
raw = os.environ.get("HYPERCLAW_SERVICE_ALLOWLIST", "")
|
|
45
|
+
items = tuple(x.strip() for x in raw.split(",") if x.strip())
|
|
46
|
+
return items or DEFAULT_ALLOWLIST
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def label_allowed(label: str) -> bool:
|
|
50
|
+
return any(fnmatch.fnmatchcase(label, pat) for pat in allowlist())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class ServiceAction:
|
|
55
|
+
verdict: str # "deny" | "approve"
|
|
56
|
+
labels: List[str] = field(default_factory=list)
|
|
57
|
+
reason: str = ""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _label_from_target(tok: str) -> str:
|
|
61
|
+
"""gui/501/com.x.y -> com.x.y; ~/Library/LaunchAgents/com.x.y.plist -> com.x.y."""
|
|
62
|
+
t = tok.rstrip("/")
|
|
63
|
+
t = t.rsplit("/", 1)[-1]
|
|
64
|
+
if t.endswith(".plist"):
|
|
65
|
+
t = t[:-6]
|
|
66
|
+
if t.endswith(".service"):
|
|
67
|
+
t = t[:-8]
|
|
68
|
+
return t
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _segment_action(words: List[str]) -> Optional[ServiceAction]:
|
|
72
|
+
# drop leading sudo / env assignments
|
|
73
|
+
while words and (words[0] in ("sudo", "doas", "exec", "command", "nohup")
|
|
74
|
+
or re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*=.*", words[0])):
|
|
75
|
+
words = words[1:]
|
|
76
|
+
if not words:
|
|
77
|
+
return None
|
|
78
|
+
prog = words[0].rsplit("/", 1)[-1]
|
|
79
|
+
if prog in _KILLERS:
|
|
80
|
+
return ServiceAction("deny", [], f"'{prog}' targets processes by pid or name, which "
|
|
81
|
+
f"cannot be limited to HyperClaw's own services")
|
|
82
|
+
if prog == "launchctl" and len(words) > 1 and words[1] in _LAUNCHCTL_VERBS:
|
|
83
|
+
targets = [w for w in words[2:] if not w.startswith("-") and not w.isdigit()]
|
|
84
|
+
labels = [_label_from_target(t) for t in targets if t not in ("system", "gui", "user")]
|
|
85
|
+
labels = [x for x in labels if x and not re.fullmatch(r"(gui|user|system)", x)]
|
|
86
|
+
return _decide(labels, f"launchctl {words[1]}")
|
|
87
|
+
if prog == "systemctl" and len(words) > 1:
|
|
88
|
+
rest = [w for w in words[1:] if not w.startswith("-")]
|
|
89
|
+
if rest and rest[0] in _SYSTEMCTL_VERBS:
|
|
90
|
+
return _decide([_label_from_target(w) for w in rest[1:]], f"systemctl {rest[0]}")
|
|
91
|
+
if prog == "service" and len(words) > 2:
|
|
92
|
+
return _decide([words[1]], f"service {words[2]}")
|
|
93
|
+
if prog == "brew" and len(words) > 2 and words[1] == "services" and words[2] in (
|
|
94
|
+
"start", "stop", "restart", "kill", "run", "cleanup"):
|
|
95
|
+
return _decide(words[3:4] or ["(all)"], f"brew services {words[2]}")
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _decide(labels: List[str], what: str) -> ServiceAction:
|
|
100
|
+
if not labels:
|
|
101
|
+
return ServiceAction("deny", [], f"{what} names no service label")
|
|
102
|
+
bad = [x for x in labels if not label_allowed(x)]
|
|
103
|
+
if bad:
|
|
104
|
+
return ServiceAction("deny", labels, f"{what} on {', '.join(bad)}: only HyperClaw's own "
|
|
105
|
+
f"services ({', '.join(allowlist())}) may be touched")
|
|
106
|
+
return ServiceAction("approve", labels, f"{what} on {', '.join(labels)}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def check_command(text: str, shell: bool = True) -> Optional[ServiceAction]:
|
|
110
|
+
"""None when `text` contains no service action; else the verdict (any
|
|
111
|
+
denied segment denies the whole command)."""
|
|
112
|
+
if not isinstance(text, str) or not text.strip():
|
|
113
|
+
return None
|
|
114
|
+
found: List[ServiceAction] = []
|
|
115
|
+
for seg in _SPLIT.split(text):
|
|
116
|
+
seg = seg.strip()
|
|
117
|
+
if not seg:
|
|
118
|
+
continue
|
|
119
|
+
try:
|
|
120
|
+
words = shlex.split(seg, comments=True)
|
|
121
|
+
except ValueError:
|
|
122
|
+
words = seg.split()
|
|
123
|
+
act = _segment_action(words)
|
|
124
|
+
if act:
|
|
125
|
+
found.append(act)
|
|
126
|
+
# Code in other languages (python_exec subprocess lists, AppleScript
|
|
127
|
+
# "do shell script"): look for the programs as plain words too.
|
|
128
|
+
words_re = r"\b(launchctl|systemctl)\b" if shell else r"\b(launchctl|systemctl|pkill|killall|kill)\b"
|
|
129
|
+
if not found and re.search(words_re, text):
|
|
130
|
+
m = re.search(r"\b(launchctl|systemctl)\b[\s'\",\[]+(\w[\w-]*)", text)
|
|
131
|
+
labels = re.findall(r"(?:gui|user|system)/\d*/?([\w.\-]+)|([\w\-]+(?:\.[\w\-]+){2,})", text)
|
|
132
|
+
names = [a or b for a, b in labels]
|
|
133
|
+
if m and m.group(2) in (_LAUNCHCTL_VERBS | _SYSTEMCTL_VERBS) and names:
|
|
134
|
+
found.append(_decide(names, f"{m.group(1)} {m.group(2)}"))
|
|
135
|
+
else:
|
|
136
|
+
found.append(ServiceAction("deny", [], "process/service control from code cannot be "
|
|
137
|
+
"limited to HyperClaw's own services"))
|
|
138
|
+
if not found:
|
|
139
|
+
return None
|
|
140
|
+
denied = [a for a in found if a.verdict == "deny"]
|
|
141
|
+
if denied:
|
|
142
|
+
return denied[0]
|
|
143
|
+
labels = [x for a in found for x in a.labels]
|
|
144
|
+
return ServiceAction("approve", labels, "; ".join(a.reason for a in found))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def check_tool(name: str, args: dict) -> Optional[ServiceAction]:
|
|
148
|
+
field_name = COMMAND_ARGS.get(name)
|
|
149
|
+
if not field_name or not isinstance(args, dict):
|
|
150
|
+
return None
|
|
151
|
+
return check_command(args.get(field_name, ""), shell=(name == "bash"))
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Which tools a surface may offer.
|
|
3
|
+
|
|
4
|
+
Spec cross-cutting rule 4 / §6: "shell and code execution from remote
|
|
5
|
+
channels, and desktop/app control" are tier never_from_chat. They are
|
|
6
|
+
REMOVED from every chat/remote surface (absent from the tool list and
|
|
7
|
+
refused if called anyway), not gated behind a confirmation.
|
|
8
|
+
|
|
9
|
+
Only the local terminal (surface "tui") keeps them, and there every call
|
|
10
|
+
that is not read-only goes through the approval layer (tools/approval.py):
|
|
11
|
+
the owner sees the exact command line, script, keystrokes, click, app, file
|
|
12
|
+
or address and answers Send / Edit / Cancel before anything runs.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
# Shell / code execution and desktop or device control.
|
|
17
|
+
NEVER_FROM_CHAT = frozenset({
|
|
18
|
+
"bash", # shell
|
|
19
|
+
"python_exec", # bare exec() of model-written code
|
|
20
|
+
"applescript", # arbitrary macOS automation
|
|
21
|
+
"type_text", # keyboard
|
|
22
|
+
"key_press", # keyboard
|
|
23
|
+
"mouse_click", # mouse
|
|
24
|
+
"open_app", # app control
|
|
25
|
+
"open_file", # opens files/apps on the owner's desktop
|
|
26
|
+
"open_url", # drives the owner's browser
|
|
27
|
+
"screenshot", # captures the owner's screen
|
|
28
|
+
"record_audio", # records the owner's microphone
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
# Tools only the lead (the person's own agent) has: never offered to or run by
|
|
32
|
+
# a specialist (surface "swarm"). Specialists can't start specialists.
|
|
33
|
+
LEAD_ONLY = frozenset({"team_delegate", "agent_start", "agent_stop", "agent_watch"})
|
|
34
|
+
SPECIALIST_SURFACE = "swarm"
|
|
35
|
+
|
|
36
|
+
LOCAL_SURFACES = frozenset({"tui"})
|
|
37
|
+
# Known remote surfaces. Any surface name not in LOCAL_SURFACES is treated as
|
|
38
|
+
# remote, including unknown ones (fail closed).
|
|
39
|
+
REMOTE_SURFACES = frozenset({"telegram", "imessage", "bridge", "server", "scheduler", "swarm"})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_local(surface: str) -> bool:
|
|
43
|
+
return surface in LOCAL_SURFACES
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def allowed_on_surface(name: str, surface: str) -> bool:
|
|
47
|
+
if surface == SPECIALIST_SURFACE and name in LEAD_ONLY:
|
|
48
|
+
return False
|
|
49
|
+
return is_local(surface) or name not in NEVER_FROM_CHAT
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def tools_for_surface(tools: list, surface: str) -> list:
|
|
53
|
+
"""Filter an Anthropic-format tool list for a surface."""
|
|
54
|
+
return [t for t in tools if allowed_on_surface(t.get("name", ""), surface)]
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Writes only to the workspace (benchmark 12.5, rc3).
|
|
3
|
+
|
|
4
|
+
Every tool that writes a file (write_file, edit_file, download, qr_code,
|
|
5
|
+
image_generate, sql_query) resolves the real target path first, following
|
|
6
|
+
symlinks and '..', and refuses anything outside the workspace,
|
|
7
|
+
``$HYPERCLAW_ROOT/workspace`` (default ``~/.hyperclaw/workspace``). A
|
|
8
|
+
relative path is taken relative to the workspace, not the process cwd.
|
|
9
|
+
|
|
10
|
+
Refusals start with ``Permission denied:`` so the registry returns a
|
|
11
|
+
``permission_denied`` envelope and the model is told not to work around it.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
# tool name -> the argument(s) naming a file the tool writes
|
|
20
|
+
WRITE_PATH_ARGS = {
|
|
21
|
+
"write_file": ("path",),
|
|
22
|
+
"edit_file": ("path",),
|
|
23
|
+
"download": ("path",),
|
|
24
|
+
"qr_code": ("save_path",),
|
|
25
|
+
"image_generate": ("save_path",),
|
|
26
|
+
"sql_query": ("database",),
|
|
27
|
+
"document_create": ("path",),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
DENIED_PREFIX = "Permission denied:"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def workspace_root() -> Path:
|
|
34
|
+
"""The real (symlink-resolved) workspace directory for this install."""
|
|
35
|
+
from hyperclaw.userconfig import root
|
|
36
|
+
ws = root() / "workspace"
|
|
37
|
+
return Path(os.path.realpath(ws))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def resolve(path, base: Optional[Path] = None) -> Path:
|
|
41
|
+
"""The real path a write to `path` would land on: '~' expanded,
|
|
42
|
+
relative to the workspace, every symlink and '..' resolved (including a
|
|
43
|
+
symlinked final component)."""
|
|
44
|
+
base = base or workspace_root()
|
|
45
|
+
p = Path(os.path.expanduser(str(path)))
|
|
46
|
+
if not p.is_absolute():
|
|
47
|
+
p = base / p
|
|
48
|
+
return Path(os.path.realpath(p))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def inside_workspace(path, base: Optional[Path] = None) -> bool:
|
|
52
|
+
base = base or workspace_root()
|
|
53
|
+
real = resolve(path, base)
|
|
54
|
+
try:
|
|
55
|
+
real.relative_to(base)
|
|
56
|
+
return True
|
|
57
|
+
except ValueError:
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def check_write(tool: str, args: dict) -> Optional[str]:
|
|
62
|
+
"""None if every path `tool` would write is inside the workspace;
|
|
63
|
+
otherwise the refusal text (nothing is written)."""
|
|
64
|
+
names = WRITE_PATH_ARGS.get(tool)
|
|
65
|
+
if not names or not isinstance(args, dict):
|
|
66
|
+
return None
|
|
67
|
+
base = workspace_root()
|
|
68
|
+
for key in names:
|
|
69
|
+
val = args.get(key)
|
|
70
|
+
if val in (None, ""):
|
|
71
|
+
continue
|
|
72
|
+
try:
|
|
73
|
+
ok = inside_workspace(val, base)
|
|
74
|
+
except (OSError, ValueError, RuntimeError):
|
|
75
|
+
ok = False
|
|
76
|
+
if not ok:
|
|
77
|
+
return (f"{DENIED_PREFIX} {tool} may only write inside the HyperClaw workspace "
|
|
78
|
+
f"({base}); {val!r} resolves outside it. Nothing was written.")
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def workspace_path(path) -> Path:
|
|
83
|
+
"""Resolve `path` for writing, or raise PermissionError if outside."""
|
|
84
|
+
base = workspace_root()
|
|
85
|
+
if not inside_workspace(path, base):
|
|
86
|
+
raise PermissionError(f"{path!r} is outside the HyperClaw workspace ({base})")
|
|
87
|
+
return resolve(path, base)
|