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,717 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Restricted-client guard: content about a restricted client never reaches an
|
|
3
|
+
outside model.
|
|
4
|
+
|
|
5
|
+
Rule (Vaughn, 12 Oct 2026; CLIENT-AI-RESTRICTIONS 11 Oct): when the ACTIVE
|
|
6
|
+
model is from an outside provider (anything that is not Hyper's own model,
|
|
7
|
+
HyperSpeed V2), nothing involving a restricted client reaches it from Gmail,
|
|
8
|
+
Slack, Calendar, Meet transcripts, Drive, Docs, Sheets, Slides or GitLab,
|
|
9
|
+
whether the lead model or a specialist asked for it.
|
|
10
|
+
|
|
11
|
+
Two layers:
|
|
12
|
+
|
|
13
|
+
1. **Connector-result boundary** (``screen`` / ``filter_tool_result`` /
|
|
14
|
+
``filter_items``): before a connector read enters any model request, every
|
|
15
|
+
item (one email thread, one event, one file, one transcript, one Slack or
|
|
16
|
+
GitLab item) that names a restricted client (name, alias, domain or
|
|
17
|
+
project name, also inside forwards and threads that never show the
|
|
18
|
+
client's domain) is withheld. Anything that could not be checked (binary,
|
|
19
|
+
unreadable, an unknown shape) is withheld too: when unsure, withhold.
|
|
20
|
+
2. **Final check on the outgoing request body** (``redact_request`` /
|
|
21
|
+
``final_check``), run by the cost guard's adapters, which every model call
|
|
22
|
+
in the package already goes through (tests/unit/test_cost_guard.py). An
|
|
23
|
+
outside request that still carries restricted content has those strings
|
|
24
|
+
replaced by a plain note; whatever cannot be replaced stops the request
|
|
25
|
+
(``RestrictedContentBlocked``) and nothing is sent.
|
|
26
|
+
|
|
27
|
+
Every withholding is recorded on the current task, and the answer ends with
|
|
28
|
+
one plain line saying what was left out and that switching to HyperSpeed V2
|
|
29
|
+
includes it (``reply_note``). The note never names the client, so the note
|
|
30
|
+
itself can never trip the guard.
|
|
31
|
+
|
|
32
|
+
Configuration. No client is named in the package: it ships with an EMPTY
|
|
33
|
+
default list. The list comes from the install profile:
|
|
34
|
+
|
|
35
|
+
- the managed config (``hyperclaw/managed_config.py``), key
|
|
36
|
+
``restricted_clients``: Hyper's list for the install (team and client
|
|
37
|
+
installs get it in their team profile);
|
|
38
|
+
- ``restricted_clients_required: true`` in the same file: the profile MUST
|
|
39
|
+
carry a list. If it is missing, empty or unreadable while an outside
|
|
40
|
+
model is active, ALL connector content is withheld, and the user is told
|
|
41
|
+
why (fail safe);
|
|
42
|
+
- ``$HYPERCLAW_ROOT/config/restricted_clients.json`` (or
|
|
43
|
+
``HYPERCLAW_RESTRICTED_CLIENTS``): the owner may ADD entries, never
|
|
44
|
+
remove one.
|
|
45
|
+
|
|
46
|
+
With no list and nothing required, there is nothing to withhold.
|
|
47
|
+
|
|
48
|
+
Entry shape: ``{"name": "...", "aliases": [...], "domains": [...],
|
|
49
|
+
"projects": [...]}``.
|
|
50
|
+
"""
|
|
51
|
+
from __future__ import annotations
|
|
52
|
+
|
|
53
|
+
import contextlib
|
|
54
|
+
import contextvars
|
|
55
|
+
import html
|
|
56
|
+
import json
|
|
57
|
+
import logging
|
|
58
|
+
import os
|
|
59
|
+
import re
|
|
60
|
+
import threading
|
|
61
|
+
import unicodedata
|
|
62
|
+
from dataclasses import dataclass, field
|
|
63
|
+
from pathlib import Path
|
|
64
|
+
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
|
65
|
+
|
|
66
|
+
log = logging.getLogger("hyperclaw.client_guard")
|
|
67
|
+
|
|
68
|
+
# Shipped empty on purpose: no client is named in shipped code. The real
|
|
69
|
+
# list arrives in each install's profile (managed config).
|
|
70
|
+
DEFAULT_CLIENTS: Tuple[dict, ...] = ()
|
|
71
|
+
|
|
72
|
+
# Said when the profile requires a list but has none (no client named).
|
|
73
|
+
PROFILE_MISSING_NOTE = ("Withheld: the restricted-client list is missing from your install "
|
|
74
|
+
"profile, so nothing from your connected accounts is shown to an "
|
|
75
|
+
"outside model. Ask Vaughn to fix your install profile, or switch to "
|
|
76
|
+
"HyperSpeed V2 (/model).")
|
|
77
|
+
|
|
78
|
+
USER_FILE = "restricted_clients.json"
|
|
79
|
+
ENV_FILE = "HYPERCLAW_RESTRICTED_CLIENTS"
|
|
80
|
+
|
|
81
|
+
# What the model and the user see in place of withheld content. It never
|
|
82
|
+
# names the client (so it can never trip the guard itself).
|
|
83
|
+
PLACEHOLDER = ("[withheld: this is about a client whose agreement keeps their data on Hyper's "
|
|
84
|
+
"own models; switch to HyperSpeed V2 to include it]")
|
|
85
|
+
|
|
86
|
+
# The sources this guard covers (taint origins and tool families).
|
|
87
|
+
SOURCES = ("gmail", "slack", "google_calendar", "google_meet", "google_drive", "google_docs",
|
|
88
|
+
"google_sheets", "google_slides", "gitlab")
|
|
89
|
+
SOURCE_LABELS = {
|
|
90
|
+
"gmail": "email", "slack": "Slack", "google_calendar": "calendar",
|
|
91
|
+
"google_meet": "Meet", "google_drive": "Drive", "google_docs": "Docs",
|
|
92
|
+
"google_sheets": "Sheets", "google_slides": "Slides", "gitlab": "GitLab",
|
|
93
|
+
"email": "email", "calendar": "calendar", "request": "earlier messages",
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
# Model tools whose results are connector content from the covered sources.
|
|
97
|
+
# The Google Workspace tools register theirs (hyperclaw/google_workspace).
|
|
98
|
+
CONNECTOR_TOOL_SOURCES: Dict[str, str] = {
|
|
99
|
+
"email_read": "gmail", "email_search": "gmail", "calendar_read": "google_calendar",
|
|
100
|
+
# GitLab reader (team of agents, rc5)
|
|
101
|
+
"gitlab_review_queue": "gitlab",
|
|
102
|
+
# hyperclaw/google_workspace/tools.py
|
|
103
|
+
"gmail_search": "gmail", "gmail_read_thread": "gmail", "gmail_read_attachment": "gmail",
|
|
104
|
+
"calendar_list_events": "google_calendar", "calendar_freebusy": "google_calendar",
|
|
105
|
+
"drive_search": "google_drive", "drive_read": "google_drive", "docs_read": "google_docs",
|
|
106
|
+
"sheets_read": "google_sheets", "slides_read": "google_slides",
|
|
107
|
+
"meet_list_records": "google_meet", "meet_read_transcript": "google_meet",
|
|
108
|
+
}
|
|
109
|
+
# One result is one item (a thread with all its messages, one file, one
|
|
110
|
+
# transcript): withheld whole, never message by message.
|
|
111
|
+
WHOLE_RESULT_TOOLS = frozenset({"gmail_read_thread", "gmail_read_attachment", "drive_read",
|
|
112
|
+
"docs_read", "sheets_read", "slides_read",
|
|
113
|
+
"meet_read_transcript"})
|
|
114
|
+
# Of those, the legacy readers that return headers only (no body): their
|
|
115
|
+
# results can't be checked for a forward or a thread that names the client
|
|
116
|
+
# in the body, so under an outside model they are withheld whole.
|
|
117
|
+
HEADER_ONLY_TOOLS = frozenset({"email_read", "email_search", "calendar_read"})
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ───────────────────────── configuration ─────────────────────────
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class RestrictedClient:
|
|
124
|
+
name: str
|
|
125
|
+
aliases: Tuple[str, ...] = ()
|
|
126
|
+
domains: Tuple[str, ...] = ()
|
|
127
|
+
projects: Tuple[str, ...] = ()
|
|
128
|
+
|
|
129
|
+
def terms(self) -> List[str]:
|
|
130
|
+
return [t for t in (self.name,) + self.aliases + self.projects if t]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _strs(v: Any) -> Tuple[str, ...]:
|
|
134
|
+
if isinstance(v, str):
|
|
135
|
+
v = [v]
|
|
136
|
+
if not isinstance(v, (list, tuple)):
|
|
137
|
+
return ()
|
|
138
|
+
out = []
|
|
139
|
+
for x in v:
|
|
140
|
+
if isinstance(x, str) and x.strip():
|
|
141
|
+
out.append(x.strip())
|
|
142
|
+
return tuple(dict.fromkeys(out))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _entry(d: Any) -> Optional[RestrictedClient]:
|
|
146
|
+
if not isinstance(d, dict):
|
|
147
|
+
return None
|
|
148
|
+
name = d.get("name")
|
|
149
|
+
if not isinstance(name, str) or not name.strip():
|
|
150
|
+
return None
|
|
151
|
+
domains = tuple(x.lower().lstrip("@").strip(".") for x in _strs(d.get("domains")))
|
|
152
|
+
return RestrictedClient(name=name.strip(), aliases=_strs(d.get("aliases")),
|
|
153
|
+
domains=tuple(x for x in domains if x),
|
|
154
|
+
projects=_strs(d.get("projects")))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _root() -> Path:
|
|
158
|
+
return Path(os.environ.get("HYPERCLAW_ROOT", Path.home() / ".hyperclaw")).expanduser()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def user_file() -> Path:
|
|
162
|
+
explicit = os.environ.get(ENV_FILE, "").strip()
|
|
163
|
+
if explicit:
|
|
164
|
+
return Path(explicit).expanduser()
|
|
165
|
+
return _root() / "config" / USER_FILE
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _read_list(p: Path) -> list:
|
|
169
|
+
try:
|
|
170
|
+
if not p.is_file():
|
|
171
|
+
return []
|
|
172
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
173
|
+
except Exception as e: # noqa: BLE001 -- never fatal: the defaults still apply
|
|
174
|
+
log.warning("restricted clients file %s unreadable (%s); defaults apply", p,
|
|
175
|
+
type(e).__name__)
|
|
176
|
+
return []
|
|
177
|
+
if isinstance(data, dict):
|
|
178
|
+
data = data.get("restricted_clients")
|
|
179
|
+
return data if isinstance(data, list) else []
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _managed() -> Tuple[list, bool, bool]:
|
|
183
|
+
"""(profile list, list required, profile readable). A managed file that
|
|
184
|
+
exists but can't be read counts as requiring a list it doesn't have."""
|
|
185
|
+
try:
|
|
186
|
+
from hyperclaw import managed_config
|
|
187
|
+
data, ok = managed_config.raw() # system file, else the verified team profile
|
|
188
|
+
except Exception:
|
|
189
|
+
return [], True, False
|
|
190
|
+
if not ok:
|
|
191
|
+
return [], True, False
|
|
192
|
+
if data is None:
|
|
193
|
+
return [], False, True
|
|
194
|
+
lst = data.get("restricted_clients")
|
|
195
|
+
return ((lst if isinstance(lst, list) else []),
|
|
196
|
+
data.get("restricted_clients_required") is True, True)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def clients() -> List[RestrictedClient]:
|
|
200
|
+
"""The restricted list in force: the install profile's list plus the
|
|
201
|
+
owner's additions (the package itself ships none)."""
|
|
202
|
+
defaults = [c for c in (_entry(d) for d in DEFAULT_CLIENTS) if c]
|
|
203
|
+
managed, _required, _ok = _managed()
|
|
204
|
+
mlist = [c for c in (_entry(d) for d in managed) if c]
|
|
205
|
+
extra = [c for c in (_entry(d) for d in _read_list(user_file())) if c]
|
|
206
|
+
return defaults + mlist + extra
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def placeholder() -> str:
|
|
210
|
+
"""What stands in for withheld content (never names a client)."""
|
|
211
|
+
return PROFILE_MISSING_NOTE if profile_missing() else PLACEHOLDER
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def profile_missing() -> bool:
|
|
215
|
+
"""The install profile requires a restricted-client list (or can't be
|
|
216
|
+
read) and has no valid entry: fail safe, withhold all connector content."""
|
|
217
|
+
managed, required, _ok = _managed()
|
|
218
|
+
if not required:
|
|
219
|
+
return False
|
|
220
|
+
return not any(_entry(d) for d in managed)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def active() -> bool:
|
|
224
|
+
"""Is there anything to protect? A list in force, or a missing required one."""
|
|
225
|
+
return profile_missing() or bool(matcher()._pats)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ───────────────────────── detection ─────────────────────────
|
|
229
|
+
|
|
230
|
+
_ZW = re.compile(r"[-]")
|
|
231
|
+
_SEP = r"[\s\-_.·/]*"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def normalize(text: str) -> str:
|
|
235
|
+
"""NFKC, HTML entities decoded, zero-width characters removed, lower case."""
|
|
236
|
+
t = unicodedata.normalize("NFKC", str(text))
|
|
237
|
+
t = html.unescape(t)
|
|
238
|
+
t = _ZW.sub("", t)
|
|
239
|
+
return t.lower()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _term_pattern(term: str) -> str:
|
|
243
|
+
term = normalize(term).strip()
|
|
244
|
+
if re.fullmatch(r"[a-z]{2,6}", term) and term == term.strip():
|
|
245
|
+
# A short acronym: also "a.b.g." and "a b g".
|
|
246
|
+
body = r"[.\s]?".join(re.escape(ch) for ch in term) + r"\.?"
|
|
247
|
+
else:
|
|
248
|
+
parts = [p for p in re.split(r"[\s\-_.]+", term) if p]
|
|
249
|
+
body = _SEP.join(re.escape(p) for p in parts) if parts else re.escape(term)
|
|
250
|
+
return r"(?<![a-z0-9])" + body + r"(?![a-z0-9])"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _domain_pattern(dom: str) -> str:
|
|
254
|
+
return r"(?<![a-z0-9-])" + re.escape(dom.lower()) + r"(?![a-z0-9-])"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class Matcher:
|
|
258
|
+
def __init__(self, entries: Iterable[RestrictedClient]):
|
|
259
|
+
self.entries = list(entries)
|
|
260
|
+
self._pats: List[Tuple[str, re.Pattern]] = []
|
|
261
|
+
for c in self.entries:
|
|
262
|
+
pats = [_term_pattern(t) for t in c.terms()] + [_domain_pattern(d)
|
|
263
|
+
for d in c.domains]
|
|
264
|
+
if pats:
|
|
265
|
+
self._pats.append((c.name, re.compile("|".join(pats))))
|
|
266
|
+
|
|
267
|
+
def find(self, text: Any) -> List[str]:
|
|
268
|
+
"""Names of the restricted clients `text` involves (any string inside it)."""
|
|
269
|
+
found: List[str] = []
|
|
270
|
+
for s in strings(text):
|
|
271
|
+
n = normalize(s)
|
|
272
|
+
for name, pat in self._pats:
|
|
273
|
+
if name not in found and pat.search(n):
|
|
274
|
+
found.append(name)
|
|
275
|
+
return found
|
|
276
|
+
|
|
277
|
+
def hits(self, value: Any) -> bool:
|
|
278
|
+
return bool(self.find(value))
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
_CACHE: Dict[str, Any] = {}
|
|
282
|
+
_CACHE_LOCK = threading.Lock()
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def matcher() -> Matcher:
|
|
286
|
+
"""The matcher for the list in force (rebuilt when a config file changes)."""
|
|
287
|
+
key_parts = []
|
|
288
|
+
for p in (user_file(),):
|
|
289
|
+
try:
|
|
290
|
+
key_parts.append(f"{p}:{p.stat().st_mtime_ns}")
|
|
291
|
+
except OSError:
|
|
292
|
+
key_parts.append(f"{p}:-")
|
|
293
|
+
try:
|
|
294
|
+
from hyperclaw import managed_config
|
|
295
|
+
key_parts.append(managed_config.source_key())
|
|
296
|
+
except Exception:
|
|
297
|
+
key_parts.append("managed:-")
|
|
298
|
+
key = "|".join(key_parts)
|
|
299
|
+
with _CACHE_LOCK:
|
|
300
|
+
if _CACHE.get("key") != key:
|
|
301
|
+
_CACHE["key"] = key
|
|
302
|
+
_CACHE["m"] = Matcher(clients())
|
|
303
|
+
return _CACHE["m"]
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def reset_cache() -> None:
|
|
307
|
+
with _CACHE_LOCK:
|
|
308
|
+
_CACHE.clear()
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def strings(value: Any, depth: int = 0) -> Iterable[str]:
|
|
312
|
+
if depth > 12 or value is None:
|
|
313
|
+
return
|
|
314
|
+
if isinstance(value, str):
|
|
315
|
+
yield value
|
|
316
|
+
elif isinstance(value, dict):
|
|
317
|
+
for k, v in value.items():
|
|
318
|
+
if isinstance(k, str):
|
|
319
|
+
yield k
|
|
320
|
+
yield from strings(v, depth + 1)
|
|
321
|
+
elif isinstance(value, (list, tuple, set)):
|
|
322
|
+
for v in value:
|
|
323
|
+
yield from strings(v, depth + 1)
|
|
324
|
+
elif hasattr(value, "model_dump"):
|
|
325
|
+
yield from strings(value.model_dump(), depth + 1)
|
|
326
|
+
elif hasattr(value, "__dict__") and not isinstance(value, type):
|
|
327
|
+
yield from strings(vars(value), depth + 1)
|
|
328
|
+
elif isinstance(value, (int, float, bool)):
|
|
329
|
+
return
|
|
330
|
+
else:
|
|
331
|
+
yield str(value)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def checkable(value: Any, depth: int = 0) -> bool:
|
|
335
|
+
"""False for anything this guard can't read as text (bytes, unknown objects)."""
|
|
336
|
+
if depth > 12:
|
|
337
|
+
return False
|
|
338
|
+
if value is None or isinstance(value, (str, int, float, bool)):
|
|
339
|
+
return True
|
|
340
|
+
if isinstance(value, (bytes, bytearray, memoryview)):
|
|
341
|
+
return False
|
|
342
|
+
if isinstance(value, dict):
|
|
343
|
+
return all(checkable(v, depth + 1) for v in value.values())
|
|
344
|
+
if isinstance(value, (list, tuple, set)):
|
|
345
|
+
return all(checkable(v, depth + 1) for v in value)
|
|
346
|
+
return False
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# ───────────────────────── is the active model outside? ─────────────────────────
|
|
350
|
+
|
|
351
|
+
_OVERRIDE: contextvars.ContextVar = contextvars.ContextVar("hyperclaw_client_guard_model",
|
|
352
|
+
default=None)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def is_hyper_provider(provider: Optional[str]) -> bool:
|
|
356
|
+
"""Hyper's own model (HyperSpeed V2, or a provider configured with origin
|
|
357
|
+
hyper). Anything unknown is outside."""
|
|
358
|
+
name = str(provider or "").strip()
|
|
359
|
+
if not name:
|
|
360
|
+
return False
|
|
361
|
+
try:
|
|
362
|
+
from hyperclaw import hyperspeed
|
|
363
|
+
if name == hyperspeed.PROVIDER_NAME:
|
|
364
|
+
return True
|
|
365
|
+
except Exception:
|
|
366
|
+
pass
|
|
367
|
+
try:
|
|
368
|
+
from hyperclaw.providers import registry
|
|
369
|
+
prov = registry().providers.get(name)
|
|
370
|
+
return prov is not None and getattr(prov, "origin", "") == "hyper"
|
|
371
|
+
except Exception:
|
|
372
|
+
return False
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@contextlib.contextmanager
|
|
376
|
+
def model_scope(provider: str):
|
|
377
|
+
"""Run the body as if `provider` were the active model (a caller that
|
|
378
|
+
knows exactly which model its reads go to: the digest, the brief, tests)."""
|
|
379
|
+
tok = _OVERRIDE.set(str(provider or ""))
|
|
380
|
+
try:
|
|
381
|
+
yield
|
|
382
|
+
finally:
|
|
383
|
+
_OVERRIDE.reset(tok)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def note_model(provider: str) -> None:
|
|
387
|
+
"""Called by the cost guard before every model request: the providers a
|
|
388
|
+
task's model calls went to (lead and specialists share the task)."""
|
|
389
|
+
try:
|
|
390
|
+
from hyperclaw.cost_guard import current_task
|
|
391
|
+
t = current_task()
|
|
392
|
+
if t is not None:
|
|
393
|
+
t.model_providers.add(str(provider or "unknown"))
|
|
394
|
+
except Exception:
|
|
395
|
+
pass
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _third_party_live() -> bool:
|
|
399
|
+
"""Could an outside model answer on this install at all? Unsure -> True."""
|
|
400
|
+
try:
|
|
401
|
+
from hyperclaw.providers import registry
|
|
402
|
+
for p in registry().providers.values():
|
|
403
|
+
if getattr(p, "origin", "customer") != "hyper" and getattr(p, "live", False):
|
|
404
|
+
return True
|
|
405
|
+
return False
|
|
406
|
+
except Exception:
|
|
407
|
+
return True
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def outside_model_active() -> bool:
|
|
411
|
+
"""True when content read now could reach an outside model.
|
|
412
|
+
|
|
413
|
+
1. an explicit ``model_scope``;
|
|
414
|
+
2. the current task's model calls so far: any outside provider -> True
|
|
415
|
+
(a specialist on an outside model counts);
|
|
416
|
+
3. nothing known: True unless no outside provider is live on this
|
|
417
|
+
install. When unsure, withhold."""
|
|
418
|
+
o = _OVERRIDE.get()
|
|
419
|
+
if o is not None:
|
|
420
|
+
return not is_hyper_provider(o)
|
|
421
|
+
try:
|
|
422
|
+
from hyperclaw.cost_guard import current_task
|
|
423
|
+
t = current_task()
|
|
424
|
+
provs = set(getattr(t, "model_providers", ()) or ()) if t is not None else set()
|
|
425
|
+
except Exception:
|
|
426
|
+
provs = set()
|
|
427
|
+
if provs:
|
|
428
|
+
return any(not is_hyper_provider(p) for p in provs)
|
|
429
|
+
return _third_party_live()
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
# ───────────────────────── withheld notes ─────────────────────────
|
|
433
|
+
|
|
434
|
+
_FALLBACK_WITHHELD: Dict[str, int] = {}
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _record(source: str, n: int) -> None:
|
|
438
|
+
if n <= 0:
|
|
439
|
+
return
|
|
440
|
+
try:
|
|
441
|
+
from hyperclaw.cost_guard import current_task
|
|
442
|
+
t = current_task()
|
|
443
|
+
except Exception:
|
|
444
|
+
t = None
|
|
445
|
+
if t is None:
|
|
446
|
+
_FALLBACK_WITHHELD[source] = _FALLBACK_WITHHELD.get(source, 0) + n
|
|
447
|
+
return
|
|
448
|
+
t.withheld[source] = t.withheld.get(source, 0) + n
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
record = _record # public name for the connector readers
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def withheld_counts(t: Any = None) -> Dict[str, int]:
|
|
455
|
+
if t is None:
|
|
456
|
+
return dict(_FALLBACK_WITHHELD)
|
|
457
|
+
return dict(getattr(t, "withheld", {}) or {})
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def note_text(counts: Dict[str, int]) -> str:
|
|
461
|
+
"""One plain line: what was left out, and that V2 includes it."""
|
|
462
|
+
total = sum(counts.values())
|
|
463
|
+
if total <= 0:
|
|
464
|
+
return ""
|
|
465
|
+
kinds = sorted({SOURCE_LABELS.get(s, s.replace("_", " ")) for s in counts})
|
|
466
|
+
what = ", ".join(kinds)
|
|
467
|
+
items = "1 item" if total == 1 else f"{total} items"
|
|
468
|
+
return (f"Left out: {items} ({what}) about a client whose agreement keeps their data on "
|
|
469
|
+
f"Hyper's own models, because the model answering now is from an outside "
|
|
470
|
+
f"provider. Switch to HyperSpeed V2 (/model) to include them.")
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def reply_note(t: Any) -> str:
|
|
474
|
+
counts = withheld_counts(t)
|
|
475
|
+
if sum(counts.values()) > 0 and profile_missing():
|
|
476
|
+
return PROFILE_MISSING_NOTE
|
|
477
|
+
return note_text(counts)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def message_note(n: int, source: str) -> str:
|
|
481
|
+
if n <= 0:
|
|
482
|
+
return ""
|
|
483
|
+
if profile_missing():
|
|
484
|
+
return (PROFILE_MISSING_NOTE + " Tell the user exactly this; do not guess what was "
|
|
485
|
+
"left out.")
|
|
486
|
+
label = SOURCE_LABELS.get(source, source)
|
|
487
|
+
items = "1 item" if n == 1 else f"{n} items"
|
|
488
|
+
return (f"Withheld {items} from {label}: about a client whose agreement keeps their data on "
|
|
489
|
+
f"Hyper's own models, and the model answering now is from an outside provider. "
|
|
490
|
+
f"Tell the user plainly that {items} {'was' if n == 1 else 'were'} left out and that "
|
|
491
|
+
f"switching to HyperSpeed V2 includes {'it' if n == 1 else 'them'}. Do not guess what "
|
|
492
|
+
f"{'it says' if n == 1 else 'they say'}.")
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
# ───────────────────────── layer 1: the connector-result boundary ─────────────────────────
|
|
496
|
+
|
|
497
|
+
def involves(value: Any) -> bool:
|
|
498
|
+
"""Withhold this? The profile's required list is missing (fail safe),
|
|
499
|
+
it names a restricted client, or it can't be checked. With no list in
|
|
500
|
+
force (and none required) nothing is withheld."""
|
|
501
|
+
if profile_missing():
|
|
502
|
+
return True
|
|
503
|
+
m = matcher()
|
|
504
|
+
if not m._pats:
|
|
505
|
+
return False
|
|
506
|
+
return (not checkable(value)) or m.hits(value)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def filter_items(items: List[Any], source: str, *, outside: Optional[bool] = None,
|
|
510
|
+
record: bool = True) -> Tuple[List[Any], int]:
|
|
511
|
+
"""Keep the items that don't involve a restricted client. Under Hyper's
|
|
512
|
+
own model everything is kept. Returns (kept, withheld count)."""
|
|
513
|
+
if outside is None:
|
|
514
|
+
outside = outside_model_active()
|
|
515
|
+
if not outside or not active():
|
|
516
|
+
return list(items), 0
|
|
517
|
+
kept, n = [], 0
|
|
518
|
+
for it in items:
|
|
519
|
+
if involves(it):
|
|
520
|
+
n += 1
|
|
521
|
+
else:
|
|
522
|
+
kept.append(it)
|
|
523
|
+
if record:
|
|
524
|
+
_record(source, n)
|
|
525
|
+
return kept, n
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def screen(value: Any, source: str, *, outside: Optional[bool] = None,
|
|
529
|
+
record: bool = True) -> Tuple[Any, int]:
|
|
530
|
+
"""Whole-value screen: (value, 0) or (PLACEHOLDER, 1)."""
|
|
531
|
+
if outside is None:
|
|
532
|
+
outside = outside_model_active()
|
|
533
|
+
if not outside or not involves(value):
|
|
534
|
+
return value, 0
|
|
535
|
+
if record:
|
|
536
|
+
_record(source, 1)
|
|
537
|
+
return placeholder(), 1
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
# Envelope data keys that hold one connector item per list element.
|
|
541
|
+
ITEM_KEYS = ("items", "messages", "threads", "events", "files", "records", "entries",
|
|
542
|
+
"results")
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def filter_tool_result(tool: str, env: Any, *, outside: Optional[bool] = None) -> Any:
|
|
546
|
+
"""The registry boundary for a connector tool's OK envelope: items that
|
|
547
|
+
involve a restricted client are withheld (lists of items one by one,
|
|
548
|
+
anything else whole), and the message says so. Other envelopes pass."""
|
|
549
|
+
source = CONNECTOR_TOOL_SOURCES.get(tool)
|
|
550
|
+
if source is None or getattr(env, "status", None) != "ok":
|
|
551
|
+
return env
|
|
552
|
+
if outside is None:
|
|
553
|
+
outside = outside_model_active()
|
|
554
|
+
if not outside or not active():
|
|
555
|
+
return env
|
|
556
|
+
data = env.data
|
|
557
|
+
withheld = 0
|
|
558
|
+
if tool in HEADER_ONLY_TOOLS:
|
|
559
|
+
new_data, withheld = placeholder(), 1
|
|
560
|
+
_record(source, 1)
|
|
561
|
+
msg = (f"{tool} can't show message or event bodies, so it can't check them for a client "
|
|
562
|
+
f"whose agreement keeps their data on Hyper's own models; nothing it read is "
|
|
563
|
+
f"shown while an outside model is answering. Use the Google Workspace tools "
|
|
564
|
+
f"(they check each thread and event), or switch to HyperSpeed V2.")
|
|
565
|
+
return env.model_copy(update={"data": new_data, "message": msg})
|
|
566
|
+
if tool not in WHOLE_RESULT_TOOLS and isinstance(data, dict) \
|
|
567
|
+
and any(isinstance(data.get(k), list) for k in ITEM_KEYS):
|
|
568
|
+
new_data = dict(data)
|
|
569
|
+
for k in ITEM_KEYS:
|
|
570
|
+
if isinstance(data.get(k), list):
|
|
571
|
+
kept, n = filter_items(data[k], source, outside=True)
|
|
572
|
+
new_data[k] = kept
|
|
573
|
+
withheld += n
|
|
574
|
+
rest = {k: v for k, v in new_data.items() if k not in ITEM_KEYS
|
|
575
|
+
and k not in ("withheld", "note")}
|
|
576
|
+
if involves(rest): # e.g. a thread subject at the top level
|
|
577
|
+
_record(source, 1)
|
|
578
|
+
withheld += 1
|
|
579
|
+
new_data = {"withheld": withheld, "note": placeholder()}
|
|
580
|
+
elif withheld:
|
|
581
|
+
new_data["withheld"] = int(new_data.get("withheld") or 0) + withheld
|
|
582
|
+
else:
|
|
583
|
+
new_data, withheld = screen(data, source, outside=True)
|
|
584
|
+
msg = env.message
|
|
585
|
+
if involves(msg):
|
|
586
|
+
msg = f"{tool} completed."
|
|
587
|
+
if not withheld:
|
|
588
|
+
withheld = 1
|
|
589
|
+
_record(source, 1)
|
|
590
|
+
new_data = placeholder()
|
|
591
|
+
if not withheld:
|
|
592
|
+
return env
|
|
593
|
+
note = message_note(withheld, source)
|
|
594
|
+
return env.model_copy(update={"data": new_data, "message": (msg + " " + note).strip()})
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
# ───────────────────────── layer 2: the outgoing request body ─────────────────────────
|
|
598
|
+
|
|
599
|
+
class RestrictedContentBlocked(Exception):
|
|
600
|
+
"""An outside model request still carried restricted content that could
|
|
601
|
+
not be withheld: the request was NOT sent."""
|
|
602
|
+
|
|
603
|
+
def __init__(self, provider: str = ""):
|
|
604
|
+
self.provider = provider
|
|
605
|
+
super().__init__(self.user_message)
|
|
606
|
+
|
|
607
|
+
@property
|
|
608
|
+
def user_message(self) -> str:
|
|
609
|
+
return ("Not sent: this request includes content about a client whose agreement keeps "
|
|
610
|
+
"their data on Hyper's own models, and the model answering now is from an "
|
|
611
|
+
"outside provider. Nothing was sent to it. Switch to HyperSpeed V2 (/model), or "
|
|
612
|
+
"start a new conversation (/reset) without that content.")
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
# Request keys that carry content. Everything else (model, max_tokens, ...)
|
|
616
|
+
# is left alone.
|
|
617
|
+
REQUEST_CONTENT_KEYS = ("system", "messages", "input", "instructions", "prompt", "tools")
|
|
618
|
+
# Inside messages: protocol fields that are never content.
|
|
619
|
+
_PROTOCOL_KEYS = frozenset({"role", "type", "id", "tool_use_id", "tool_call_id", "name",
|
|
620
|
+
"model", "cache_control", "media_type", "stop_reason",
|
|
621
|
+
"index", "status"})
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _redact(value: Any, m: Matcher, counter: list, *, paragraphs: bool = False,
|
|
625
|
+
key: str = "", depth: int = 0) -> Any:
|
|
626
|
+
if depth > 16:
|
|
627
|
+
return value
|
|
628
|
+
if isinstance(value, str):
|
|
629
|
+
if key in _PROTOCOL_KEYS or not m.hits(value):
|
|
630
|
+
return value
|
|
631
|
+
if key == "arguments": # an OpenAI tool call's JSON arguments
|
|
632
|
+
counter[0] += 1
|
|
633
|
+
return json.dumps({"withheld": placeholder()})
|
|
634
|
+
if paragraphs: # the system prompt: only the paragraphs that match
|
|
635
|
+
parts = re.split(r"(\n\s*\n)", value)
|
|
636
|
+
out = []
|
|
637
|
+
for p in parts:
|
|
638
|
+
if p.strip() and m.hits(p):
|
|
639
|
+
counter[0] += 1
|
|
640
|
+
out.append(placeholder())
|
|
641
|
+
else:
|
|
642
|
+
out.append(p)
|
|
643
|
+
return "".join(out)
|
|
644
|
+
counter[0] += 1
|
|
645
|
+
return placeholder()
|
|
646
|
+
if isinstance(value, dict):
|
|
647
|
+
return {k: _redact(v, m, counter, paragraphs=paragraphs, key=str(k), depth=depth + 1)
|
|
648
|
+
for k, v in value.items()}
|
|
649
|
+
if isinstance(value, list):
|
|
650
|
+
return [_redact(v, m, counter, paragraphs=paragraphs, key=key, depth=depth + 1)
|
|
651
|
+
for v in value]
|
|
652
|
+
if isinstance(value, tuple):
|
|
653
|
+
return tuple(_redact(v, m, counter, paragraphs=paragraphs, key=key, depth=depth + 1)
|
|
654
|
+
for v in value)
|
|
655
|
+
if hasattr(value, "model_dump"): # SDK message objects in a history
|
|
656
|
+
try:
|
|
657
|
+
return _redact(value.model_dump(), m, counter, paragraphs=paragraphs, key=key,
|
|
658
|
+
depth=depth + 1) if m.hits(value) else value
|
|
659
|
+
except Exception:
|
|
660
|
+
return value
|
|
661
|
+
return value
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
class _ConnectorContent:
|
|
665
|
+
"""Fail-safe matcher when the profile's required list is missing: any
|
|
666
|
+
string that is connector content (type tag or the taint ledger)."""
|
|
667
|
+
|
|
668
|
+
def hits(self, value: Any) -> bool:
|
|
669
|
+
from hyperclaw import taint
|
|
670
|
+
for s in strings(value):
|
|
671
|
+
if isinstance(s, taint.TaintedText):
|
|
672
|
+
return True
|
|
673
|
+
try:
|
|
674
|
+
if taint._matches_any(s):
|
|
675
|
+
return True
|
|
676
|
+
except Exception:
|
|
677
|
+
return True # can't check: withhold
|
|
678
|
+
return False
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _request_matcher():
|
|
682
|
+
return _ConnectorContent() if profile_missing() else matcher()
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def redact_request(provider: str, kw: dict) -> dict:
|
|
686
|
+
"""For an SDK call's keyword arguments: under an outside provider, every
|
|
687
|
+
content string that names a restricted client is replaced by the note
|
|
688
|
+
(whole message content; for the system prompt, the matching paragraphs).
|
|
689
|
+
Returns the kwargs to send (a copy when anything changed)."""
|
|
690
|
+
if is_hyper_provider(provider) or not isinstance(kw, dict):
|
|
691
|
+
return kw
|
|
692
|
+
m = _request_matcher()
|
|
693
|
+
counter = [0]
|
|
694
|
+
out = dict(kw)
|
|
695
|
+
for k in REQUEST_CONTENT_KEYS:
|
|
696
|
+
if k in out and out[k] is not None and k != "tools":
|
|
697
|
+
out[k] = _redact(out[k], m, counter, paragraphs=(k in ("system", "instructions")))
|
|
698
|
+
if counter[0]:
|
|
699
|
+
_record("request", counter[0])
|
|
700
|
+
log.info("client guard: withheld %d restricted part(s) from a %s request",
|
|
701
|
+
counter[0], provider or "?")
|
|
702
|
+
return out
|
|
703
|
+
return kw
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def final_check(provider: str, request: Any) -> None:
|
|
707
|
+
"""Last check before a request leaves: an outside provider's request that
|
|
708
|
+
still involves a restricted client is stopped. Raises
|
|
709
|
+
RestrictedContentBlocked; nothing is sent."""
|
|
710
|
+
if is_hyper_provider(provider):
|
|
711
|
+
return
|
|
712
|
+
if request is None:
|
|
713
|
+
return
|
|
714
|
+
if _request_matcher().hits(request):
|
|
715
|
+
log.warning("client guard: stopped a %s request carrying restricted content",
|
|
716
|
+
provider or "?")
|
|
717
|
+
raise RestrictedContentBlocked(provider)
|