tether-sdk 0.0.2__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.
tether/sandbox.py ADDED
@@ -0,0 +1,342 @@
1
+ """Deterministic security sandbox for Tether (workspace jail, env scrubbing, Linux bwrap)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import re
8
+ import shlex
9
+ import shutil
10
+ import signal
11
+ import subprocess
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+
17
+ class SandboxViolationError(PermissionError):
18
+ """Raised when a tool call violates the deterministic SandboxPolicy."""
19
+
20
+
21
+ _SENSITIVE_FILENAMES = {
22
+ ".env",
23
+ ".git/config",
24
+ ".git/hooks",
25
+ ".tether",
26
+ "id_rsa",
27
+ "id_ed25519",
28
+ "authorized_keys",
29
+ }
30
+
31
+ _SECRET_ENV_RE = re.compile(
32
+ r"(API_KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL|PRIVATE_KEY|^AWS_|^OPENAI_|^ANTHROPIC_|^GH_)",
33
+ re.IGNORECASE,
34
+ )
35
+
36
+ _BLOCKED_BASH_PATTERNS: list[tuple[re.Pattern[str], str]] = [
37
+ (re.compile(r"(?:^|[;&|()\s])sudo\b"), "`sudo` privilege escalation is blocked"),
38
+ (re.compile(r"(?:^|[;&|()\s])su\b"), "`su` user switching is blocked"),
39
+ (
40
+ re.compile(r"(?:^|[;&|()\s])(?:mkfs|fdisk|parted|mount|umount)\b"),
41
+ "disk utilities are blocked",
42
+ ),
43
+ (re.compile(r"(?:^|[;&|()\s])dd\s+if="), "`dd` raw block operations are blocked"),
44
+ (re.compile(r"rm\s+-[a-zA-Z]*r[a-zA-Z]*f?[a-zA-Z]*\s+/(?:\s|$|\*)"), "`rm -rf /` is blocked"),
45
+ (re.compile(r"chmod\s+(?:-R\s+)?777\b"), "`chmod 777` is blocked"),
46
+ (re.compile(r":\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;"), "fork bombs are blocked"),
47
+ (
48
+ re.compile(r"(?:^|[\s/\"'])(\.env(?:\.[a-zA-Z0-9_-]+)?)\b"),
49
+ "access to `.env` files is blocked",
50
+ ),
51
+ (
52
+ re.compile(r"(?:^|[\s/\"'])\.tether(?:/|\b)"),
53
+ "access to `.tether` internal store is blocked",
54
+ ),
55
+ (
56
+ re.compile(r"(?:^|[\s/\"'])\.git/(?:config|hooks)\b"),
57
+ "access to `.git/config` or `hooks` is blocked",
58
+ ),
59
+ (
60
+ re.compile(r"(?:~|\$HOME)/\.ssh\b|/etc/(?:shadow|sudoers)\b"),
61
+ "access to system secrets is blocked",
62
+ ),
63
+ ]
64
+
65
+ _READONLY_ALLOWED_BINARIES = {
66
+ "ls",
67
+ "cat",
68
+ "head",
69
+ "tail",
70
+ "grep",
71
+ "rg",
72
+ "find",
73
+ "wc",
74
+ "pwd",
75
+ "echo",
76
+ "stat",
77
+ "file",
78
+ "which",
79
+ "uname",
80
+ "date",
81
+ "sort",
82
+ "uniq",
83
+ "cut",
84
+ "tr",
85
+ }
86
+
87
+ _BWRAP_USABLE: bool | None = None
88
+
89
+
90
+ def _can_use_bwrap() -> bool:
91
+ """Probe once whether `bwrap` is installed and permitted by host kernel namespaces."""
92
+ global _BWRAP_USABLE
93
+ if _BWRAP_USABLE is not None:
94
+ return _BWRAP_USABLE
95
+
96
+ bwrap_bin = shutil.which("bwrap")
97
+ if not bwrap_bin:
98
+ _BWRAP_USABLE = False
99
+ return False
100
+
101
+ try:
102
+ proc = subprocess.run(
103
+ [bwrap_bin, "--ro-bind", "/", "/", "--unshare-all", "--", "true"],
104
+ stdout=subprocess.DEVNULL,
105
+ stderr=subprocess.DEVNULL,
106
+ timeout=2.0,
107
+ check=False,
108
+ )
109
+ _BWRAP_USABLE = proc.returncode == 0
110
+ except Exception:
111
+ _BWRAP_USABLE = False
112
+ return _BWRAP_USABLE
113
+
114
+
115
+ @dataclass(slots=True)
116
+ class SandboxPolicy:
117
+ """Deterministic security envelope enforced by the harness (never by the LLM)."""
118
+
119
+ profile: str = "workspace-write" # "workspace-write" | "read-only"
120
+ workspace_root: Path = field(default_factory=lambda: Path.cwd().resolve())
121
+ timeout_seconds: float = 30.0
122
+ max_output_chars: int = 12000
123
+ use_kernel_sandbox: bool = True
124
+
125
+ def __post_init__(self) -> None:
126
+ self.workspace_root = Path(self.workspace_root).resolve()
127
+ if self.profile not in ("workspace-write", "read-only"):
128
+ raise ValueError(f"Unsupported sandbox profile: {self.profile}")
129
+
130
+ def to_dict(self) -> dict[str, Any]:
131
+ return {
132
+ "profile": self.profile,
133
+ "workspace_root": str(self.workspace_root),
134
+ "timeout_seconds": self.timeout_seconds,
135
+ "max_output_chars": self.max_output_chars,
136
+ "use_kernel_sandbox": self.use_kernel_sandbox,
137
+ }
138
+
139
+ @classmethod
140
+ def from_dict(cls, data: dict[str, Any] | None) -> SandboxPolicy:
141
+ if not data:
142
+ return cls()
143
+ return cls(
144
+ profile=str(data.get("profile", "workspace-write")),
145
+ workspace_root=Path(str(data.get("workspace_root", Path.cwd()))).resolve(),
146
+ timeout_seconds=float(data.get("timeout_seconds", 30.0)),
147
+ max_output_chars=int(data.get("max_output_chars", 12000)),
148
+ use_kernel_sandbox=bool(data.get("use_kernel_sandbox", True)),
149
+ )
150
+
151
+ def attenuate(self, child_profile: str | None = None) -> SandboxPolicy:
152
+ """Create a child SandboxPolicy that can only narrow (never escalate) privileges."""
153
+ if self.profile == "read-only":
154
+ effective_profile = "read-only"
155
+ else:
156
+ effective_profile = child_profile if child_profile == "read-only" else self.profile
157
+
158
+ return SandboxPolicy(
159
+ profile=effective_profile,
160
+ workspace_root=self.workspace_root,
161
+ timeout_seconds=self.timeout_seconds,
162
+ max_output_chars=self.max_output_chars,
163
+ use_kernel_sandbox=self.use_kernel_sandbox,
164
+ )
165
+
166
+ def resolve_safe_path(self, raw_path: str | Path, *, write: bool = False) -> Path:
167
+ """Resolve a path and enforce workspace confinement + sensitive-file protection."""
168
+ if write and self.profile == "read-only":
169
+ raise SandboxViolationError(
170
+ "Sandbox violation: file writes are disabled in `read-only` profile."
171
+ )
172
+
173
+ p = Path(raw_path)
174
+ candidate = (self.workspace_root / p).resolve() if not p.is_absolute() else p.resolve()
175
+
176
+ if not candidate.is_relative_to(self.workspace_root):
177
+ raise SandboxViolationError(
178
+ f"Sandbox violation: path `{raw_path}` resolves outside "
179
+ f"workspace `{self.workspace_root}`."
180
+ )
181
+
182
+ rel = candidate.relative_to(self.workspace_root)
183
+ rel_str = rel.as_posix()
184
+ parts = rel.parts
185
+
186
+ # Check sensitive patterns
187
+ if (
188
+ rel_str in _SENSITIVE_FILENAMES
189
+ or any(part.startswith(".env") for part in parts)
190
+ or ".tether" in parts
191
+ or (len(parts) >= 2 and parts[0] == ".git" and parts[1] in ("config", "hooks"))
192
+ or candidate.name in ("id_rsa", "id_ed25519", "authorized_keys")
193
+ or candidate.suffix in (".pem", ".key")
194
+ ):
195
+ raise SandboxViolationError(
196
+ f"Sandbox violation: access to protected path `{rel_str}` is denied."
197
+ )
198
+
199
+ return candidate
200
+
201
+ def scrub_env(self, base_env: dict[str, str] | None = None) -> dict[str, str]:
202
+ """Return a sanitized environment dictionary with secrets stripped."""
203
+ source = base_env if base_env is not None else dict(os.environ)
204
+ clean: dict[str, str] = {}
205
+ for k, v in source.items():
206
+ if _SECRET_ENV_RE.search(k):
207
+ continue
208
+ clean[k] = v
209
+ clean["HOME"] = str(self.workspace_root)
210
+ clean["PWD"] = str(self.workspace_root)
211
+ clean["PAGER"] = "cat"
212
+ return clean
213
+
214
+ def validate_bash_command(self, cmd: str) -> None:
215
+ """Deterministically validate a shell command against sandbox rules."""
216
+ stripped = cmd.strip()
217
+ if not stripped:
218
+ raise SandboxViolationError("Empty command.")
219
+
220
+ for pattern, reason in _BLOCKED_BASH_PATTERNS:
221
+ if pattern.search(stripped):
222
+ raise SandboxViolationError(f"Sandbox violation: {reason}.")
223
+
224
+ # Block explicit parent directory traversal (`..`) or absolute paths outside workspace
225
+ try:
226
+ tokens = shlex.split(stripped, posix=True)
227
+ except ValueError:
228
+ tokens = stripped.split()
229
+
230
+ for tok in tokens:
231
+ if tok == ".." or tok.startswith("../") or "/../" in tok or tok.endswith("/.."):
232
+ raise SandboxViolationError(
233
+ "Sandbox violation: parent directory traversal (`..`) is blocked."
234
+ )
235
+ if tok.startswith("/") and not tok.startswith(("/dev/null", "/tmp")):
236
+ resolved = Path(tok).resolve()
237
+ if not resolved.is_relative_to(self.workspace_root):
238
+ raise SandboxViolationError(
239
+ f"Sandbox violation: absolute path `{tok}` is outside workspace."
240
+ )
241
+
242
+ if self.profile == "read-only":
243
+ if any(
244
+ op in stripped
245
+ for op in (">", ">>", "| tee", "rm ", "mv ", "cp ", "mkdir ", "touch ")
246
+ ):
247
+ raise SandboxViolationError(
248
+ "Sandbox violation: mutating shell commands are blocked in `read-only` profile."
249
+ )
250
+ first_bin = Path(tokens[0]).name if tokens else ""
251
+ if first_bin not in _READONLY_ALLOWED_BINARIES:
252
+ raise SandboxViolationError(
253
+ f"Sandbox violation: binary `{first_bin}` is not in the `read-only` allowlist."
254
+ )
255
+
256
+ def _build_exec_argv(self, cmd: str) -> list[str]:
257
+ """Wrap command in Linux `bwrap` kernel sandbox if available, else standard bash."""
258
+ if self.use_kernel_sandbox and _can_use_bwrap():
259
+ ws = str(self.workspace_root)
260
+ bwrap_cmd = [
261
+ "bwrap",
262
+ "--ro-bind",
263
+ "/",
264
+ "/",
265
+ "--dev",
266
+ "/dev",
267
+ "--proc",
268
+ "/proc",
269
+ "--tmpfs",
270
+ "/tmp",
271
+ "--tmpfs",
272
+ os.path.expanduser("~"),
273
+ "--bind" if self.profile == "workspace-write" else "--ro-bind",
274
+ ws,
275
+ ws,
276
+ "--chdir",
277
+ ws,
278
+ "--die-with-parent",
279
+ "--new-session",
280
+ "--",
281
+ "bash",
282
+ "-c",
283
+ cmd,
284
+ ]
285
+ return bwrap_cmd
286
+ return ["bash", "-c", cmd]
287
+
288
+ async def execute_bash(self, cmd: str, stdin: str | None = None) -> str:
289
+ """Execute a shell command inside the deterministic sandbox."""
290
+ effective_cmd = cmd.strip()
291
+ if not effective_cmd and stdin:
292
+ effective_cmd = stdin.strip()
293
+ stdin = None
294
+
295
+ self.validate_bash_command(effective_cmd)
296
+ argv = self._build_exec_argv(effective_cmd)
297
+ env = self.scrub_env()
298
+
299
+ proc = await asyncio.create_subprocess_exec(
300
+ *argv,
301
+ stdin=asyncio.subprocess.PIPE if stdin is not None else asyncio.subprocess.DEVNULL,
302
+ stdout=asyncio.subprocess.PIPE,
303
+ stderr=asyncio.subprocess.PIPE,
304
+ cwd=str(self.workspace_root),
305
+ env=env,
306
+ start_new_session=True,
307
+ )
308
+
309
+ stdin_bytes = stdin.encode("utf-8") if stdin is not None else None
310
+ try:
311
+ stdout_b, stderr_b = await asyncio.wait_for(
312
+ proc.communicate(input=stdin_bytes),
313
+ timeout=self.timeout_seconds,
314
+ )
315
+ except TimeoutError:
316
+ try:
317
+ os.killpg(proc.pid, signal.SIGKILL)
318
+ except ProcessLookupError:
319
+ pass
320
+ await proc.wait()
321
+ return f"Error: command timed out after {self.timeout_seconds:.1f}s (SIGKILL)."
322
+
323
+ stdout_str = stdout_b.decode("utf-8", errors="replace")
324
+ stderr_str = stderr_b.decode("utf-8", errors="replace")
325
+ combined = stdout_str
326
+ if stderr_str:
327
+ combined = f"{stdout_str}\n[stderr]\n{stderr_str}".strip()
328
+
329
+ if not combined:
330
+ combined = f"[exit {proc.returncode}]"
331
+ elif proc.returncode != 0:
332
+ combined = f"{combined}\n[exit {proc.returncode}]"
333
+
334
+ if len(combined) > self.max_output_chars:
335
+ half = self.max_output_chars // 2
336
+ omitted = len(combined) - self.max_output_chars
337
+ combined = (
338
+ f"{combined[:half]}\n"
339
+ f"... [truncated {omitted:,} chars by sandbox cap] ...\n"
340
+ f"{combined[-half:]}"
341
+ )
342
+ return combined
tether/skills.py ADDED
@@ -0,0 +1,157 @@
1
+ """Progressive Skill discovery, YAML frontmatter parser, and companion tools.py loader."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import yaml
11
+
12
+ from tether.decorator import ToolSpec, build_tool_spec
13
+ from tether.multiplexer import Multiplexer
14
+
15
+
16
+ @dataclass(slots=True)
17
+ class Skill:
18
+ name: str
19
+ description: str
20
+ instructions: str
21
+ model: str | None = None
22
+ tools: list[str] = field(default_factory=list)
23
+ path: Path | None = None
24
+ custom_tools: list[ToolSpec] = field(default_factory=list)
25
+
26
+
27
+ def parse_skill_markdown(
28
+ raw_text: str, default_name: str, source_path: Path | None = None
29
+ ) -> Skill:
30
+ """Parse a Markdown file with optional YAML frontmatter into a Skill object."""
31
+ text = raw_text.strip()
32
+ meta: dict[str, Any] = {}
33
+ body = text
34
+
35
+ if text.startswith("---"):
36
+ parts = text.split("---", 2)
37
+ if len(parts) >= 3:
38
+ yaml_block = parts[1].strip()
39
+ body = parts[2].strip()
40
+ loaded = yaml.safe_load(yaml_block)
41
+ if isinstance(loaded, dict):
42
+ meta = loaded
43
+
44
+ name = str(meta.get("name") or default_name).strip()
45
+ desc = str(meta.get("description") or f"Skill {name}").strip()
46
+ model = str(meta["model"]).strip() if meta.get("model") else None
47
+ raw_tools = meta.get("tools") or []
48
+ tools_list = (
49
+ [str(t).strip() for t in raw_tools]
50
+ if isinstance(raw_tools, list)
51
+ else [t.strip() for t in str(raw_tools).split(",") if t.strip()]
52
+ )
53
+
54
+ custom_tools: list[ToolSpec] = []
55
+ if source_path and source_path.name == "SKILL.md":
56
+ tools_py = source_path.parent / "tools.py"
57
+ if tools_py.is_file():
58
+ custom_tools = _load_tools_module(tools_py)
59
+
60
+ return Skill(
61
+ name=name,
62
+ description=desc,
63
+ instructions=body,
64
+ model=model,
65
+ tools=tools_list,
66
+ path=source_path,
67
+ custom_tools=custom_tools,
68
+ )
69
+
70
+
71
+ def _load_tools_module(tools_py: Path) -> list[ToolSpec]:
72
+ """Dynamically import a skill's sibling `tools.py` and extract `@tool` functions."""
73
+ spec = importlib.util.spec_from_file_location(f"tether_skill_{tools_py.parent.name}", tools_py)
74
+ if spec is None or spec.loader is None:
75
+ return []
76
+ module = importlib.util.module_from_spec(spec)
77
+ spec.loader.exec_module(module)
78
+ found: list[ToolSpec] = []
79
+ for attr_name in dir(module):
80
+ obj = getattr(module, attr_name)
81
+ if hasattr(obj, "__tether_tool__"):
82
+ t_spec: ToolSpec = obj.__tether_tool__
83
+ found.append(t_spec)
84
+ return found
85
+
86
+
87
+ class SkillLoader:
88
+ """Discovers and loads skills from `./skills/` and `~/.tether/skills/`."""
89
+
90
+ def __init__(self, search_dirs: list[str | Path] | None = None) -> None:
91
+ if search_dirs is not None:
92
+ self.search_dirs = [Path(d).resolve() for d in search_dirs]
93
+ else:
94
+ self.search_dirs = [
95
+ (Path.cwd() / "skills").resolve(),
96
+ (Path.home() / ".tether" / "skills").resolve(),
97
+ ]
98
+
99
+ def discover(self) -> dict[str, Skill]:
100
+ skills: dict[str, Skill] = {}
101
+ for base in self.search_dirs:
102
+ if not base.is_dir():
103
+ continue
104
+ # 1. Single-file skills: skills/<name>.md
105
+ for md_file in sorted(base.glob("*.md")):
106
+ default_name = md_file.stem
107
+ sk = parse_skill_markdown(
108
+ md_file.read_text(encoding="utf-8"), default_name, md_file
109
+ )
110
+ skills.setdefault(sk.name, sk)
111
+ # 2. Directory skills: skills/<name>/SKILL.md
112
+ for skill_md in sorted(base.glob("*/SKILL.md")):
113
+ default_name = skill_md.parent.name
114
+ sk = parse_skill_markdown(
115
+ skill_md.read_text(encoding="utf-8"), default_name, skill_md
116
+ )
117
+ skills.setdefault(sk.name, sk)
118
+ return skills
119
+
120
+ def load(self, name: str) -> Skill | None:
121
+ return self.discover().get(name)
122
+
123
+
124
+ def make_skill_tool(loader: SkillLoader, mux: Multiplexer) -> ToolSpec:
125
+ """Create the progressive `skill` tool (`skill list | load <name>`)."""
126
+ help_str = (
127
+ "Usage: skill list | skill load <name>\n"
128
+ "Desc : List available skills or load a skill's instructions and tools."
129
+ )
130
+
131
+ def _dispatch(args: list[str], stdin: str | None = None) -> str:
132
+ if not args or args[0] in ("--help", "-h"):
133
+ return help_str
134
+ sub = args[0]
135
+ if sub == "list":
136
+ discovered = loader.discover()
137
+ if not discovered:
138
+ return "No skills found in ./skills/ or ~/.tether/skills/."
139
+ return "\n".join(f"- {s.name}: {s.description}" for s in discovered.values())
140
+ if sub == "load":
141
+ if len(args) < 2:
142
+ return f"Error: missing `<name>`.\n{help_str}"
143
+ sk = loader.load(args[1])
144
+ if not sk:
145
+ return f"Error: skill `{args[1]}` not found."
146
+ for ct in sk.custom_tools:
147
+ mux.register(ct)
148
+ return f"[Skill `{sk.name}` loaded]\n{sk.instructions}"
149
+ return f"Error: unknown subcommand `{sub}`.\n{help_str}"
150
+
151
+ return build_tool_spec(
152
+ _dispatch,
153
+ name="skill",
154
+ description="Discover and load specialized skills",
155
+ custom_help=help_str,
156
+ raw_dispatch=True,
157
+ )