techtide-swarm 0.1.0__tar.gz

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.
Files changed (29) hide show
  1. techtide_swarm-0.1.0/.gitignore +64 -0
  2. techtide_swarm-0.1.0/PKG-INFO +75 -0
  3. techtide_swarm-0.1.0/README.md +31 -0
  4. techtide_swarm-0.1.0/pyproject.toml +97 -0
  5. techtide_swarm-0.1.0/src/techtide_swarm/__init__.py +30 -0
  6. techtide_swarm-0.1.0/src/techtide_swarm/agent.py +224 -0
  7. techtide_swarm-0.1.0/src/techtide_swarm/bash_gate.py +44 -0
  8. techtide_swarm-0.1.0/src/techtide_swarm/cli.py +1121 -0
  9. techtide_swarm-0.1.0/src/techtide_swarm/core/__init__.py +1 -0
  10. techtide_swarm-0.1.0/src/techtide_swarm/core/types.py +17 -0
  11. techtide_swarm-0.1.0/src/techtide_swarm/http_security.py +29 -0
  12. techtide_swarm-0.1.0/src/techtide_swarm/llm.py +78 -0
  13. techtide_swarm-0.1.0/src/techtide_swarm/memory.py +255 -0
  14. techtide_swarm-0.1.0/src/techtide_swarm/memvid_bridge.py +85 -0
  15. techtide_swarm-0.1.0/src/techtide_swarm/persistence.py +203 -0
  16. techtide_swarm-0.1.0/src/techtide_swarm/rate_limit.py +83 -0
  17. techtide_swarm-0.1.0/src/techtide_swarm/server.py +350 -0
  18. techtide_swarm-0.1.0/src/techtide_swarm/structured_logging.py +56 -0
  19. techtide_swarm-0.1.0/src/techtide_swarm/swarm.py +378 -0
  20. techtide_swarm-0.1.0/src/techtide_swarm/telemetry.py +108 -0
  21. techtide_swarm-0.1.0/src/techtide_swarm/tools/__init__.py +67 -0
  22. techtide_swarm-0.1.0/src/techtide_swarm/tools/file_ops.py +141 -0
  23. techtide_swarm-0.1.0/src/techtide_swarm/tools/mcp.py +348 -0
  24. techtide_swarm-0.1.0/src/techtide_swarm/tools/memory_tool.py +140 -0
  25. techtide_swarm-0.1.0/src/techtide_swarm/tools/registry.py +207 -0
  26. techtide_swarm-0.1.0/src/techtide_swarm/tools/terminal.py +70 -0
  27. techtide_swarm-0.1.0/src/techtide_swarm/tools/web_scrape.py +134 -0
  28. techtide_swarm-0.1.0/src/techtide_swarm/tools/web_search.py +131 -0
  29. techtide_swarm-0.1.0/src/techtide_swarm/ultra_plan.py +66 -0
