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,724 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Effect identity for the tool standard wave 2 (S3 idempotency, S5 per-resource
|
|
3
|
+
budgets).
|
|
4
|
+
|
|
5
|
+
Three things live here, all pure functions (no I/O except reading files that
|
|
6
|
+
are about to be written, to hash them):
|
|
7
|
+
|
|
8
|
+
1. The **effect key** (S3). The runtime derives it; the model never supplies
|
|
9
|
+
one:
|
|
10
|
+
|
|
11
|
+
key = sha256(effect, canonical_target, canonical_payload)
|
|
12
|
+
|
|
13
|
+
`canonical_target` comes from the registry's `target_on` fields: the
|
|
14
|
+
resolved absolute path for files (family "file", so write_file, edit_file
|
|
15
|
+
and a bash redirect to the same path share it), the recipients for an
|
|
16
|
+
email, the channel for Slack, title + start for a calendar event, and so
|
|
17
|
+
on. `canonical_payload` comes from the remaining `idempotent_on` fields.
|
|
18
|
+
For a whole-file write the payload is the sha256 of the file content (one
|
|
19
|
+
trailing newline run ignored, so `echo x > f` and write_file("x") agree).
|
|
20
|
+
The key is the same digest the agent-mode broker calls `payloadDigest`.
|
|
21
|
+
|
|
22
|
+
2. **Bash file writes** (Q-T2). `bash_file_writes(command)` parses the simple
|
|
23
|
+
shell forms models use to write files: `>`/`>>` redirects (with echo,
|
|
24
|
+
printf, cat <<EOF), `tee [-a]`, `cp`/`mv` onto a path and `sed -i`. Each
|
|
25
|
+
becomes a FileWrite on the same "file:<abs path>" target as write_file.
|
|
26
|
+
Limits (documented in docs/TOOL-STANDARD.md): anything with variable or
|
|
27
|
+
command expansion in the target, `dd of=`, `install`, `rsync`, writes made
|
|
28
|
+
by scripts or programs the command runs (python -c, node -e, git ...),
|
|
29
|
+
`cd` changing the base directory, and subshell/eval tricks are not seen.
|
|
30
|
+
|
|
31
|
+
3. **Resource identity** (S5). `resources(tool, args)` lists the things a
|
|
32
|
+
call touches: paths, URLs, message/event/plan/memory ids, and for bash the
|
|
33
|
+
path-like words of the command plus its text. `touches(r, missing)`
|
|
34
|
+
decides whether a call is still going after a resource that was just
|
|
35
|
+
reported not_found: the same path; the same basename anywhere under a home
|
|
36
|
+
directory (so an invented `/Users/<someone>/...` copy counts); a directory
|
|
37
|
+
that contains it below home (listing the folder to look for it); or, for
|
|
38
|
+
bash, the basename in the command text.
|
|
39
|
+
"""
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import hashlib
|
|
43
|
+
import json
|
|
44
|
+
import os
|
|
45
|
+
import re
|
|
46
|
+
from dataclasses import dataclass
|
|
47
|
+
from typing import Any, Iterable, List, Optional, Sequence, Tuple
|
|
48
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
49
|
+
|
|
50
|
+
# Arguments whose value is a local path (resolved to an absolute path).
|
|
51
|
+
PATH_FIELDS = ("path", "image_path", "file", "save_path", "attachments")
|
|
52
|
+
# Arguments that identify a resource for the per-resource budget.
|
|
53
|
+
RESOURCE_PATH_FIELDS = ("path", "image_path", "file", "attachments")
|
|
54
|
+
RESOURCE_ID_FIELDS = ("message_id", "event_id", "plan_id", "memory_id")
|
|
55
|
+
# Tools whose target is a whole file's content.
|
|
56
|
+
FILE_CONTENT_TOOLS = {"write_file": "content"}
|
|
57
|
+
# Tools that create the path they name; a not_found on that path earlier in
|
|
58
|
+
# the turn does not block them (creating it is not searching for it). Their
|
|
59
|
+
# other resources (a download URL) are still checked.
|
|
60
|
+
CREATE_TOOLS = frozenset({"write_file", "download", "qr_code", "image_generate"})
|
|
61
|
+
_RECIPIENT_FIELDS = ("to", "cc", "bcc", "recipient")
|
|
62
|
+
MAX_HASH_BYTES = 50 * 1024 * 1024
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ───────────────────────── canonical forms ─────────────────────────
|
|
66
|
+
|
|
67
|
+
def home() -> str:
|
|
68
|
+
return os.path.realpath(os.path.expanduser("~"))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def resolve_path(p: Any, base: Optional[str] = None) -> Optional[str]:
|
|
72
|
+
"""Absolute, normalised, symlink-resolved path; None if not a usable path."""
|
|
73
|
+
if not isinstance(p, str) or not p.strip():
|
|
74
|
+
return None
|
|
75
|
+
p = p.strip()
|
|
76
|
+
for var in ("${HOME}", "$HOME"):
|
|
77
|
+
if p == var or p.startswith(var + "/"):
|
|
78
|
+
p = "~" + p[len(var):]
|
|
79
|
+
if "$" in p or "`" in p:
|
|
80
|
+
return None
|
|
81
|
+
p = os.path.expanduser(p)
|
|
82
|
+
if not os.path.isabs(p):
|
|
83
|
+
p = os.path.join(base or home(), p)
|
|
84
|
+
return os.path.realpath(os.path.normpath(p))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def normalize_url(u: Any) -> Optional[str]:
|
|
88
|
+
if not isinstance(u, str) or not u.strip():
|
|
89
|
+
return None
|
|
90
|
+
try:
|
|
91
|
+
s = urlsplit(u.strip())
|
|
92
|
+
except ValueError:
|
|
93
|
+
return u.strip()
|
|
94
|
+
path = s.path.rstrip("/") or ""
|
|
95
|
+
return urlunsplit((s.scheme.lower(), s.netloc.lower(), path, s.query, ""))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _canon_value(field: str, v: Any) -> Any:
|
|
99
|
+
if field in _RECIPIENT_FIELDS:
|
|
100
|
+
items = v if isinstance(v, (list, tuple)) else re.split(r"[,;]", str(v or ""))
|
|
101
|
+
return sorted({str(x).strip().lower() for x in items if str(x).strip()})
|
|
102
|
+
if field in PATH_FIELDS:
|
|
103
|
+
if isinstance(v, (list, tuple)):
|
|
104
|
+
return sorted(filter(None, (resolve_path(x) for x in v)))
|
|
105
|
+
return resolve_path(v) or v
|
|
106
|
+
if field == "url":
|
|
107
|
+
return normalize_url(v)
|
|
108
|
+
if field == "method" and isinstance(v, str):
|
|
109
|
+
return v.strip().upper()
|
|
110
|
+
if isinstance(v, str):
|
|
111
|
+
return v.strip()
|
|
112
|
+
return v
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _present(v: Any) -> bool:
|
|
116
|
+
return v not in (None, "", [], {}, ())
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _dumps(obj: Any) -> str:
|
|
120
|
+
return json.dumps(obj, sort_keys=True, ensure_ascii=False, separators=(",", ":"), default=str)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def sha256(text: str) -> str:
|
|
124
|
+
return hashlib.sha256(text.encode("utf-8", "surrogateescape")).hexdigest()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def content_hash(content: str) -> str:
|
|
128
|
+
"""Hash of file content as written: one trailing run of newlines ignored."""
|
|
129
|
+
data = content.encode("utf-8", "surrogateescape") if isinstance(content, str) else content
|
|
130
|
+
return hashlib.sha256(data.rstrip(b"\n")).hexdigest()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def file_hash(path: Optional[str]) -> Optional[str]:
|
|
134
|
+
"""content_hash of the file on disk now; None if missing/unreadable/huge."""
|
|
135
|
+
if not path:
|
|
136
|
+
return None
|
|
137
|
+
try:
|
|
138
|
+
if not os.path.isfile(path) or os.path.getsize(path) > MAX_HASH_BYTES:
|
|
139
|
+
return None
|
|
140
|
+
with open(path, "rb") as fh:
|
|
141
|
+
return hashlib.sha256(fh.read().rstrip(b"\n")).hexdigest()
|
|
142
|
+
except OSError:
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def read_text(path: Optional[str]) -> Optional[str]:
|
|
147
|
+
try:
|
|
148
|
+
if path and os.path.isfile(path) and os.path.getsize(path) <= MAX_HASH_BYTES:
|
|
149
|
+
with open(path, "rb") as fh:
|
|
150
|
+
return fh.read().decode("utf-8", "surrogateescape")
|
|
151
|
+
except OSError:
|
|
152
|
+
pass
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def file_target(path: str) -> str:
|
|
157
|
+
return f"file:{path}"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def make_key(effect: str, target: str, payload: str) -> str:
|
|
161
|
+
return sha256(f"{effect}\x1f{target}\x1f{payload}")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ───────────────────────── effective effect ─────────────────────────
|
|
165
|
+
|
|
166
|
+
_SQL_READ = re.compile(r"^\s*(select|with|pragma|explain|show|describe)\b", re.I)
|
|
167
|
+
_GITHUB_READ = {"repos", "issues", "prs", "search_code", "user_info"}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def effective_effect(spec, args: dict) -> str:
|
|
171
|
+
"""The registry effect, narrowed by payload for the few tools whose
|
|
172
|
+
effect depends on it (a GET is a read, not a send)."""
|
|
173
|
+
name, effect = spec.name, spec.effect
|
|
174
|
+
if name == "http_request" and str(args.get("method") or "GET").upper() in ("GET", "HEAD",
|
|
175
|
+
"OPTIONS"):
|
|
176
|
+
return "read"
|
|
177
|
+
if name == "sql_query" and _SQL_READ.match(str(args.get("query") or "")):
|
|
178
|
+
return "read"
|
|
179
|
+
if name == "github" and str(args.get("action") or "") in _GITHUB_READ:
|
|
180
|
+
return "read"
|
|
181
|
+
return effect
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ───────────────────────── effect identity for a tool call ─────────────────────────
|
|
185
|
+
|
|
186
|
+
@dataclass(frozen=True)
|
|
187
|
+
class Effect:
|
|
188
|
+
tool: str
|
|
189
|
+
effect: str # write / send / spend / delete / system
|
|
190
|
+
target: str # canonical target
|
|
191
|
+
payload: str # canonical payload (string form)
|
|
192
|
+
key: Optional[str] # None: not idempotent (recorded for the activity log only)
|
|
193
|
+
file_path: Optional[str] = None # set for file-family targets
|
|
194
|
+
expected_post_hash: Optional[str] = None # file content hash after a whole-file write
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def payload_hash(self) -> str:
|
|
198
|
+
return sha256(self.payload)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def call_effect(spec, args: dict) -> Optional[Effect]:
|
|
202
|
+
"""Effect identity for one non-read tool call; None for reads."""
|
|
203
|
+
effect = effective_effect(spec, args)
|
|
204
|
+
if effect == "read":
|
|
205
|
+
return None
|
|
206
|
+
idem = tuple(getattr(spec, "idempotent_on", ()) or ())
|
|
207
|
+
target_on = tuple(getattr(spec, "target_on", ()) or ())
|
|
208
|
+
name = spec.name
|
|
209
|
+
|
|
210
|
+
file_path = None
|
|
211
|
+
if target_on and all(f in PATH_FIELDS for f in target_on) and len(target_on) == 1:
|
|
212
|
+
file_path = resolve_path(args.get(target_on[0]))
|
|
213
|
+
if file_path:
|
|
214
|
+
target = file_target(file_path)
|
|
215
|
+
elif target_on:
|
|
216
|
+
parts = {f: _canon_value(f, args.get(f)) for f in target_on if _present(args.get(f))}
|
|
217
|
+
target = f"{name}:{_dumps(parts)}" if parts else name
|
|
218
|
+
else:
|
|
219
|
+
target = name
|
|
220
|
+
|
|
221
|
+
if name in FILE_CONTENT_TOOLS and file_path:
|
|
222
|
+
content = args.get(FILE_CONTENT_TOOLS[name])
|
|
223
|
+
content = content if isinstance(content, str) else str(content or "")
|
|
224
|
+
h = content_hash(content)
|
|
225
|
+
payload = f"content:{h}"
|
|
226
|
+
return Effect(name, effect, target, payload, make_key("write", target, payload),
|
|
227
|
+
file_path=file_path, expected_post_hash=h)
|
|
228
|
+
|
|
229
|
+
payload_fields = [f for f in idem if f not in target_on]
|
|
230
|
+
payload_obj = {f: _canon_value(f, args.get(f)) for f in payload_fields if _present(args.get(f))}
|
|
231
|
+
payload = _dumps(payload_obj)
|
|
232
|
+
key = make_key(effect, target, payload) if idem else None
|
|
233
|
+
return Effect(name, effect, target, payload, key, file_path=file_path)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def summarize_args(args: Any, limit: int = 400) -> str:
|
|
237
|
+
try:
|
|
238
|
+
s = _dumps(args)
|
|
239
|
+
except Exception:
|
|
240
|
+
s = repr(args)
|
|
241
|
+
return s if len(s) <= limit else s[:limit] + "..."
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ───────────────────────── bash: tokenizer ─────────────────────────
|
|
245
|
+
|
|
246
|
+
@dataclass
|
|
247
|
+
class Tok:
|
|
248
|
+
kind: str # "word" | "op"
|
|
249
|
+
value: str
|
|
250
|
+
expansion: bool = False # contains unresolved $var / `cmd` / glob
|
|
251
|
+
quoted: bool = False
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
_OPS = ("&&", "||", ";;", ">>", "&>>", "&>", ">|", ">&", "<<-", "<<", "<&", "|&",
|
|
255
|
+
";", "|", "&", "<", ">", "(", ")", "\n")
|
|
256
|
+
_SEPARATORS = {"&&", "||", ";", ";;", "&", "\n", "(", ")", "|", "|&"}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def tokenize(cmd: str) -> List[Tok]:
|
|
260
|
+
"""A small POSIX-ish shell tokenizer: quotes, escapes, operators, fd
|
|
261
|
+
redirects (2>, 1>>) and here-documents (body attached to the `<<` op)."""
|
|
262
|
+
toks: List[Tok] = []
|
|
263
|
+
i, n = 0, len(cmd)
|
|
264
|
+
word: List[str] = []
|
|
265
|
+
in_word = False
|
|
266
|
+
expansion = quoted = False
|
|
267
|
+
pending_heredocs: List[Tuple[Tok, str, bool]] = []
|
|
268
|
+
h = home()
|
|
269
|
+
|
|
270
|
+
def end_word():
|
|
271
|
+
nonlocal word, in_word, expansion, quoted
|
|
272
|
+
if in_word:
|
|
273
|
+
toks.append(Tok("word", "".join(word), expansion, quoted))
|
|
274
|
+
word, in_word, expansion, quoted = [], False, False, False
|
|
275
|
+
|
|
276
|
+
def dollar(j: int) -> Tuple[Optional[str], int]:
|
|
277
|
+
for var in ("${HOME}", "$HOME"):
|
|
278
|
+
if cmd.startswith(var, j):
|
|
279
|
+
k = j + len(var)
|
|
280
|
+
if var == "$HOME" and k < n and (cmd[k].isalnum() or cmd[k] == "_"):
|
|
281
|
+
break
|
|
282
|
+
return h, k
|
|
283
|
+
return None, j
|
|
284
|
+
|
|
285
|
+
while i < n:
|
|
286
|
+
c = cmd[i]
|
|
287
|
+
if c == "\\" and i + 1 < n:
|
|
288
|
+
if cmd[i + 1] == "\n":
|
|
289
|
+
i += 2
|
|
290
|
+
continue
|
|
291
|
+
word.append(cmd[i + 1]); in_word = True; i += 2
|
|
292
|
+
continue
|
|
293
|
+
if c == "'":
|
|
294
|
+
j = cmd.find("'", i + 1)
|
|
295
|
+
j = n if j < 0 else j
|
|
296
|
+
word.append(cmd[i + 1:j]); in_word = True; quoted = True; i = j + 1
|
|
297
|
+
continue
|
|
298
|
+
if c == '"':
|
|
299
|
+
in_word = True; quoted = True; i += 1
|
|
300
|
+
while i < n and cmd[i] != '"':
|
|
301
|
+
if cmd[i] == "\\" and i + 1 < n and cmd[i + 1] in '"\\$`\n':
|
|
302
|
+
word.append(cmd[i + 1]); i += 2
|
|
303
|
+
continue
|
|
304
|
+
if cmd[i] == "$":
|
|
305
|
+
val, k = dollar(i)
|
|
306
|
+
if val is not None:
|
|
307
|
+
word.append(val); i = k
|
|
308
|
+
continue
|
|
309
|
+
expansion = True
|
|
310
|
+
if cmd[i] == "`":
|
|
311
|
+
expansion = True
|
|
312
|
+
word.append(cmd[i]); i += 1
|
|
313
|
+
i += 1
|
|
314
|
+
continue
|
|
315
|
+
if c == "$":
|
|
316
|
+
val, k = dollar(i)
|
|
317
|
+
if val is not None:
|
|
318
|
+
word.append(val); in_word = True; i = k
|
|
319
|
+
continue
|
|
320
|
+
expansion = True; word.append(c); in_word = True; i += 1
|
|
321
|
+
continue
|
|
322
|
+
if c in "`*?[":
|
|
323
|
+
expansion = True; word.append(c); in_word = True; i += 1
|
|
324
|
+
continue
|
|
325
|
+
if c == "~" and not in_word:
|
|
326
|
+
word.append(c); in_word = True; i += 1
|
|
327
|
+
continue
|
|
328
|
+
if c == "#" and not in_word:
|
|
329
|
+
j = cmd.find("\n", i)
|
|
330
|
+
i = n if j < 0 else j
|
|
331
|
+
continue
|
|
332
|
+
if c in " \t":
|
|
333
|
+
end_word(); i += 1
|
|
334
|
+
continue
|
|
335
|
+
op = next((o for o in _OPS if cmd.startswith(o, i)), None)
|
|
336
|
+
if op:
|
|
337
|
+
# "2>" / "1>>": a digit-only word glued to a redirect is an fd.
|
|
338
|
+
if op[0] in "<>" and in_word and not quoted and "".join(word).isdigit():
|
|
339
|
+
fd = "".join(word)
|
|
340
|
+
word, in_word = [], False
|
|
341
|
+
toks.append(Tok("op", fd + op))
|
|
342
|
+
else:
|
|
343
|
+
end_word()
|
|
344
|
+
toks.append(Tok("op", op))
|
|
345
|
+
i += len(op)
|
|
346
|
+
if op in ("<<", "<<-"):
|
|
347
|
+
# Read the delimiter word now; the body follows the next newline.
|
|
348
|
+
while i < n and cmd[i] in " \t":
|
|
349
|
+
i += 1
|
|
350
|
+
j = i
|
|
351
|
+
while j < n and cmd[j] not in " \t\n;|&<>()":
|
|
352
|
+
j += 1
|
|
353
|
+
raw = cmd[i:j]
|
|
354
|
+
delim = raw.strip("'\"").replace("\\", "")
|
|
355
|
+
pending_heredocs.append((toks[-1], delim, raw != delim))
|
|
356
|
+
i = j
|
|
357
|
+
if op == "\n" and pending_heredocs:
|
|
358
|
+
for htok, delim, lit in pending_heredocs:
|
|
359
|
+
body_lines = []
|
|
360
|
+
while i < n:
|
|
361
|
+
j = cmd.find("\n", i)
|
|
362
|
+
line = cmd[i:] if j < 0 else cmd[i:j]
|
|
363
|
+
i = n if j < 0 else j + 1
|
|
364
|
+
if (line.lstrip("\t") if htok.value == "<<-" else line) == delim:
|
|
365
|
+
break
|
|
366
|
+
body_lines.append(line)
|
|
367
|
+
body = "".join(l + "\n" for l in body_lines)
|
|
368
|
+
htok.value = htok.value + "\x00" + body
|
|
369
|
+
htok.expansion = (not lit) and ("$" in body or "`" in body)
|
|
370
|
+
pending_heredocs = []
|
|
371
|
+
continue
|
|
372
|
+
word.append(c); in_word = True; i += 1
|
|
373
|
+
end_word()
|
|
374
|
+
return toks
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
# ───────────────────────── bash: file writes ─────────────────────────
|
|
378
|
+
|
|
379
|
+
@dataclass
|
|
380
|
+
class FileWrite:
|
|
381
|
+
path: str # resolved absolute path
|
|
382
|
+
mode: str # "w" (replace) or "a" (append)
|
|
383
|
+
content: Optional[str] # known content written (None: unknown)
|
|
384
|
+
via: str # ">", ">>", "tee", "cp", "mv", "sed -i", ...
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
@dataclass
|
|
388
|
+
class _Seg:
|
|
389
|
+
words: List[Tok]
|
|
390
|
+
redirects: List[Tuple[str, Tok]]
|
|
391
|
+
heredoc: Optional[Tok]
|
|
392
|
+
piped_from_prev: bool
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _segments(toks: Sequence[Tok]) -> List[_Seg]:
|
|
396
|
+
segs: List[_Seg] = []
|
|
397
|
+
cur = _Seg([], [], None, False)
|
|
398
|
+
i = 0
|
|
399
|
+
while i < len(toks):
|
|
400
|
+
t = toks[i]
|
|
401
|
+
if t.kind == "op" and t.value in _SEPARATORS:
|
|
402
|
+
segs.append(cur)
|
|
403
|
+
cur = _Seg([], [], None, t.value in ("|", "|&"))
|
|
404
|
+
i += 1
|
|
405
|
+
continue
|
|
406
|
+
if t.kind == "op" and t.value.split("\x00")[0] in ("<<", "<<-"):
|
|
407
|
+
cur.heredoc = t
|
|
408
|
+
i += 1
|
|
409
|
+
continue
|
|
410
|
+
if t.kind == "op" and t.value.lstrip("0123456789") in (">", ">>", ">|", "&>", "&>>",
|
|
411
|
+
"<", ">&", "<&"):
|
|
412
|
+
nxt = toks[i + 1] if i + 1 < len(toks) and toks[i + 1].kind == "word" else None
|
|
413
|
+
cur.redirects.append((t.value, nxt))
|
|
414
|
+
i += 2 if nxt else 1
|
|
415
|
+
continue
|
|
416
|
+
if t.kind == "word":
|
|
417
|
+
cur.words.append(t)
|
|
418
|
+
i += 1
|
|
419
|
+
segs.append(cur)
|
|
420
|
+
return [s for s in segs if s.words or s.redirects]
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
_PRINTF_ESC = {"n": "\n", "t": "\t", "\\": "\\", "r": "\r", "'": "'", '"': '"'}
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _printf_unescape(s: str) -> Optional[str]:
|
|
427
|
+
out, i = [], 0
|
|
428
|
+
while i < len(s):
|
|
429
|
+
c = s[i]
|
|
430
|
+
if c == "\\" and i + 1 < len(s):
|
|
431
|
+
e = _PRINTF_ESC.get(s[i + 1])
|
|
432
|
+
if e is None:
|
|
433
|
+
return None
|
|
434
|
+
out.append(e); i += 2
|
|
435
|
+
continue
|
|
436
|
+
out.append(c); i += 1
|
|
437
|
+
return "".join(out)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _printf(args: List[str]) -> Optional[str]:
|
|
441
|
+
if not args:
|
|
442
|
+
return None
|
|
443
|
+
fmt = _printf_unescape(args[0])
|
|
444
|
+
if fmt is None:
|
|
445
|
+
return None
|
|
446
|
+
pieces = re.split(r"(%%|%s|%[^%s]?)", fmt)
|
|
447
|
+
spec_count = sum(1 for p in pieces if p == "%s")
|
|
448
|
+
if any(p.startswith("%") and p not in ("%%", "%s") for p in pieces if p):
|
|
449
|
+
return None
|
|
450
|
+
rest = args[1:]
|
|
451
|
+
if spec_count == 0:
|
|
452
|
+
return fmt.replace("%%", "%")
|
|
453
|
+
out = []
|
|
454
|
+
groups = max(1, -(-len(rest) // spec_count))
|
|
455
|
+
for g in range(groups):
|
|
456
|
+
vals = rest[g * spec_count:(g + 1) * spec_count]
|
|
457
|
+
k = 0
|
|
458
|
+
for p in pieces:
|
|
459
|
+
if p == "%s":
|
|
460
|
+
out.append(vals[k] if k < len(vals) else ""); k += 1
|
|
461
|
+
elif p == "%%":
|
|
462
|
+
out.append("%")
|
|
463
|
+
else:
|
|
464
|
+
out.append(p)
|
|
465
|
+
return "".join(out)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _echo(args: List[str]) -> Optional[str]:
|
|
469
|
+
newline, escapes = True, False
|
|
470
|
+
while args and re.fullmatch(r"-[neE]+", args[0]):
|
|
471
|
+
newline = newline and "n" not in args[0]
|
|
472
|
+
escapes = escapes or "e" in args[0]
|
|
473
|
+
args = args[1:]
|
|
474
|
+
text = " ".join(args)
|
|
475
|
+
if escapes and "\\" in text:
|
|
476
|
+
text = _printf_unescape(text)
|
|
477
|
+
if text is None:
|
|
478
|
+
return None
|
|
479
|
+
return text + ("\n" if newline else "")
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _stdout(seg: _Seg, stdin: Optional[str], base: str) -> Optional[str]:
|
|
483
|
+
"""Known stdout of a simple command, or None."""
|
|
484
|
+
if not seg.words or any(w.expansion for w in seg.words):
|
|
485
|
+
return None
|
|
486
|
+
cmd, args = seg.words[0].value, [w.value for w in seg.words[1:]]
|
|
487
|
+
if cmd == "echo":
|
|
488
|
+
return _echo(args)
|
|
489
|
+
if cmd == "printf":
|
|
490
|
+
return _printf(args)
|
|
491
|
+
if cmd == "cat":
|
|
492
|
+
files = [a for a in args if not a.startswith("-") or a == "-"]
|
|
493
|
+
if not files:
|
|
494
|
+
if seg.heredoc is not None:
|
|
495
|
+
if seg.heredoc.expansion:
|
|
496
|
+
return None
|
|
497
|
+
return seg.heredoc.value.split("\x00", 1)[1] if "\x00" in seg.heredoc.value else None
|
|
498
|
+
return stdin
|
|
499
|
+
texts = [read_text(resolve_path(f, base)) for f in files]
|
|
500
|
+
return None if any(t is None for t in texts) else "".join(texts)
|
|
501
|
+
return None
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def bash_file_writes(command: str, base: Optional[str] = None) -> List[FileWrite]:
|
|
505
|
+
"""File writes a bash command will make, as far as simple parsing can tell."""
|
|
506
|
+
if not isinstance(command, str) or not command.strip():
|
|
507
|
+
return []
|
|
508
|
+
base = base or home()
|
|
509
|
+
try:
|
|
510
|
+
segs = _segments(tokenize(command))
|
|
511
|
+
except Exception: # never let a parser bug block a command
|
|
512
|
+
return []
|
|
513
|
+
writes: List[FileWrite] = []
|
|
514
|
+
prev_out: Optional[str] = None
|
|
515
|
+
for seg in segs:
|
|
516
|
+
stdin = prev_out if seg.piped_from_prev else None
|
|
517
|
+
out = _stdout(seg, stdin, base)
|
|
518
|
+
for op, target in seg.redirects:
|
|
519
|
+
if target is None or target.expansion:
|
|
520
|
+
continue
|
|
521
|
+
core = op.lstrip("0123456789")
|
|
522
|
+
fd = op[: len(op) - len(core)] or "1"
|
|
523
|
+
if core in ("<", "<&", ">&"):
|
|
524
|
+
continue
|
|
525
|
+
path = resolve_path(target.value, base)
|
|
526
|
+
if not path or path.startswith("/dev/"):
|
|
527
|
+
continue
|
|
528
|
+
mode = "a" if core in (">>", "&>>") else "w"
|
|
529
|
+
known = out if (fd == "1" and core in (">", ">>", ">|")) else None
|
|
530
|
+
if fd == "2":
|
|
531
|
+
known = None
|
|
532
|
+
writes.append(FileWrite(path, mode, known, op))
|
|
533
|
+
words = [w for w in seg.words]
|
|
534
|
+
if words and not any(w.expansion for w in words):
|
|
535
|
+
cmd = words[0].value
|
|
536
|
+
args = [w.value for w in words[1:]]
|
|
537
|
+
if cmd == "tee":
|
|
538
|
+
append = any(a in ("-a", "--append") for a in args)
|
|
539
|
+
for a in args:
|
|
540
|
+
if a.startswith("-"):
|
|
541
|
+
continue
|
|
542
|
+
p = resolve_path(a, base)
|
|
543
|
+
if p and not p.startswith("/dev/"):
|
|
544
|
+
writes.append(FileWrite(p, "a" if append else "w", stdin, "tee"))
|
|
545
|
+
elif cmd in ("cp", "mv"):
|
|
546
|
+
operands = [a for a in args if not a.startswith("-")]
|
|
547
|
+
if "-t" in args or "--target-directory" in args or len(operands) < 2:
|
|
548
|
+
pass
|
|
549
|
+
else:
|
|
550
|
+
dest = resolve_path(operands[-1], base)
|
|
551
|
+
srcs = [resolve_path(s, base) for s in operands[:-1]]
|
|
552
|
+
if dest and all(srcs):
|
|
553
|
+
into_dir = len(srcs) > 1 or os.path.isdir(dest)
|
|
554
|
+
for s in srcs:
|
|
555
|
+
d = os.path.join(dest, os.path.basename(s)) if into_dir else dest
|
|
556
|
+
writes.append(FileWrite(d, "w", read_text(s), cmd))
|
|
557
|
+
elif cmd == "sed":
|
|
558
|
+
inplace, i, script_seen = False, 0, False
|
|
559
|
+
targets = []
|
|
560
|
+
while i < len(args):
|
|
561
|
+
a = args[i]
|
|
562
|
+
if a == "-i" or a.startswith("-i") or a.startswith("--in-place"):
|
|
563
|
+
inplace = True
|
|
564
|
+
# BSD sed: `-i ''` / `-i .bak` takes a suffix argument.
|
|
565
|
+
if a == "-i" and i + 1 < len(args) and (args[i + 1] == ""
|
|
566
|
+
or args[i + 1].startswith(".")) \
|
|
567
|
+
and i + 2 < len(args):
|
|
568
|
+
i += 1
|
|
569
|
+
elif a in ("-e", "-f", "--expression", "--file"):
|
|
570
|
+
script_seen = True
|
|
571
|
+
i += 1
|
|
572
|
+
elif a.startswith("-"):
|
|
573
|
+
pass
|
|
574
|
+
elif not script_seen:
|
|
575
|
+
script_seen = True
|
|
576
|
+
else:
|
|
577
|
+
targets.append(a)
|
|
578
|
+
i += 1
|
|
579
|
+
if inplace:
|
|
580
|
+
for a in targets:
|
|
581
|
+
p = resolve_path(a, base)
|
|
582
|
+
if p:
|
|
583
|
+
writes.append(FileWrite(p, "w", None, "sed -i"))
|
|
584
|
+
prev_out = out
|
|
585
|
+
return writes
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def planned_file_effects(writes: Iterable[FileWrite]) -> List[Tuple[str, Optional[Effect]]]:
|
|
589
|
+
"""Per path: the effect we can key BEFORE running (None if unknown).
|
|
590
|
+
|
|
591
|
+
A path written once with known content in replace mode keys on the final
|
|
592
|
+
content (same key as write_file). A path only appended to with known
|
|
593
|
+
content keys on the appended text. Anything else is unknown until it ran.
|
|
594
|
+
"""
|
|
595
|
+
by_path: dict = {}
|
|
596
|
+
for w in writes:
|
|
597
|
+
by_path.setdefault(w.path, []).append(w)
|
|
598
|
+
out = []
|
|
599
|
+
for path, ws in by_path.items():
|
|
600
|
+
target = file_target(path)
|
|
601
|
+
eff = None
|
|
602
|
+
if len(ws) == 1 and ws[0].mode == "w" and ws[0].content is not None:
|
|
603
|
+
h = content_hash(ws[0].content)
|
|
604
|
+
payload = f"content:{h}"
|
|
605
|
+
eff = Effect("bash", "write", target, payload, make_key("write", target, payload),
|
|
606
|
+
file_path=path, expected_post_hash=h)
|
|
607
|
+
elif all(w.mode == "a" and w.content is not None for w in ws):
|
|
608
|
+
appended = "".join(w.content for w in ws)
|
|
609
|
+
payload = f"append:{content_hash(appended)}"
|
|
610
|
+
eff = Effect("bash", "write", target, payload, make_key("write", target, payload),
|
|
611
|
+
file_path=path)
|
|
612
|
+
out.append((path, eff))
|
|
613
|
+
return out
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def recorded_file_effect(path: str, writes: Sequence[FileWrite]) -> Optional[Effect]:
|
|
617
|
+
"""Per path, AFTER running: the effect to record, keyed on what is on disk
|
|
618
|
+
(appends with known text keep the append key)."""
|
|
619
|
+
target = file_target(path)
|
|
620
|
+
ws = [w for w in writes if w.path == path]
|
|
621
|
+
if ws and all(w.mode == "a" and w.content is not None for w in ws):
|
|
622
|
+
payload = f"append:{content_hash(''.join(w.content for w in ws))}"
|
|
623
|
+
return Effect("bash", "write", target, payload, make_key("write", target, payload),
|
|
624
|
+
file_path=path)
|
|
625
|
+
h = file_hash(path)
|
|
626
|
+
if h is None:
|
|
627
|
+
return None
|
|
628
|
+
payload = f"content:{h}"
|
|
629
|
+
return Effect("bash", "write", target, payload, make_key("write", target, payload),
|
|
630
|
+
file_path=path, expected_post_hash=h)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
# ───────────────────────── resources (S5) ─────────────────────────
|
|
634
|
+
|
|
635
|
+
@dataclass(frozen=True)
|
|
636
|
+
class Resource:
|
|
637
|
+
kind: str # "path" | "url" | "<field>" (ids) | "text" (bash command)
|
|
638
|
+
value: str
|
|
639
|
+
|
|
640
|
+
def __str__(self) -> str:
|
|
641
|
+
return f"{self.kind}:{self.value}"
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
_HOME_ROOT = re.compile(r"^/(?:Users|home)/[^/]+(?=/|$)|^/root(?=/|$)")
|
|
645
|
+
_PATHISH = re.compile(r"^(?:~|\.{1,2}/|/)|/|\.[A-Za-z0-9]{1,8}$")
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def home_tail(path: str) -> Optional[str]:
|
|
649
|
+
"""Path relative to a home directory (the real one or any /Users/<x>,
|
|
650
|
+
/home/<x>, /root); None when it is not under a home."""
|
|
651
|
+
h = home()
|
|
652
|
+
if path == h or path.startswith(h + "/"):
|
|
653
|
+
return path[len(h):].lstrip("/")
|
|
654
|
+
m = _HOME_ROOT.match(path)
|
|
655
|
+
if m:
|
|
656
|
+
return path[m.end():].lstrip("/")
|
|
657
|
+
# macOS realpath of /tmp etc. is not a home; a sandboxed HOME may live
|
|
658
|
+
# under /private/var/... and is covered by the first branch.
|
|
659
|
+
return None
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def resources(spec_name: str, args: dict, base: Optional[str] = None) -> List[Resource]:
|
|
663
|
+
out: List[Resource] = []
|
|
664
|
+
if not isinstance(args, dict):
|
|
665
|
+
return out
|
|
666
|
+
if spec_name == "bash":
|
|
667
|
+
cmd = args.get("command")
|
|
668
|
+
if isinstance(cmd, str) and cmd.strip():
|
|
669
|
+
out.append(Resource("text", cmd))
|
|
670
|
+
try:
|
|
671
|
+
toks = tokenize(cmd)
|
|
672
|
+
except Exception:
|
|
673
|
+
toks = []
|
|
674
|
+
for t in toks:
|
|
675
|
+
if t.kind == "word" and not t.expansion and _PATHISH.search(t.value) \
|
|
676
|
+
and not t.value.startswith("-") and "://" not in t.value:
|
|
677
|
+
p = resolve_path(t.value, base)
|
|
678
|
+
if p:
|
|
679
|
+
out.append(Resource("path", p))
|
|
680
|
+
return out
|
|
681
|
+
for f in RESOURCE_PATH_FIELDS:
|
|
682
|
+
v = args.get(f)
|
|
683
|
+
for item in (v if isinstance(v, (list, tuple)) else [v]):
|
|
684
|
+
p = resolve_path(item, base) if isinstance(item, str) else None
|
|
685
|
+
if p:
|
|
686
|
+
out.append(Resource("path", p))
|
|
687
|
+
u = normalize_url(args.get("url"))
|
|
688
|
+
if u:
|
|
689
|
+
out.append(Resource("url", u))
|
|
690
|
+
for f in RESOURCE_ID_FIELDS:
|
|
691
|
+
v = args.get(f)
|
|
692
|
+
if _present(v):
|
|
693
|
+
out.append(Resource(f, str(v).strip()))
|
|
694
|
+
return out
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _stem(name: str) -> str:
|
|
698
|
+
return os.path.splitext(name)[0]
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def touches(r: Resource, missing: Resource) -> bool:
|
|
702
|
+
"""Is call resource `r` still about the not-found resource `missing`?"""
|
|
703
|
+
if missing.kind == "path":
|
|
704
|
+
m = missing.value
|
|
705
|
+
mbase = os.path.basename(m)
|
|
706
|
+
if r.kind == "text":
|
|
707
|
+
stem = _stem(mbase)
|
|
708
|
+
return bool(mbase) and (mbase in r.value or (len(stem) >= 6 and stem in r.value))
|
|
709
|
+
if r.kind != "path":
|
|
710
|
+
return False
|
|
711
|
+
p = r.value
|
|
712
|
+
if p == m:
|
|
713
|
+
return True
|
|
714
|
+
if mbase and os.path.basename(p) == mbase and home_tail(p) is not None:
|
|
715
|
+
return True # same file name anywhere under a home (invented paths)
|
|
716
|
+
# A directory above it below a home (also an invented home), or its
|
|
717
|
+
# direct parent anywhere: listing the folder to look for it.
|
|
718
|
+
pt, mt = home_tail(p), home_tail(m)
|
|
719
|
+
if pt and mt and mt.startswith(pt.rstrip("/") + "/"):
|
|
720
|
+
return True
|
|
721
|
+
if p == os.path.dirname(m) and p != "/":
|
|
722
|
+
return True
|
|
723
|
+
return False
|
|
724
|
+
return r.kind == missing.kind and r.value == missing.value
|