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
hyperclaw/taint.py
ADDED
|
@@ -0,0 +1,832 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Connector-content taint guard.
|
|
3
|
+
|
|
4
|
+
Vaughn's rule: "connector-sourced content can never enter any training,
|
|
5
|
+
corpus, eval-data or self-improvement proposal path." The reason is Google's
|
|
6
|
+
Limited Use policy for Google user data; the same rule is applied to every
|
|
7
|
+
connector (Slack, GitLab, Telegram messages from other people, documents,
|
|
8
|
+
web pages, iMessage).
|
|
9
|
+
|
|
10
|
+
Three parts:
|
|
11
|
+
|
|
12
|
+
- **Producers** wrap what a connector read returns in ``TaintedText`` (a
|
|
13
|
+
``str`` that carries ``origin`` and ``connector_id``) with ``mark()`` or
|
|
14
|
+
the ``@source(...)`` decorator. Every tainted read is also recorded in the
|
|
15
|
+
conversation's ``TaintLedger``.
|
|
16
|
+
- **TaintLedger** (one per conversation/session): the 64-character shingles
|
|
17
|
+
(hashed) of every tainted read, and PER-MESSAGE provenance: which message
|
|
18
|
+
(turn) read which connector (rc5, HC-038). The conversation's "tainted"
|
|
19
|
+
flag is derived from it (some message in it read connector content).
|
|
20
|
+
Shingles catch connector text after it has lost its type tag (sliced,
|
|
21
|
+
concatenated, JSON-encoded, quoted in a model's summary). The ledger is
|
|
22
|
+
persisted in ``$HYPERCLAW_ROOT/taint/taint.sqlite3`` (hashes only, never the
|
|
23
|
+
text), so a reloaded conversation is re-flagged.
|
|
24
|
+
- **Sinks**: ``@training_sink(name)`` on every writer to a learning, memory,
|
|
25
|
+
skills, self-improvement, knowledge-graph, civilization, corpus or eval
|
|
26
|
+
store. A sink refuses tainted input by raising ``TaintedInput``, writes an
|
|
27
|
+
activity-log row ("refused: connector content") and never truncates or
|
|
28
|
+
drops anything silently.
|
|
29
|
+
|
|
30
|
+
What a sink refuses:
|
|
31
|
+
1. a ``TaintedText`` anywhere in its arguments (type tag);
|
|
32
|
+
2. any argument text that shares a 64-character shingle with a tainted read
|
|
33
|
+
(or equals a short tainted string) in a loaded ledger;
|
|
34
|
+
3. any call made while the active conversation is tainted, unless the sink is
|
|
35
|
+
``own_words=True``. Own-words sinks are the user-directed memory stores
|
|
36
|
+
(``memory_store``, ``MemoryManager.remember``): a memory the user asks for
|
|
37
|
+
in their own words still saves in a conversation that read email, while
|
|
38
|
+
connector text itself (rules 1 and 2) is still refused there.
|
|
39
|
+
|
|
40
|
+
Memory (rc5): ``memory_decision`` decides, per message, whether a
|
|
41
|
+
``memory_store`` call is the person's own words (saved without a prompt) or
|
|
42
|
+
something learned from email, pages or other people (it waits for the
|
|
43
|
+
person's tap on an approval card that names the source). The message counts,
|
|
44
|
+
not the conversation: an email read three messages ago no longer puts a tap
|
|
45
|
+
on a memory the person states in their own words now. Limit (documented by a
|
|
46
|
+
test): a PARAPHRASE of connector content shares no 64-character shingle with
|
|
47
|
+
it, so rules 1-2 do not catch it; the per-message check and the own-words
|
|
48
|
+
overlap are what put it in front of the person.
|
|
49
|
+
|
|
50
|
+
``tests/unit/test_taint_guard.py`` holds an AST scan that fails the build on
|
|
51
|
+
any undecorated writer to these stores (see ``SCAN_ALLOWLIST`` there).
|
|
52
|
+
"""
|
|
53
|
+
from __future__ import annotations
|
|
54
|
+
|
|
55
|
+
import contextvars
|
|
56
|
+
import dataclasses
|
|
57
|
+
import functools
|
|
58
|
+
import hashlib
|
|
59
|
+
import inspect
|
|
60
|
+
import logging
|
|
61
|
+
import os
|
|
62
|
+
import re
|
|
63
|
+
import sqlite3
|
|
64
|
+
import threading
|
|
65
|
+
from contextlib import contextmanager
|
|
66
|
+
from pathlib import Path
|
|
67
|
+
from typing import Any, Callable, Dict, Iterable, Iterator, Optional, Set, Tuple
|
|
68
|
+
|
|
69
|
+
log = logging.getLogger("hyperclaw.taint")
|
|
70
|
+
|
|
71
|
+
SHINGLE = 64 # characters per shingle (normalised text)
|
|
72
|
+
MIN_EXACT = 24 # shorter tainted strings are matched whole, not by shingle
|
|
73
|
+
BACKGROUND = "background" # reads outside any conversation (scheduled brief/digest)
|
|
74
|
+
MAX_DEPTH = 8
|
|
75
|
+
|
|
76
|
+
# origin -> how the user knows that source
|
|
77
|
+
LABELS = {
|
|
78
|
+
"gmail": "Gmail", "google_calendar": "Google Calendar", "google_docs": "Google Docs",
|
|
79
|
+
"google_sheets": "Google Sheets", "google_drive": "Google Drive", "slack": "Slack",
|
|
80
|
+
"gitlab": "GitLab", "telegram": "Telegram", "imessage": "iMessage", "web": "web",
|
|
81
|
+
"document": "document", "email": "email", "gdrive": "Google Drive", "google": "Google",
|
|
82
|
+
"notion": "Notion", "confluence": "Confluence", "google_slides": "Google Slides",
|
|
83
|
+
"google_meet": "Google Meet",
|
|
84
|
+
}
|
|
85
|
+
GOOGLE_ORIGINS = {"gmail", "google_calendar", "google_docs", "google_sheets", "google_drive",
|
|
86
|
+
"gdrive", "google", "google_slides", "google_meet"}
|
|
87
|
+
# `source=` names that mean the content came from a connector (civilization sync).
|
|
88
|
+
CONNECTOR_SOURCES = {"gdrive", "google_drive", "gmail", "google", "notion", "confluence",
|
|
89
|
+
"slack", "gitlab", "telegram", "imessage", "jira", "hubspot",
|
|
90
|
+
"salesforce", "dropbox", "box"}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ───────────────────────── the type tag ─────────────────────────
|
|
94
|
+
|
|
95
|
+
class TaintedText(str):
|
|
96
|
+
"""A str read from a connector. Slicing or joining returns a plain str;
|
|
97
|
+
the ledger's shingles still recognise it."""
|
|
98
|
+
|
|
99
|
+
def __new__(cls, value: str = "", origin: str = "connector", connector_id: str = ""):
|
|
100
|
+
obj = super().__new__(cls, value)
|
|
101
|
+
obj.origin = origin
|
|
102
|
+
obj.connector_id = connector_id
|
|
103
|
+
return obj
|
|
104
|
+
|
|
105
|
+
def __reduce__(self):
|
|
106
|
+
return (TaintedText, (str(self), self.origin, self.connector_id))
|
|
107
|
+
|
|
108
|
+
def __repr__(self) -> str:
|
|
109
|
+
return f"TaintedText({str.__repr__(self)}, origin={self.origin!r})"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class TaintedInput(Exception):
|
|
113
|
+
"""A training/corpus/eval/self-improvement sink was given connector content."""
|
|
114
|
+
|
|
115
|
+
def __init__(self, sink: str, reason: str, origin: str = "", conversation: str = ""):
|
|
116
|
+
self.sink = sink
|
|
117
|
+
self.reason = reason # "tagged" | "shingle" | "conversation" | "unavailable"
|
|
118
|
+
self.origin = origin or "connector"
|
|
119
|
+
self.conversation = conversation
|
|
120
|
+
super().__init__(self.user_message)
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def label(self) -> str:
|
|
124
|
+
return LABELS.get(self.origin, self.origin.replace("_", " "))
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def user_message(self) -> str:
|
|
128
|
+
policy = ("Google's Limited Use policy for Google data, and HyperAgents applies the same "
|
|
129
|
+
"rule to every connected account" if self.origin in GOOGLE_ORIGINS else
|
|
130
|
+
"HyperAgents' Limited Use rule for every connected account, the same rule "
|
|
131
|
+
"Google sets for Google data")
|
|
132
|
+
if self.reason == "conversation":
|
|
133
|
+
return (f"Not done ({self.sink}): this conversation includes content read from "
|
|
134
|
+
f"{self.label}, so it is not used for learning, reflections, skill proposals "
|
|
135
|
+
f"or any training or evaluation data ({policy}). Nothing was saved. Start a "
|
|
136
|
+
f"new conversation (/reset) to turn these back on.")
|
|
137
|
+
if self.reason == "unavailable":
|
|
138
|
+
return (f"Not saved ({self.sink}): the connector-content check could not run, so "
|
|
139
|
+
f"nothing was saved. Try again; if it keeps failing, check the HyperAgents "
|
|
140
|
+
f"data folder.")
|
|
141
|
+
return (f"Not saved: this is content from {self.label}. Connector content is never kept "
|
|
142
|
+
f"in memory, learning, training or evaluation data ({policy}). Keep it in the "
|
|
143
|
+
f"source email or document instead; I can read it from there whenever you ask. "
|
|
144
|
+
f"I can still remember things you tell me in your own words.")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ───────────────────────── shingles ─────────────────────────
|
|
148
|
+
|
|
149
|
+
_ESCAPES = re.compile(r"\\u[0-9a-fA-F]{4}|\\[nrtbf\"'\\/]")
|
|
150
|
+
_NON_WORD = re.compile(r"[\W_]+", re.UNICODE)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def normalize(text: str) -> str:
|
|
154
|
+
"""Lower case, JSON escapes and punctuation/whitespace runs -> one space,
|
|
155
|
+
so a shingle survives re-wrapping, markdown and JSON encoding."""
|
|
156
|
+
t = _ESCAPES.sub(" ", str(text))
|
|
157
|
+
return _NON_WORD.sub(" ", t.lower()).strip()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _hash(s: str) -> int:
|
|
161
|
+
return int.from_bytes(hashlib.blake2b(s.encode("utf-8"), digest_size=8).digest(),
|
|
162
|
+
"big", signed=True)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def shingles(text: str) -> Set[int]:
|
|
166
|
+
n = normalize(text)
|
|
167
|
+
if len(n) < SHINGLE:
|
|
168
|
+
return set()
|
|
169
|
+
return {_hash(n[i:i + SHINGLE]) for i in range(len(n) - SHINGLE + 1)}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def candidate_hashes(text: str) -> Set[int]:
|
|
173
|
+
"""Every shingle of `text`, plus its whole-text hash when it is short."""
|
|
174
|
+
out = shingles(text)
|
|
175
|
+
ex = _exact(text)
|
|
176
|
+
if ex is not None:
|
|
177
|
+
out.add(ex)
|
|
178
|
+
return out
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _exact(text: str) -> Optional[int]:
|
|
182
|
+
n = normalize(text)
|
|
183
|
+
if MIN_EXACT <= len(n) < SHINGLE:
|
|
184
|
+
return _hash("=" + n)
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ───────────────────────── the ledger ─────────────────────────
|
|
189
|
+
|
|
190
|
+
def taint_dir() -> Path:
|
|
191
|
+
explicit = os.environ.get("HYPERCLAW_TAINT_DIR")
|
|
192
|
+
if explicit:
|
|
193
|
+
return Path(explicit).expanduser()
|
|
194
|
+
root = Path(os.environ.get("HYPERCLAW_ROOT", Path.home() / ".hyperclaw")).expanduser()
|
|
195
|
+
return root / "taint"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
_SCHEMA = """
|
|
199
|
+
CREATE TABLE IF NOT EXISTS taint_sessions (
|
|
200
|
+
session TEXT PRIMARY KEY,
|
|
201
|
+
tainted INTEGER NOT NULL DEFAULT 0,
|
|
202
|
+
origins TEXT NOT NULL DEFAULT ''
|
|
203
|
+
);
|
|
204
|
+
CREATE TABLE IF NOT EXISTS taint_shingles (
|
|
205
|
+
session TEXT NOT NULL,
|
|
206
|
+
h INTEGER NOT NULL,
|
|
207
|
+
PRIMARY KEY (session, h)
|
|
208
|
+
) WITHOUT ROWID;
|
|
209
|
+
CREATE TABLE IF NOT EXISTS taint_messages (
|
|
210
|
+
session TEXT NOT NULL,
|
|
211
|
+
msg TEXT NOT NULL,
|
|
212
|
+
origin TEXT NOT NULL,
|
|
213
|
+
connector TEXT NOT NULL DEFAULT '',
|
|
214
|
+
PRIMARY KEY (session, msg, origin, connector)
|
|
215
|
+
) WITHOUT ROWID;
|
|
216
|
+
"""
|
|
217
|
+
# Reads with no message id (scheduled jobs, direct calls) are recorded under
|
|
218
|
+
# this id: they count for the conversation, never for a specific message.
|
|
219
|
+
UNSCOPED = "*"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class _Store:
|
|
223
|
+
"""Hashes only (never the text), one SQLite file per HYPERCLAW_ROOT."""
|
|
224
|
+
|
|
225
|
+
def __init__(self, path: Path):
|
|
226
|
+
self.path = path
|
|
227
|
+
self._ready = False
|
|
228
|
+
self._lock = threading.Lock()
|
|
229
|
+
|
|
230
|
+
def connect(self) -> sqlite3.Connection:
|
|
231
|
+
if not self._ready:
|
|
232
|
+
with self._lock:
|
|
233
|
+
if not self._ready:
|
|
234
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
235
|
+
try:
|
|
236
|
+
os.chmod(self.path.parent, 0o700)
|
|
237
|
+
except OSError:
|
|
238
|
+
pass
|
|
239
|
+
con = sqlite3.connect(self.path, timeout=10)
|
|
240
|
+
try:
|
|
241
|
+
con.executescript(_SCHEMA)
|
|
242
|
+
finally:
|
|
243
|
+
con.close()
|
|
244
|
+
try:
|
|
245
|
+
os.chmod(self.path, 0o600)
|
|
246
|
+
except OSError:
|
|
247
|
+
pass
|
|
248
|
+
self._ready = True
|
|
249
|
+
return sqlite3.connect(self.path, timeout=10)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class TaintLedger:
|
|
253
|
+
"""Per-conversation record of tainted reads: shingle hashes, short-string
|
|
254
|
+
hashes, the tainted flag and which sources were read."""
|
|
255
|
+
|
|
256
|
+
def __init__(self, session: str, store: Optional[_Store] = None):
|
|
257
|
+
self.session = session
|
|
258
|
+
self.hashes: Set[int] = set()
|
|
259
|
+
self.tainted = False
|
|
260
|
+
self.origins: Set[Tuple[str, str]] = set()
|
|
261
|
+
# message id -> {(origin, connector_id)}: which message read what.
|
|
262
|
+
self.messages: Dict[str, Set[Tuple[str, str]]] = {}
|
|
263
|
+
self._store = store
|
|
264
|
+
self._lock = threading.Lock()
|
|
265
|
+
|
|
266
|
+
# persistence
|
|
267
|
+
def load(self) -> "TaintLedger":
|
|
268
|
+
"""Read this conversation's record. A store that cannot be read is
|
|
269
|
+
logged and the ledger works in memory (reads are never blocked)."""
|
|
270
|
+
if self._store is None:
|
|
271
|
+
return self
|
|
272
|
+
try:
|
|
273
|
+
self._load()
|
|
274
|
+
except (sqlite3.Error, OSError) as e:
|
|
275
|
+
log.warning("taint ledger for %s not loaded (%s); in memory only", self.session, e)
|
|
276
|
+
return self
|
|
277
|
+
|
|
278
|
+
def _load(self) -> None:
|
|
279
|
+
con = self._store.connect()
|
|
280
|
+
try:
|
|
281
|
+
row = con.execute("SELECT tainted, origins FROM taint_sessions WHERE session=?",
|
|
282
|
+
(self.session,)).fetchone()
|
|
283
|
+
if row:
|
|
284
|
+
self.tainted = bool(row[0])
|
|
285
|
+
self.origins = {tuple(o.split("|", 1)) if "|" in o else (o, "")
|
|
286
|
+
for o in (row[1] or "").split(",") if o}
|
|
287
|
+
self.hashes = {r[0] for r in con.execute(
|
|
288
|
+
"SELECT h FROM taint_shingles WHERE session=?", (self.session,))}
|
|
289
|
+
for msg, origin, cid in con.execute(
|
|
290
|
+
"SELECT msg, origin, connector FROM taint_messages WHERE session=?",
|
|
291
|
+
(self.session,)):
|
|
292
|
+
self.messages.setdefault(msg, set()).add((origin, cid))
|
|
293
|
+
finally:
|
|
294
|
+
con.close()
|
|
295
|
+
|
|
296
|
+
def _persist(self, new: Iterable[int], msg: Optional[Tuple[str, str, str]] = None) -> None:
|
|
297
|
+
if self._store is None:
|
|
298
|
+
return
|
|
299
|
+
try:
|
|
300
|
+
self._write(new, msg)
|
|
301
|
+
except (sqlite3.Error, OSError) as e: # the in-memory record still applies
|
|
302
|
+
log.warning("taint ledger for %s not saved (%s); in memory only", self.session, e)
|
|
303
|
+
|
|
304
|
+
def _write(self, new: Iterable[int], msg: Optional[Tuple[str, str, str]] = None) -> None:
|
|
305
|
+
con = self._store.connect()
|
|
306
|
+
try:
|
|
307
|
+
with con:
|
|
308
|
+
con.execute("INSERT INTO taint_sessions (session, tainted, origins) VALUES (?,?,?) "
|
|
309
|
+
"ON CONFLICT (session) DO UPDATE SET tainted=excluded.tainted, "
|
|
310
|
+
"origins=excluded.origins",
|
|
311
|
+
(self.session, int(self.tainted),
|
|
312
|
+
",".join(sorted(f"{o}|{c}" for o, c in self.origins))))
|
|
313
|
+
con.executemany("INSERT OR IGNORE INTO taint_shingles (session, h) VALUES (?, ?)",
|
|
314
|
+
((self.session, h) for h in new))
|
|
315
|
+
if msg is not None:
|
|
316
|
+
con.execute("INSERT OR IGNORE INTO taint_messages (session, msg, origin, "
|
|
317
|
+
"connector) VALUES (?,?,?,?)", (self.session,) + msg)
|
|
318
|
+
finally:
|
|
319
|
+
con.close()
|
|
320
|
+
|
|
321
|
+
def _note_message(self, origin: str, connector_id: str,
|
|
322
|
+
message: Optional[str]) -> Optional[Tuple[str, str, str]]:
|
|
323
|
+
msg = str(message or current_message() or UNSCOPED)
|
|
324
|
+
seen = self.messages.setdefault(msg, set())
|
|
325
|
+
if (origin, connector_id) in seen:
|
|
326
|
+
return None
|
|
327
|
+
seen.add((origin, connector_id))
|
|
328
|
+
return (msg, origin, connector_id)
|
|
329
|
+
|
|
330
|
+
def add(self, text: str, origin: str, connector_id: str = "",
|
|
331
|
+
message: Optional[str] = None) -> None:
|
|
332
|
+
new = candidate_hashes(text)
|
|
333
|
+
with self._lock:
|
|
334
|
+
fresh = new - self.hashes
|
|
335
|
+
mrow = self._note_message(origin, connector_id, message)
|
|
336
|
+
changed = bool(fresh) or not self.tainted or \
|
|
337
|
+
(origin, connector_id) not in self.origins or mrow is not None
|
|
338
|
+
self.hashes |= fresh
|
|
339
|
+
self.tainted = True
|
|
340
|
+
self.origins.add((origin, connector_id))
|
|
341
|
+
if changed:
|
|
342
|
+
self._persist(fresh, mrow)
|
|
343
|
+
|
|
344
|
+
def flag(self, origin: str, connector_id: str = "", message: Optional[str] = None) -> None:
|
|
345
|
+
with self._lock:
|
|
346
|
+
mrow = self._note_message(origin, connector_id, message)
|
|
347
|
+
if self.tainted and (origin, connector_id) in self.origins and mrow is None:
|
|
348
|
+
return
|
|
349
|
+
self.tainted = True
|
|
350
|
+
self.origins.add((origin, connector_id))
|
|
351
|
+
self._persist((), mrow)
|
|
352
|
+
|
|
353
|
+
def message_origins(self, message: Optional[str]) -> Set[str]:
|
|
354
|
+
"""The connectors read in this one message (turn)."""
|
|
355
|
+
if not message:
|
|
356
|
+
return set()
|
|
357
|
+
return {o for o, _ in self.messages.get(str(message), set())}
|
|
358
|
+
|
|
359
|
+
def matches(self, text: str) -> bool:
|
|
360
|
+
return bool(self.hashes) and not self.hashes.isdisjoint(candidate_hashes(text))
|
|
361
|
+
|
|
362
|
+
def first_origin(self) -> str:
|
|
363
|
+
return sorted(self.origins)[0][0] if self.origins else "connector"
|
|
364
|
+
|
|
365
|
+
def clear(self) -> None:
|
|
366
|
+
with self._lock:
|
|
367
|
+
self.hashes.clear()
|
|
368
|
+
self.tainted = False
|
|
369
|
+
self.origins.clear()
|
|
370
|
+
self.messages.clear()
|
|
371
|
+
if self._store is None:
|
|
372
|
+
return
|
|
373
|
+
try:
|
|
374
|
+
con = self._store.connect()
|
|
375
|
+
try:
|
|
376
|
+
with con:
|
|
377
|
+
con.execute("DELETE FROM taint_sessions WHERE session=?", (self.session,))
|
|
378
|
+
con.execute("DELETE FROM taint_shingles WHERE session=?", (self.session,))
|
|
379
|
+
con.execute("DELETE FROM taint_messages WHERE session=?", (self.session,))
|
|
380
|
+
finally:
|
|
381
|
+
con.close()
|
|
382
|
+
except (sqlite3.Error, OSError) as e:
|
|
383
|
+
log.warning("taint ledger for %s not cleared on disk (%s)", self.session, e)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# ───────────────────────── process registry + active conversation ─────────────────────────
|
|
387
|
+
|
|
388
|
+
_LOCK = threading.RLock()
|
|
389
|
+
_STORES: Dict[str, _Store] = {}
|
|
390
|
+
_LEDGERS: Dict[Tuple[str, str], TaintLedger] = {}
|
|
391
|
+
_ACTIVE: contextvars.ContextVar = contextvars.ContextVar("hyperclaw_taint_conversation",
|
|
392
|
+
default=None)
|
|
393
|
+
# The message (turn) a read belongs to: the TurnGuard's turn id, set by the
|
|
394
|
+
# registry around every tool call.
|
|
395
|
+
_MESSAGE: contextvars.ContextVar = contextvars.ContextVar("hyperclaw_taint_message",
|
|
396
|
+
default=None)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def current_message() -> Optional[str]:
|
|
400
|
+
return _MESSAGE.get()
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
@contextmanager
|
|
404
|
+
def message(msg_id: Optional[str]):
|
|
405
|
+
"""Run a block as part of one message (turn): reads inside are recorded
|
|
406
|
+
against it (per-message provenance)."""
|
|
407
|
+
if not msg_id:
|
|
408
|
+
yield
|
|
409
|
+
return
|
|
410
|
+
token = _MESSAGE.set(str(msg_id))
|
|
411
|
+
try:
|
|
412
|
+
yield
|
|
413
|
+
finally:
|
|
414
|
+
_MESSAGE.reset(token)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _store() -> _Store:
|
|
418
|
+
p = str(taint_dir() / "taint.sqlite3")
|
|
419
|
+
with _LOCK:
|
|
420
|
+
if p not in _STORES:
|
|
421
|
+
_STORES[p] = _Store(Path(p))
|
|
422
|
+
return _STORES[p]
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def ledger(session: Optional[str] = None) -> TaintLedger:
|
|
426
|
+
"""The ledger for `session` (default: the active conversation, else the
|
|
427
|
+
background ledger), loaded from disk on first use in this process."""
|
|
428
|
+
sid = session or active() or BACKGROUND
|
|
429
|
+
st = _store()
|
|
430
|
+
key = (str(st.path), sid)
|
|
431
|
+
with _LOCK:
|
|
432
|
+
led = _LEDGERS.get(key)
|
|
433
|
+
if led is None:
|
|
434
|
+
led = TaintLedger(sid, st).load()
|
|
435
|
+
_LEDGERS[key] = led
|
|
436
|
+
return led
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _loaded() -> Iterator[TaintLedger]:
|
|
440
|
+
st = str(_store().path)
|
|
441
|
+
with _LOCK:
|
|
442
|
+
items = [led for (p, _), led in _LEDGERS.items() if p == st]
|
|
443
|
+
return iter(items)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def active() -> Optional[str]:
|
|
447
|
+
return _ACTIVE.get()
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
@contextmanager
|
|
451
|
+
def conversation(session: Optional[str]):
|
|
452
|
+
"""Run a block as part of conversation `session`: reads made inside are
|
|
453
|
+
recorded in its ledger and sinks check its flag."""
|
|
454
|
+
if not session:
|
|
455
|
+
yield
|
|
456
|
+
return
|
|
457
|
+
token = _ACTIVE.set(str(session))
|
|
458
|
+
try:
|
|
459
|
+
yield
|
|
460
|
+
finally:
|
|
461
|
+
_ACTIVE.reset(token)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def is_tainted(session: Optional[str] = None) -> bool:
|
|
465
|
+
sid = session or active()
|
|
466
|
+
if not sid:
|
|
467
|
+
return False
|
|
468
|
+
return ledger(sid).tainted
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def clear(session: str) -> None:
|
|
472
|
+
"""Forget a conversation's taint: only when its history is cleared
|
|
473
|
+
(/reset, /clear), since the content is then gone from it."""
|
|
474
|
+
ledger(session).clear()
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def reset_process_state() -> None:
|
|
478
|
+
"""Drop the in-memory ledgers (tests; a restarted process). The on-disk
|
|
479
|
+
store is untouched, so a reloaded conversation is re-flagged."""
|
|
480
|
+
with _LOCK:
|
|
481
|
+
_LEDGERS.clear()
|
|
482
|
+
_STORES.clear()
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
# ───────────────────────── producers ─────────────────────────
|
|
486
|
+
|
|
487
|
+
def mark(value: Any, origin: str, connector_id: str = "",
|
|
488
|
+
session: Optional[str] = None, message: Optional[str] = None) -> Any:
|
|
489
|
+
"""Wrap every string in `value` (str, dict values, lists, tuples) in
|
|
490
|
+
TaintedText and record it in the conversation's ledger, against the
|
|
491
|
+
message (turn) that read it. Returns the wrapped value (dicts and lists
|
|
492
|
+
are copied, never mutated)."""
|
|
493
|
+
led = ledger(session)
|
|
494
|
+
out = _wrap(value, origin, connector_id or origin, led, 0, message)
|
|
495
|
+
led.flag(origin, connector_id or origin, message)
|
|
496
|
+
return out
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _wrap(value: Any, origin: str, cid: str, led: TaintLedger, depth: int,
|
|
500
|
+
msg: Optional[str] = None) -> Any:
|
|
501
|
+
if depth > MAX_DEPTH or value is None:
|
|
502
|
+
return value
|
|
503
|
+
if isinstance(value, str):
|
|
504
|
+
if value:
|
|
505
|
+
led.add(value, origin, cid, msg)
|
|
506
|
+
if isinstance(value, TaintedText):
|
|
507
|
+
return value
|
|
508
|
+
return TaintedText(value, origin, cid)
|
|
509
|
+
if isinstance(value, dict):
|
|
510
|
+
return {k: _wrap(v, origin, cid, led, depth + 1, msg) for k, v in value.items()}
|
|
511
|
+
if isinstance(value, list):
|
|
512
|
+
return [_wrap(v, origin, cid, led, depth + 1, msg) for v in value]
|
|
513
|
+
if isinstance(value, tuple) and not hasattr(value, "_fields"):
|
|
514
|
+
return tuple(_wrap(v, origin, cid, led, depth + 1, msg) for v in value)
|
|
515
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
516
|
+
for f in dataclasses.fields(value):
|
|
517
|
+
try:
|
|
518
|
+
object.__setattr__(value, f.name,
|
|
519
|
+
_wrap(getattr(value, f.name), origin, cid, led, depth + 1,
|
|
520
|
+
msg))
|
|
521
|
+
except Exception: # an exotic field: its text is still in the ledger
|
|
522
|
+
pass
|
|
523
|
+
return value
|
|
524
|
+
return value
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def source(origin: str, connector_id: str = "") -> Callable:
|
|
528
|
+
"""Decorator for a connector read: its return value is marked."""
|
|
529
|
+
def deco(fn: Callable) -> Callable:
|
|
530
|
+
if inspect.iscoroutinefunction(fn):
|
|
531
|
+
@functools.wraps(fn)
|
|
532
|
+
async def awrapper(*a, **kw):
|
|
533
|
+
return mark(await fn(*a, **kw), origin, connector_id)
|
|
534
|
+
awrapper.__taint_source__ = origin
|
|
535
|
+
return awrapper
|
|
536
|
+
|
|
537
|
+
@functools.wraps(fn)
|
|
538
|
+
def wrapper(*a, **kw):
|
|
539
|
+
return mark(fn(*a, **kw), origin, connector_id)
|
|
540
|
+
wrapper.__taint_source__ = origin
|
|
541
|
+
return wrapper
|
|
542
|
+
return deco
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
# Tool-result envelopes: model tools whose OK result is connector content.
|
|
546
|
+
CONNECTOR_TOOLS = {
|
|
547
|
+
"email_read": "gmail", "email_search": "gmail", "calendar_read": "google_calendar",
|
|
548
|
+
"imessage_read": "imessage", "web_fetch": "web", "document_read": "document",
|
|
549
|
+
"pdf_read": "document", "gitlab_review_queue": "gitlab",
|
|
550
|
+
# Google Workspace tools (hyperclaw/google_workspace/tools.py ORIGIN)
|
|
551
|
+
"gmail_search": "gmail", "gmail_read_thread": "gmail", "gmail_read_attachment": "gmail",
|
|
552
|
+
"calendar_list_events": "google_calendar", "calendar_freebusy": "google_calendar",
|
|
553
|
+
"drive_search": "google_drive", "drive_read": "google_drive", "docs_read": "google_docs",
|
|
554
|
+
"sheets_read": "google_sheets", "slides_read": "google_slides",
|
|
555
|
+
"meet_list_records": "google_meet", "meet_read_transcript": "google_meet",
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def mark_tool_result(tool: str, env: Any, session: Optional[str],
|
|
560
|
+
message: Optional[str] = None) -> None:
|
|
561
|
+
"""Record a connector tool's envelope (data and message) in the
|
|
562
|
+
conversation's ledger, against the message (turn) that read it. The
|
|
563
|
+
envelope itself is not changed."""
|
|
564
|
+
origin = CONNECTOR_TOOLS.get(tool)
|
|
565
|
+
if origin is None:
|
|
566
|
+
return
|
|
567
|
+
led = ledger(session)
|
|
568
|
+
for part in (getattr(env, "data", None), getattr(env, "message", None)):
|
|
569
|
+
_wrap(part, origin, tool, led, 0, message)
|
|
570
|
+
led.flag(origin, tool, message)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
# ───────────────────────── sinks ─────────────────────────
|
|
574
|
+
|
|
575
|
+
SINKS: Dict[str, str] = {} # sink name -> module.qualname
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _origin_of(value: Any, depth: int = 0) -> Optional[str]:
|
|
579
|
+
"""origin of the first TaintedText found in value."""
|
|
580
|
+
if depth > MAX_DEPTH or value is None:
|
|
581
|
+
return None
|
|
582
|
+
if isinstance(value, TaintedText):
|
|
583
|
+
return value.origin
|
|
584
|
+
for v in _children(value):
|
|
585
|
+
o = _origin_of(v, depth + 1)
|
|
586
|
+
if o:
|
|
587
|
+
return o
|
|
588
|
+
return None
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _children(value: Any) -> Iterable[Any]:
|
|
592
|
+
if isinstance(value, (str, bytes, bytearray, int, float, bool)) or value is None:
|
|
593
|
+
return ()
|
|
594
|
+
if isinstance(value, dict):
|
|
595
|
+
return list(value.keys()) + list(value.values())
|
|
596
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
597
|
+
return list(value)
|
|
598
|
+
dump = getattr(value, "model_dump", None)
|
|
599
|
+
if callable(dump) and not isinstance(value, type):
|
|
600
|
+
try:
|
|
601
|
+
return [dump(mode="python")]
|
|
602
|
+
except Exception:
|
|
603
|
+
return list(getattr(value, "__dict__", {}).values())
|
|
604
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
605
|
+
return [getattr(value, f.name, None) for f in dataclasses.fields(value)]
|
|
606
|
+
return ()
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _strings(value: Any, depth: int = 0) -> Iterator[str]:
|
|
610
|
+
if depth > MAX_DEPTH or value is None:
|
|
611
|
+
return
|
|
612
|
+
if isinstance(value, str):
|
|
613
|
+
yield value
|
|
614
|
+
return
|
|
615
|
+
for v in _children(value):
|
|
616
|
+
yield from _strings(v, depth + 1)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def check(sink: str, values: Iterable[Any], *, session: Optional[str] = None,
|
|
620
|
+
own_words: bool = False) -> None:
|
|
621
|
+
"""Raise TaintedInput (after logging it) if `values` carry connector
|
|
622
|
+
content or, for a non-own-words sink, the conversation is tainted."""
|
|
623
|
+
sid = session or active()
|
|
624
|
+
values = list(values)
|
|
625
|
+
try:
|
|
626
|
+
origin = _origin_of(values)
|
|
627
|
+
if origin:
|
|
628
|
+
_refuse(sink, "tagged", origin, sid)
|
|
629
|
+
texts = [t for t in _strings(values) if len(t) >= MIN_EXACT]
|
|
630
|
+
if texts:
|
|
631
|
+
ledgers = list(_loaded())
|
|
632
|
+
if sid:
|
|
633
|
+
ledgers.append(ledger(sid))
|
|
634
|
+
ledgers.append(ledger(BACKGROUND))
|
|
635
|
+
cands = [candidate_hashes(t) for t in texts]
|
|
636
|
+
for led in {id(x): x for x in ledgers}.values():
|
|
637
|
+
if led.hashes and any(not led.hashes.isdisjoint(c) for c in cands):
|
|
638
|
+
_refuse(sink, "shingle", led.first_origin(), sid)
|
|
639
|
+
if sid and not own_words:
|
|
640
|
+
led = ledger(sid)
|
|
641
|
+
if led.tainted:
|
|
642
|
+
_refuse(sink, "conversation", led.first_origin(), sid)
|
|
643
|
+
except TaintedInput:
|
|
644
|
+
raise
|
|
645
|
+
except Exception as e: # fail closed: an unchecked write could carry connector text
|
|
646
|
+
log.warning("taint check for %s could not run: %s", sink, e, exc_info=True)
|
|
647
|
+
_refuse(sink, "unavailable", "", sid)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def refuse_connector_source(sink: str, source_name: Optional[str]) -> None:
|
|
651
|
+
"""Raise TaintedInput when a writer is told its input came from a connector."""
|
|
652
|
+
name = str(source_name or "").strip().lower()
|
|
653
|
+
if name in CONNECTOR_SOURCES:
|
|
654
|
+
_refuse(sink, "tagged", name, active())
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _refuse(sink: str, reason: str, origin: str, sid: Optional[str]) -> None:
|
|
658
|
+
exc = TaintedInput(sink, reason, origin, sid or "")
|
|
659
|
+
log.warning("training sink %s refused connector content (%s, origin=%s, conversation=%s)",
|
|
660
|
+
sink, reason, origin or "-", sid or "-")
|
|
661
|
+
try:
|
|
662
|
+
from hyperclaw.tools.ledger import get_ledger
|
|
663
|
+
get_ledger().record(conversation=sid or "taint_guard", turn=None, surface="taint_guard",
|
|
664
|
+
tool=f"training_sink:{sink}", effect="write",
|
|
665
|
+
target=f"training_sink:{sink}", key=None, status="refused",
|
|
666
|
+
message=f"refused: connector content ({reason}, "
|
|
667
|
+
f"origin {origin or 'unknown'})")
|
|
668
|
+
except Exception as e:
|
|
669
|
+
log.warning("taint guard: could not write the activity log: %s", e)
|
|
670
|
+
raise exc
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def training_sink(name: str, *, own_words: bool = False,
|
|
674
|
+
session: Optional[Callable[[dict], Optional[str]]] = None,
|
|
675
|
+
own_words_unless: Optional[Callable[[dict], bool]] = None) -> Callable:
|
|
676
|
+
"""Register a writer to a training, corpus, eval, memory or
|
|
677
|
+
self-improvement store. Every call is checked before the writer runs.
|
|
678
|
+
|
|
679
|
+
own_words: a user-directed memory sink (see module docstring).
|
|
680
|
+
own_words_unless(bound_args): the call is NOT own-words (e.g. remember()
|
|
681
|
+
with source="auto_store").
|
|
682
|
+
session(bound_args): the conversation id when the writer names it in its
|
|
683
|
+
arguments (else the active conversation)."""
|
|
684
|
+
def deco(fn: Callable) -> Callable:
|
|
685
|
+
sig = inspect.signature(fn)
|
|
686
|
+
|
|
687
|
+
def _check(a, kw):
|
|
688
|
+
try:
|
|
689
|
+
bound = sig.bind_partial(*a, **kw).arguments
|
|
690
|
+
except TypeError:
|
|
691
|
+
bound = {"args": a, "kwargs": kw}
|
|
692
|
+
values = [v for k, v in bound.items() if k not in ("self", "cls")]
|
|
693
|
+
sid = session(bound) if session else None
|
|
694
|
+
ow = own_words and not (own_words_unless and own_words_unless(bound))
|
|
695
|
+
check(name, values, session=sid, own_words=ow)
|
|
696
|
+
|
|
697
|
+
if inspect.iscoroutinefunction(fn):
|
|
698
|
+
@functools.wraps(fn)
|
|
699
|
+
async def awrapper(*a, **kw):
|
|
700
|
+
_check(a, kw)
|
|
701
|
+
return await fn(*a, **kw)
|
|
702
|
+
wrapper = awrapper
|
|
703
|
+
else:
|
|
704
|
+
@functools.wraps(fn)
|
|
705
|
+
def swrapper(*a, **kw):
|
|
706
|
+
_check(a, kw)
|
|
707
|
+
return fn(*a, **kw)
|
|
708
|
+
wrapper = swrapper
|
|
709
|
+
wrapper.__training_sink__ = name
|
|
710
|
+
SINKS[name] = f"{fn.__module__}.{fn.__qualname__}"
|
|
711
|
+
return wrapper
|
|
712
|
+
return deco
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
# Model tools that write to a sink: refused before an approval card is shown.
|
|
716
|
+
TOOL_SINKS = {
|
|
717
|
+
"memory_store": ("memory.vector_memory.memory_store", ("content", "domain", "source"), True),
|
|
718
|
+
"learning_log": ("learning.log_interaction", ("action", "detail", "feedback"), False),
|
|
719
|
+
"learning_feedback": ("learning.log_feedback", ("feedback",), False),
|
|
720
|
+
"learning_reflect": ("learning.reflect", ("period",), False),
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def check_tool_call(tool: str, args: dict, session: Optional[str]) -> Optional[TaintedInput]:
|
|
725
|
+
"""The refusal for a sink tool call, or None. Never raises."""
|
|
726
|
+
spec = TOOL_SINKS.get(tool)
|
|
727
|
+
if spec is None:
|
|
728
|
+
return None
|
|
729
|
+
sink, fields, own = spec
|
|
730
|
+
try:
|
|
731
|
+
check(sink, [args.get(f) for f in fields if isinstance(args, dict)],
|
|
732
|
+
session=session, own_words=own)
|
|
733
|
+
except TaintedInput as e:
|
|
734
|
+
return e
|
|
735
|
+
return None
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
# ───────────────────────── memory: own words vs learned (per message) ─────────────────────────
|
|
739
|
+
|
|
740
|
+
# Memory tools whose save is decided per message (rc5): own words save
|
|
741
|
+
# without a prompt; anything learned waits for the person's tap.
|
|
742
|
+
MEMORY_TOOLS = frozenset({"memory_store"})
|
|
743
|
+
# Reads whose result is other people's words (pages, documents, feeds): a
|
|
744
|
+
# memory saved in the same message waits for a tap. Reading the person's own
|
|
745
|
+
# memory or files does not.
|
|
746
|
+
PAGE_TOOLS = frozenset({"web_search", "web_fetch", "wikipedia", "news", "reddit", "hackernews",
|
|
747
|
+
"arxiv", "youtube", "wayback", "pdf_read", "document_read",
|
|
748
|
+
"team_delegate", "gitlab_review_queue"})
|
|
749
|
+
# Share of the memory's words (3+ letters) that must appear in the person's
|
|
750
|
+
# own words for it to count as theirs.
|
|
751
|
+
OWN_WORDS_MIN_SHARE = 0.6
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _content_words(text: str) -> list:
|
|
755
|
+
return [w for w in normalize(text).split() if len(w) >= 3]
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def own_words_share(content: str, provenance: Iterable[str]) -> float:
|
|
759
|
+
"""How much of `content` is in the person's own words (0..1)."""
|
|
760
|
+
words = _content_words(content)
|
|
761
|
+
if not words:
|
|
762
|
+
return 0.0
|
|
763
|
+
said = set()
|
|
764
|
+
for p in provenance or ():
|
|
765
|
+
if isinstance(p, str):
|
|
766
|
+
said.update(_content_words(p))
|
|
767
|
+
return sum(1 for w in words if w in said) / len(words)
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _labels(origins: Iterable[str]) -> str:
|
|
771
|
+
names = sorted({LABELS.get(o, o.replace("_", " ")) for o in origins if o})
|
|
772
|
+
return ", ".join(names) or "a connected account"
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def memory_decision(content: str, *, session: Optional[str], message: Optional[str],
|
|
776
|
+
provenance: Iterable[str] = (), read_untrusted: bool = False
|
|
777
|
+
) -> Optional[str]:
|
|
778
|
+
"""None when a memory is the person's own words (save it without a
|
|
779
|
+
prompt). Otherwise the reason it needs the person's tap, naming the source.
|
|
780
|
+
|
|
781
|
+
Per message: what THIS message (turn) read decides, not everything the
|
|
782
|
+
conversation ever read. Verbatim connector text never gets here: the
|
|
783
|
+
sink check (rules 1-2) refuses it first."""
|
|
784
|
+
led = ledger(session) if session else None
|
|
785
|
+
here = led.message_origins(message) if led is not None else set()
|
|
786
|
+
if here:
|
|
787
|
+
return (f"it was learned in a message that read {_labels(here)}; check it is what you "
|
|
788
|
+
f"want kept")
|
|
789
|
+
if read_untrusted:
|
|
790
|
+
return "it was learned in a message that read a web page or document"
|
|
791
|
+
if own_words_share(content, provenance) >= OWN_WORDS_MIN_SHARE:
|
|
792
|
+
return None
|
|
793
|
+
if led is not None and led.origins:
|
|
794
|
+
return (f"it is not in your own words, and this conversation read "
|
|
795
|
+
f"{_labels(o for o, _ in led.origins)}")
|
|
796
|
+
return "it is not in your own words"
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
# ───────────────────────── session files ─────────────────────────
|
|
800
|
+
|
|
801
|
+
TAINT_KEY = "taint_origin"
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def tag_messages(messages: list) -> list:
|
|
805
|
+
"""Copies of `messages` with TAINT_KEY set on each message whose content
|
|
806
|
+
is connector content (for a session file)."""
|
|
807
|
+
out = []
|
|
808
|
+
for m in messages:
|
|
809
|
+
if isinstance(m, dict):
|
|
810
|
+
o = _origin_of(m.get("content"))
|
|
811
|
+
if o is None and isinstance(m.get("content"), str) and _matches_any(m["content"]):
|
|
812
|
+
o = "connector"
|
|
813
|
+
if o:
|
|
814
|
+
m = {**m, TAINT_KEY: o}
|
|
815
|
+
out.append(m)
|
|
816
|
+
return out
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def _matches_any(text: str) -> bool:
|
|
820
|
+
return any(led.matches(text) for led in _loaded())
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def restore_messages(session: str, messages: list) -> list:
|
|
824
|
+
"""After loading a session file: re-mark tagged messages (TaintedText +
|
|
825
|
+
ledger) so the reloaded conversation is flagged again, even on an
|
|
826
|
+
install whose taint store does not have it."""
|
|
827
|
+
out = []
|
|
828
|
+
for m in messages:
|
|
829
|
+
if isinstance(m, dict) and m.get(TAINT_KEY):
|
|
830
|
+
m = {**m, "content": mark(m.get("content"), str(m[TAINT_KEY]), session=session)}
|
|
831
|
+
out.append(m)
|
|
832
|
+
return out
|