@@ -0,0 +1,64 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.egg-info/
6
+ dist/
7
+ build/
8
+ *.egg
9
+ .pytest_cache/
10
+ .ruff_cache/
11
+ .mypy_cache/
12
+
13
+ # Virtual environments
14
+ venv/
15
+ .venv/
16
+
17
+ # IDE / editor
18
+ .cursor/hooks/state/
19
+ .vscode/
20
+ .idea/
21
+ *.swp
22
+ *.swo
23
+
24
+ # Next.js build artifacts
25
+ .ui_landin_sample/minimal/.next/
26
+ .ui_landin_sample/minimal/node_modules/
27
+
28
+ # Rust build
29
+ packages/memvid-swarm-bridge/target/
30
+ .repos and items/memvid-main/memvid-main/target/
31
+
32
+ # Swarm runtime state (generated at runtime, not source)
33
+ .swarm/topics/
34
+ .swarm/telemetry.jsonl
35
+ .swarm/MEMORY.md
36
+ .swarm/*.mv2
37
+
38
+ # OS
39
+ .DS_Store
40
+ Thumbs.db
41
+
42
+ # Env files
43
+ .env
44
+ .env.local
45
+
46
+ # Quarantined legacy files
47
+ _quarantine/
48
+
49
+ # Git worktrees
50
+ .worktrees/
51
+
52
+ # Package-local runtime (never commit)
53
+ **/.swarm/
54
+
55
+ # Local test / vendor junk never ship
56
+ .hypothesis/
57
+ **/.hypothesis/
58
+ .repos and items/**/assets/
59
+ .repos and items/**/.cursor/
60
+ .repos and items/**/*.png
61
+ .repos and items/**/*.jpg
62
+ .repos and items/**/*.jpeg
63
+ .repos and items/**/*.webp
64
+ .repos and items/**/*.gif
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: techtide-swarm
3
+ Version: 0.1.0
4
+ Summary: TechTide Swarm 357 — layered orchestration, durable memory bridge, and observability hooks.
5
+ Project-URL: Homepage, https://github.com/TechTideOhio/swarm-357
6
+ Project-URL: Repository, https://github.com/TechTideOhio/swarm-357
7
+ Project-URL: Documentation, https://github.com/TechTideOhio/swarm-357#readme
8
+ Project-URL: Changelog, https://github.com/TechTideOhio/swarm-357/blob/main/CHANGELOG.md
9
+ Project-URL: Bug Tracker, https://github.com/TechTideOhio/swarm-357/issues
10
+ Author-email: TechTide AI <ai@techtide.ai>
11
+ License: Apache-2.0
12
+ Keywords: agents,ai,ai-agents,anthropic,claude,llm,multi-agent,orchestration,swarm
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: Apache Software License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
22
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: anthropic>=0.40
25
+ Requires-Dist: fastapi>=0.115
26
+ Requires-Dist: httpx>=0.27
27
+ Requires-Dist: pydantic>=2.5
28
+ Requires-Dist: pyyaml>=6
29
+ Requires-Dist: rich>=13
30
+ Requires-Dist: uvicorn[standard]>=0.30
31
+ Provides-Extra: dev
32
+ Requires-Dist: build>=1.0; extra == 'dev'
33
+ Requires-Dist: mypy>=1.13; extra == 'dev'
34
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
35
+ Requires-Dist: pytest>=8; extra == 'dev'
36
+ Requires-Dist: ruff>=0.8; extra == 'dev'
37
+ Requires-Dist: types-pyyaml>=6.0; extra == 'dev'
38
+ Provides-Extra: supabase
39
+ Requires-Dist: supabase>=2.0; extra == 'supabase'
40
+ Provides-Extra: web
41
+ Requires-Dist: exa-py>=1.0; extra == 'web'
42
+ Requires-Dist: firecrawl-py>=0.0.20; extra == 'web'
43
+ Description-Content-Type: text/markdown
44
+
45
+ # techtide-swarm
46
+
47
+ Python package for **Swarm 357**: `Agent`, `Swarm`, `UltraPlan`, `MemoryManager` (flat `.swarm/` or Memvid `.mv2` via `memvid-swarm-bridge`), `BashSecurityGate`, and the `swarm` CLI.
48
+
49
+ ## Install (editable, from repo root)
50
+
51
+ ```bash
52
+ cd packages/techtide-swarm
53
+ pip install -e ".[dev]"
54
+ ```
55
+
56
+ Or from the monorepo root:
57
+
58
+ ```bash
59
+ pip install -e "packages/techtide-swarm[dev]"
60
+ ```
61
+
62
+ Ensure `anthropic` can reach the API when using live `Agent.run` (set `ANTHROPIC_API_KEY`).
63
+
64
+ ## Memvid bridge
65
+
66
+ Optional: build `packages/memvid-swarm-bridge` and set `MEMVID_SWARM_BRIDGE` to the binary path, or add it to `PATH`. See [docs/MEMVID_BRIDGE.md](../../docs/MEMVID_BRIDGE.md) (repo root).
67
+
68
+ ## CLI
69
+
70
+ ```bash
71
+ swarm init
72
+ swarm demo
73
+ ```
74
+
75
+ Full developer context: [CLAUDE.md](../../CLAUDE.md) at the swarm357 repo root.
@@ -0,0 +1,31 @@
1
+ # techtide-swarm
2
+
3
+ Python package for **Swarm 357**: `Agent`, `Swarm`, `UltraPlan`, `MemoryManager` (flat `.swarm/` or Memvid `.mv2` via `memvid-swarm-bridge`), `BashSecurityGate`, and the `swarm` CLI.
4
+
5
+ ## Install (editable, from repo root)
6
+
7
+ ```bash
8
+ cd packages/techtide-swarm
9
+ pip install -e ".[dev]"
10
+ ```
11
+
12
+ Or from the monorepo root:
13
+
14
+ ```bash
15
+ pip install -e "packages/techtide-swarm[dev]"
16
+ ```
17
+
18
+ Ensure `anthropic` can reach the API when using live `Agent.run` (set `ANTHROPIC_API_KEY`).
19
+
20
+ ## Memvid bridge
21
+
22
+ Optional: build `packages/memvid-swarm-bridge` and set `MEMVID_SWARM_BRIDGE` to the binary path, or add it to `PATH`. See [docs/MEMVID_BRIDGE.md](../../docs/MEMVID_BRIDGE.md) (repo root).
23
+
24
+ ## CLI
25
+
26
+ ```bash
27
+ swarm init
28
+ swarm demo
29
+ ```
30
+
31
+ Full developer context: [CLAUDE.md](../../CLAUDE.md) at the swarm357 repo root.
@@ -0,0 +1,97 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "techtide-swarm"
7
+ version = "0.1.0"
8
+ description = "TechTide Swarm 357 — layered orchestration, durable memory bridge, and observability hooks."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "Apache-2.0" }
12
+ authors = [{ name = "TechTide AI", email = "ai@techtide.ai" }]
13
+ keywords = ["claude", "ai", "agents", "swarm", "anthropic", "llm", "orchestration", "multi-agent", "ai-agents"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: Apache Software License",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Topic :: Software Development :: Libraries :: Application Frameworks",
24
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
25
+ ]
26
+ dependencies = [
27
+ "anthropic>=0.40",
28
+ "pydantic>=2.5",
29
+ "pyyaml>=6",
30
+ "httpx>=0.27",
31
+ "rich>=13",
32
+ "fastapi>=0.115",
33
+ "uvicorn[standard]>=0.30",
34
+ ]
35
+
36
+ [project.optional-dependencies]
37
+ dev = [
38
+ "pytest>=8",
39
+ "pytest-asyncio>=0.24",
40
+ "ruff>=0.8",
41
+ "mypy>=1.13",
42
+ "types-PyYAML>=6.0",
43
+ "build>=1.0",
44
+ ]
45
+ web = [
46
+ "exa-py>=1.0",
47
+ "firecrawl-py>=0.0.20",
48
+ ]
49
+ supabase = [
50
+ "supabase>=2.0",
51
+ ]
52
+
53
+ [project.urls]
54
+ Homepage = "https://github.com/TechTideOhio/swarm-357"
55
+ Repository = "https://github.com/TechTideOhio/swarm-357"
56
+ Documentation = "https://github.com/TechTideOhio/swarm-357#readme"
57
+ Changelog = "https://github.com/TechTideOhio/swarm-357/blob/main/CHANGELOG.md"
58
+ "Bug Tracker" = "https://github.com/TechTideOhio/swarm-357/issues"
59
+
60
+ [project.scripts]
61
+ swarm = "techtide_swarm.cli:main"
62
+
63
+ [tool.hatch.build.targets.wheel]
64
+ packages = ["src/techtide_swarm"]
65
+
66
+ [tool.hatch.build.targets.sdist]
67
+ include = ["src/techtide_swarm", "README.md", "pyproject.toml"]
68
+
69
+ [tool.pytest.ini_options]
70
+ asyncio_mode = "auto"
71
+ asyncio_default_fixture_loop_scope = "function"
72
+ testpaths = ["tests"]
73
+
74
+ [tool.ruff]
75
+ line-length = 100
76
+ target-version = "py310"
77
+
78
+ # Explicit default rule set — keep CI stable across ruff minor releases (0.16+).
79
+ [tool.ruff.lint]
80
+ select = ["E4", "E7", "E9", "F"]
81
+
82
+ [tool.mypy]
83
+ python_version = "3.10"
84
+ strict = true
85
+ packages = ["techtide_swarm"]
86
+
87
+ # CLI is large and UI-heavy; keep strict typing on library code.
88
+ [[tool.mypy.overrides]]
89
+ module = "techtide_swarm.cli"
90
+ ignore_errors = true
91
+
92
+ # Optional external integrations that ship no type stubs. These are only
93
+ # imported when the corresponding extras ([web], [supabase]) are installed,
94
+ # so allow missing imports for them without weakening type checking elsewhere.
95
+ [[tool.mypy.overrides]]
96
+ module = ["supabase", "exa_py", "firecrawl"]
97
+ ignore_missing_imports = true
@@ -0,0 +1,30 @@
1
+ """TechTide Swarm 357 — public API."""
2
+
3
+ from techtide_swarm.agent import Agent, AgentConfig, AgentResult
4
+ from techtide_swarm.bash_gate import BashSecurityGate
5
+ from techtide_swarm.memory import MemoryManager
6
+ from techtide_swarm.memvid_bridge import MemvidBridge, MemvidBridgeError, resolve_bridge_binary
7
+ from techtide_swarm.persistence import SwarmStore
8
+ from techtide_swarm.swarm import CostController, Swarm, SwarmExecutionResult
9
+ from techtide_swarm.tools.registry import TOOLSET_MAP, ToolRegistry, registry
10
+ from techtide_swarm.ultra_plan import UltraPlan, UltraPlanConfig
11
+
12
+ __all__ = [
13
+ "Agent",
14
+ "AgentConfig",
15
+ "AgentResult",
16
+ "BashSecurityGate",
17
+ "CostController",
18
+ "MemvidBridge",
19
+ "MemvidBridgeError",
20
+ "MemoryManager",
21
+ "Swarm",
22
+ "SwarmExecutionResult",
23
+ "SwarmStore",
24
+ "TOOLSET_MAP",
25
+ "ToolRegistry",
26
+ "UltraPlan",
27
+ "UltraPlanConfig",
28
+ "registry",
29
+ "resolve_bridge_binary",
30
+ ]
@@ -0,0 +1,224 @@
1
+ """Single-agent runner."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from pydantic import BaseModel, Field
12
+
13
+ from techtide_swarm.core.types import LayerType
14
+
15
+
16
+ class AgentConfig(BaseModel):
17
+ """Configuration for one agent."""
18
+
19
+ name: str
20
+ layer: LayerType
21
+ role: str
22
+ soul: str = ""
23
+ tools: list[str] = Field(default_factory=list)
24
+ model: str = "sonnet"
25
+ budget_limit_usd: float = 1.0
26
+ max_turns: int = 10
27
+
28
+
29
+ @dataclass
30
+ class AgentResult:
31
+ """Outcome of agent.run."""
32
+
33
+ output: str
34
+ cost_usd: float
35
+ latency_ms: int
36
+ status: str
37
+ agent_name: str = ""
38
+ error: str | None = None
39
+
40
+
41
+ class Agent:
42
+ """One Claude-powered agent."""
43
+
44
+ def __init__(self, config: AgentConfig) -> None:
45
+ self.config = config
46
+
47
+ def _load_system_prompt(self) -> str | None:
48
+ """Read the SOUL.md template and return the system prompt body (after YAML front-matter)."""
49
+ soul_path = self.config.soul
50
+ if not soul_path:
51
+ return None
52
+ path = Path(soul_path)
53
+ if not path.is_file():
54
+ # Resolve relative to repo root (two levels up from this package's src/)
55
+ pkg_src = Path(__file__).resolve().parent
56
+ candidates = [
57
+ pkg_src.parent.parent.parent.parent / soul_path, # repo root
58
+ pkg_src.parent.parent.parent / soul_path,
59
+ ]
60
+ for candidate in candidates:
61
+ if candidate.is_file():
62
+ path = candidate
63
+ break
64
+ else:
65
+ return None
66
+ text = path.read_text(encoding="utf-8")
67
+ # Strip YAML front-matter delimited by ---
68
+ if text.startswith("---"):
69
+ parts = text.split("---", 2)
70
+ if len(parts) >= 3:
71
+ return parts[2].strip()
72
+ return text.strip()
73
+
74
+ async def run(self, task: str) -> AgentResult:
75
+ """Execute task via Anthropic Messages API when API key is set; else deterministic stub."""
76
+ from techtide_swarm.llm import create_async_client, model_id as resolve_model_id, resolve_api_key
77
+
78
+ api_key = resolve_api_key()
79
+ if not api_key:
80
+ return self._stub_result(task)
81
+
82
+ started = time.perf_counter()
83
+ try:
84
+ from techtide_swarm.tools import get_anthropic_tools, execute_tool
85
+
86
+ client = create_async_client()
87
+ resolved_model = resolve_model_id(self.config.model)
88
+ system_prompt = self._load_system_prompt()
89
+
90
+ messages: list[dict[str, Any]] = [{"role": "user", "content": task}]
91
+ tools = get_anthropic_tools(self.config.tools)
92
+
93
+ total_cost = 0.0
94
+ final_text = ""
95
+ budget = self.config.budget_limit_usd
96
+
97
+ for turn in range(self.config.max_turns):
98
+ if total_cost >= budget:
99
+ final_text = f"Budget limit reached (${budget:.2f}). Stopping."
100
+ break
101
+
102
+ create_kwargs: dict[str, Any] = {
103
+ "model": resolved_model,
104
+ "max_tokens": 4096,
105
+ "messages": messages,
106
+ }
107
+ if system_prompt:
108
+ create_kwargs["system"] = system_prompt
109
+ if tools:
110
+ create_kwargs["tools"] = tools
111
+
112
+ message = await client.messages.create(**create_kwargs)
113
+ total_cost += self._estimate_cost(message, resolved_model)
114
+
115
+ messages.append({"role": "assistant", "content": message.content})
116
+
117
+ if total_cost >= budget:
118
+ final_text = self._extract_text(message) or (
119
+ f"Budget limit reached (${budget:.2f}). Stopping."
120
+ )
121
+ break
122
+
123
+ tool_uses = [block for block in message.content if getattr(block, "type", None) == "tool_use"]
124
+
125
+ if not tool_uses:
126
+ final_text = self._extract_text(message)
127
+ break
128
+
129
+ tool_results = []
130
+ for tool_use in tool_uses:
131
+ result_text = execute_tool(tool_use.name, tool_use.input)
132
+ tool_results.append({
133
+ "type": "tool_result",
134
+ "tool_use_id": tool_use.id,
135
+ "content": result_text
136
+ })
137
+
138
+ messages.append({"role": "user", "content": tool_results})
139
+ else:
140
+ final_text = "Max turns reached without completion."
141
+
142
+ elapsed_ms = int((time.perf_counter() - started) * 1000)
143
+
144
+ from techtide_swarm.telemetry import log_telemetry
145
+ log_telemetry("agent_run", {
146
+ "agent_name": self.config.name,
147
+ "layer": self.config.layer.value,
148
+ "cost_usd": total_cost,
149
+ "latency_ms": elapsed_ms,
150
+ "status": "success",
151
+ })
152
+
153
+ return AgentResult(
154
+ agent_name=self.config.name,
155
+ output=final_text,
156
+ cost_usd=total_cost,
157
+ latency_ms=elapsed_ms,
158
+ status="success",
159
+ )
160
+ except Exception as exc: # noqa: BLE001 — surface to CLI
161
+ elapsed_ms = int((time.perf_counter() - started) * 1000)
162
+
163
+ from techtide_swarm.telemetry import log_telemetry
164
+ log_telemetry("agent_run", {
165
+ "agent_name": self.config.name,
166
+ "layer": self.config.layer.value,
167
+ "cost_usd": 0.0,
168
+ "latency_ms": elapsed_ms,
169
+ "status": "error",
170
+ "error": str(exc),
171
+ })
172
+
173
+ return AgentResult(
174
+ agent_name=self.config.name,
175
+ output="",
176
+ cost_usd=0.0,
177
+ latency_ms=elapsed_ms,
178
+ status="error",
179
+ error=str(exc),
180
+ )
181
+
182
+ def _stub_result(self, task: str) -> AgentResult:
183
+ preview = task[:200].replace("\n", " ")
184
+ return AgentResult(
185
+ agent_name=self.config.name,
186
+ output=(
187
+ f"[stub] Agent {self.config.name} ({self.config.layer.value}) would process:\n"
188
+ f"{preview}"
189
+ ),
190
+ cost_usd=0.0,
191
+ latency_ms=0,
192
+ status="success",
193
+ )
194
+
195
+ @staticmethod
196
+ def _model_id(short: str) -> str:
197
+ from techtide_swarm.llm import model_id as resolve_model_id
198
+
199
+ return resolve_model_id(short)
200
+
201
+ @staticmethod
202
+ def _extract_text(message: Any) -> str:
203
+ parts: list[str] = []
204
+ for block in getattr(message, "content", []) or []:
205
+ btype = getattr(block, "type", None)
206
+ if btype == "text":
207
+ parts.append(getattr(block, "text", ""))
208
+ return "\n".join(parts) if parts else str(message)
209
+
210
+ @staticmethod
211
+ def _estimate_cost(message: Any, model_id: str) -> float:
212
+ usage = getattr(message, "usage", None)
213
+ if usage is None:
214
+ return 0.01
215
+ inp = float(getattr(usage, "input_tokens", 0) or 0)
216
+ out = float(getattr(usage, "output_tokens", 0) or 0)
217
+ # Approximate list pricing snapshot (Claude 4.x, 2026) — override with real billing later
218
+ if "opus" in model_id.lower():
219
+ rate_in, rate_out = 15.0 / 1e6, 75.0 / 1e6
220
+ elif "haiku" in model_id.lower():
221
+ rate_in, rate_out = 0.80 / 1e6, 4.0 / 1e6
222
+ else: # sonnet
223
+ rate_in, rate_out = 3.0 / 1e6, 15.0 / 1e6
224
+ return inp * rate_in + out * rate_out
@@ -0,0 +1,44 @@
1
+ """23-point style bash command validation (subset implemented; extend as needed)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shlex
7
+
8
+
9
+ class BashSecurityGate:
10
+ """Validate shell commands before execution."""
11
+
12
+ _BLOCK_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
13
+ (re.compile(r"(?i)\brm\s+(-[rf]*\s*)*[/~]"), "recursive delete toward root or home"),
14
+ (re.compile(r"(?i)\bcurl\b.*\|\s*bash"), "curl pipe to bash"),
15
+ (re.compile(r"(?i)\bwget\b.*\|\s*(?:sh|bash)"), "wget pipe to shell"),
16
+ (re.compile(r"(?i)\b(?:sudo\s+)?chmod\s+777\b"), "dangerous chmod"),
17
+ (re.compile(r"(?i)\$[\w_]*(?:KEY|SECRET|TOKEN|PASSWORD)\b"), "references secret env var"),
18
+ (re.compile(r"(?i)\b(?:ANTHROPIC|OPENAI|AWS|GCP)_(?:API_)?KEY\b"), "API key literal in argv"),
19
+ (re.compile(r"(?i)>\s*/etc/"), "redirect to system config"),
20
+ (re.compile(r"(?i)\bmkfs\b"), "disk format"),
21
+ (re.compile(r"(?i)\bdd\s+if="), "raw disk write"),
22
+ (re.compile(r"(?i)\bpython[23]?\s+-c\s+.*(?:exec|eval|import\s+os)"), "python code exec with os access"),
23
+ (re.compile(r"(?i)\bsudo\s+(?:rm|mv|cp|chown)\b"), "elevated file operation"),
24
+ (re.compile(r"(?i)\bnc\s+-[el]"), "netcat listener"),
25
+ (re.compile(r"(?i)>\s*/dev/(?:sd|hd|nvme)"), "redirect to block device"),
26
+ )
27
+
28
+ @classmethod
29
+ def validate(cls, command: str) -> tuple[bool, str]:
30
+ """Return (safe, reason). Safe commands get reason ''."""
31
+ stripped = command.strip()
32
+ if not stripped:
33
+ return False, "empty command"
34
+ try:
35
+ parts = shlex.split(stripped)
36
+ except ValueError as exc:
37
+ return False, f"unparseable shell: {exc}"
38
+ if not parts:
39
+ return False, "empty command"
40
+ joined = " ".join(parts)
41
+ for pattern, reason in cls._BLOCK_PATTERNS:
42
+ if pattern.search(stripped) or pattern.search(joined):
43
+ return False, reason
44
+ return True, ""