forked from Rockachopa/Timmy-time-dashboard
feat: add Shell and Git execution hands for Timmy (#136)
This commit is contained in:
committed by
GitHub
parent
39461858a0
commit
3b322d185c
249
src/infrastructure/hands/shell.py
Normal file
249
src/infrastructure/hands/shell.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""Shell Execution Hand — sandboxed command runner for Timmy.
|
||||
|
||||
Provides a restricted shell execution environment with:
|
||||
- Configurable command allow-list
|
||||
- Timeout enforcement
|
||||
- Working-directory pinning
|
||||
- Graceful degradation (never crashes the coordinator)
|
||||
|
||||
Follows project conventions:
|
||||
- Config via ``from config import settings``
|
||||
- Singleton pattern for module-level import
|
||||
- Graceful degradation: log error, return fallback, never crash
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import shlex
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
from config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Commands that are always blocked regardless of allow-list
|
||||
_BLOCKED_COMMANDS = frozenset({
|
||||
"rm -rf /",
|
||||
"rm -rf /*",
|
||||
"mkfs",
|
||||
"dd if=/dev/zero",
|
||||
":(){ :|:& };:", # fork bomb
|
||||
"> /dev/sda",
|
||||
"chmod -R 777 /",
|
||||
})
|
||||
|
||||
# Default allow-list: safe build/dev commands
|
||||
DEFAULT_ALLOWED_PREFIXES = (
|
||||
"make",
|
||||
"pytest",
|
||||
"python",
|
||||
"pip",
|
||||
"git",
|
||||
"ls",
|
||||
"cat",
|
||||
"head",
|
||||
"tail",
|
||||
"grep",
|
||||
"find",
|
||||
"echo",
|
||||
"env",
|
||||
"which",
|
||||
"uname",
|
||||
"whoami",
|
||||
"date",
|
||||
"wc",
|
||||
"sort",
|
||||
"uniq",
|
||||
"diff",
|
||||
"curl",
|
||||
"wget",
|
||||
"docker",
|
||||
"npm",
|
||||
"node",
|
||||
"cargo",
|
||||
"rustc",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShellResult:
|
||||
"""Result from a shell command execution."""
|
||||
|
||||
command: str
|
||||
success: bool
|
||||
exit_code: int = -1
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
error: str = ""
|
||||
latency_ms: float = 0.0
|
||||
timed_out: bool = False
|
||||
metadata: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
class ShellHand:
|
||||
"""Sandboxed shell executor for Timmy.
|
||||
|
||||
All methods degrade gracefully — if a command fails or times out,
|
||||
the hand returns a ``ShellResult(success=False)`` rather than raising.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
allowed_prefixes: Optional[tuple[str, ...]] = None,
|
||||
default_timeout: int = 60,
|
||||
working_dir: Optional[str] = None,
|
||||
) -> None:
|
||||
self._allowed_prefixes = allowed_prefixes or DEFAULT_ALLOWED_PREFIXES
|
||||
self._default_timeout = default_timeout
|
||||
self._working_dir = working_dir or settings.repo_root or None
|
||||
self._enabled = True
|
||||
logger.info(
|
||||
"ShellHand initialised — cwd=%s, timeout=%ds, %d allowed prefixes",
|
||||
self._working_dir,
|
||||
self._default_timeout,
|
||||
len(self._allowed_prefixes),
|
||||
)
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
def _validate_command(self, command: str) -> Optional[str]:
|
||||
"""Validate a command against the allow-list.
|
||||
|
||||
Returns None if valid, or an error message if blocked.
|
||||
"""
|
||||
stripped = command.strip()
|
||||
|
||||
# Check explicit block-list
|
||||
for blocked in _BLOCKED_COMMANDS:
|
||||
if blocked in stripped:
|
||||
return f"Command blocked by safety filter: {blocked!r}"
|
||||
|
||||
# Check allow-list by first token
|
||||
try:
|
||||
tokens = shlex.split(stripped)
|
||||
except ValueError:
|
||||
tokens = stripped.split()
|
||||
|
||||
if not tokens:
|
||||
return "Empty command"
|
||||
|
||||
base_cmd = tokens[0].split("/")[-1] # strip path prefix
|
||||
|
||||
if base_cmd not in self._allowed_prefixes:
|
||||
return (
|
||||
f"Command '{base_cmd}' not in allow-list. "
|
||||
f"Allowed: {', '.join(sorted(self._allowed_prefixes))}"
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
async def run(
|
||||
self,
|
||||
command: str,
|
||||
timeout: Optional[int] = None,
|
||||
working_dir: Optional[str] = None,
|
||||
env: Optional[dict] = None,
|
||||
) -> ShellResult:
|
||||
"""Execute a shell command in a sandboxed environment.
|
||||
|
||||
Args:
|
||||
command: The shell command to execute.
|
||||
timeout: Override default timeout (seconds).
|
||||
working_dir: Override default working directory.
|
||||
env: Additional environment variables to set.
|
||||
|
||||
Returns:
|
||||
ShellResult with stdout/stderr or error details.
|
||||
"""
|
||||
start = time.time()
|
||||
|
||||
# Validate
|
||||
validation_error = self._validate_command(command)
|
||||
if validation_error:
|
||||
return ShellResult(
|
||||
command=command,
|
||||
success=False,
|
||||
error=validation_error,
|
||||
latency_ms=(time.time() - start) * 1000,
|
||||
)
|
||||
|
||||
effective_timeout = timeout or self._default_timeout
|
||||
cwd = working_dir or self._working_dir
|
||||
|
||||
try:
|
||||
import os
|
||||
|
||||
run_env = os.environ.copy()
|
||||
if env:
|
||||
run_env.update(env)
|
||||
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=cwd,
|
||||
env=run_env,
|
||||
)
|
||||
|
||||
try:
|
||||
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
||||
proc.communicate(), timeout=effective_timeout
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
latency = (time.time() - start) * 1000
|
||||
logger.warning(
|
||||
"Shell command timed out after %ds: %s", effective_timeout, command
|
||||
)
|
||||
return ShellResult(
|
||||
command=command,
|
||||
success=False,
|
||||
exit_code=-1,
|
||||
error=f"Command timed out after {effective_timeout}s",
|
||||
latency_ms=latency,
|
||||
timed_out=True,
|
||||
)
|
||||
|
||||
latency = (time.time() - start) * 1000
|
||||
exit_code = proc.returncode or 0
|
||||
stdout = stdout_bytes.decode("utf-8", errors="replace").strip()
|
||||
stderr = stderr_bytes.decode("utf-8", errors="replace").strip()
|
||||
|
||||
return ShellResult(
|
||||
command=command,
|
||||
success=exit_code == 0,
|
||||
exit_code=exit_code,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
latency_ms=latency,
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
latency = (time.time() - start) * 1000
|
||||
logger.warning("Shell command failed: %s — %s", command, exc)
|
||||
return ShellResult(
|
||||
command=command,
|
||||
success=False,
|
||||
error=str(exc),
|
||||
latency_ms=latency,
|
||||
)
|
||||
|
||||
def status(self) -> dict:
|
||||
"""Return a status summary for the dashboard."""
|
||||
return {
|
||||
"enabled": self._enabled,
|
||||
"working_dir": self._working_dir,
|
||||
"default_timeout": self._default_timeout,
|
||||
"allowed_prefixes": list(self._allowed_prefixes),
|
||||
}
|
||||
|
||||
|
||||
# ── Module-level singleton ──────────────────────────────────────────────────
|
||||
shell_hand = ShellHand()
|
||||
Reference in New Issue
Block a user