2026-03-06 09:01:24 -05:00
|
|
|
"""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 config import settings
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
# Commands that are always blocked regardless of allow-list
|
2026-03-08 12:50:44 -04:00
|
|
|
_BLOCKED_COMMANDS = frozenset(
|
|
|
|
|
{
|
|
|
|
|
"rm -rf /",
|
|
|
|
|
"rm -rf /*",
|
|
|
|
|
"mkfs",
|
|
|
|
|
"dd if=/dev/zero",
|
|
|
|
|
":(){ :|:& };:", # fork bomb
|
|
|
|
|
"> /dev/sda",
|
|
|
|
|
"chmod -R 777 /",
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-03-06 09:01:24 -05:00
|
|
|
|
|
|
|
|
# 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,
|
ruff (#169)
* polish: streamline nav, extract inline styles, improve tablet UX
- Restructure desktop nav from 8+ flat links + overflow dropdown into
5 grouped dropdowns (Core, Agents, Intel, System, More) matching
the mobile menu structure to reduce decision fatigue
- Extract all inline styles from mission_control.html and base.html
notification elements into mission-control.css with semantic classes
- Replace JS-built innerHTML with secure DOM construction in
notification loader and chat history
- Add CONNECTING state to connection indicator (amber) instead of
showing OFFLINE before WebSocket connects
- Add tablet breakpoint (1024px) with larger touch targets for
Apple Pencil / stylus use and safe-area padding for iPad toolbar
- Add active-link highlighting in desktop dropdown menus
- Rename "Mission Control" page title to "System Overview" to
disambiguate from the chat home page
- Add "Home — Timmy Time" page title to index.html
https://claude.ai/code/session_015uPUoKyYa8M2UAcyk5Gt6h
* fix(security): move auth-gate credentials to environment variables
Hardcoded username, password, and HMAC secret in auth-gate.py replaced
with os.environ lookups. Startup now refuses to run if any variable is
unset. Added AUTH_GATE_SECRET/USER/PASS to .env.example.
https://claude.ai/code/session_015uPUoKyYa8M2UAcyk5Gt6h
* refactor(tooling): migrate from black+isort+bandit to ruff
Replace three separate linting/formatting tools with a single ruff
invocation. Updates tox.ini (lint, format, pre-push, pre-commit envs),
.pre-commit-config.yaml, and CI workflow. Fixes all ruff errors
including unused imports, missing raise-from, and undefined names.
Ruff config maps existing bandit skips to equivalent S-rules.
https://claude.ai/code/session_015uPUoKyYa8M2UAcyk5Gt6h
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-11 12:23:35 -04:00
|
|
|
allowed_prefixes: tuple[str, ...] | None = None,
|
2026-03-06 09:01:24 -05:00
|
|
|
default_timeout: int = 60,
|
ruff (#169)
* polish: streamline nav, extract inline styles, improve tablet UX
- Restructure desktop nav from 8+ flat links + overflow dropdown into
5 grouped dropdowns (Core, Agents, Intel, System, More) matching
the mobile menu structure to reduce decision fatigue
- Extract all inline styles from mission_control.html and base.html
notification elements into mission-control.css with semantic classes
- Replace JS-built innerHTML with secure DOM construction in
notification loader and chat history
- Add CONNECTING state to connection indicator (amber) instead of
showing OFFLINE before WebSocket connects
- Add tablet breakpoint (1024px) with larger touch targets for
Apple Pencil / stylus use and safe-area padding for iPad toolbar
- Add active-link highlighting in desktop dropdown menus
- Rename "Mission Control" page title to "System Overview" to
disambiguate from the chat home page
- Add "Home — Timmy Time" page title to index.html
https://claude.ai/code/session_015uPUoKyYa8M2UAcyk5Gt6h
* fix(security): move auth-gate credentials to environment variables
Hardcoded username, password, and HMAC secret in auth-gate.py replaced
with os.environ lookups. Startup now refuses to run if any variable is
unset. Added AUTH_GATE_SECRET/USER/PASS to .env.example.
https://claude.ai/code/session_015uPUoKyYa8M2UAcyk5Gt6h
* refactor(tooling): migrate from black+isort+bandit to ruff
Replace three separate linting/formatting tools with a single ruff
invocation. Updates tox.ini (lint, format, pre-push, pre-commit envs),
.pre-commit-config.yaml, and CI workflow. Fixes all ruff errors
including unused imports, missing raise-from, and undefined names.
Ruff config maps existing bandit skips to equivalent S-rules.
https://claude.ai/code/session_015uPUoKyYa8M2UAcyk5Gt6h
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-11 12:23:35 -04:00
|
|
|
working_dir: str | None = None,
|
2026-03-06 09:01:24 -05:00
|
|
|
) -> 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
|
|
|
|
|
|
ruff (#169)
* polish: streamline nav, extract inline styles, improve tablet UX
- Restructure desktop nav from 8+ flat links + overflow dropdown into
5 grouped dropdowns (Core, Agents, Intel, System, More) matching
the mobile menu structure to reduce decision fatigue
- Extract all inline styles from mission_control.html and base.html
notification elements into mission-control.css with semantic classes
- Replace JS-built innerHTML with secure DOM construction in
notification loader and chat history
- Add CONNECTING state to connection indicator (amber) instead of
showing OFFLINE before WebSocket connects
- Add tablet breakpoint (1024px) with larger touch targets for
Apple Pencil / stylus use and safe-area padding for iPad toolbar
- Add active-link highlighting in desktop dropdown menus
- Rename "Mission Control" page title to "System Overview" to
disambiguate from the chat home page
- Add "Home — Timmy Time" page title to index.html
https://claude.ai/code/session_015uPUoKyYa8M2UAcyk5Gt6h
* fix(security): move auth-gate credentials to environment variables
Hardcoded username, password, and HMAC secret in auth-gate.py replaced
with os.environ lookups. Startup now refuses to run if any variable is
unset. Added AUTH_GATE_SECRET/USER/PASS to .env.example.
https://claude.ai/code/session_015uPUoKyYa8M2UAcyk5Gt6h
* refactor(tooling): migrate from black+isort+bandit to ruff
Replace three separate linting/formatting tools with a single ruff
invocation. Updates tox.ini (lint, format, pre-push, pre-commit envs),
.pre-commit-config.yaml, and CI workflow. Fixes all ruff errors
including unused imports, missing raise-from, and undefined names.
Ruff config maps existing bandit skips to equivalent S-rules.
https://claude.ai/code/session_015uPUoKyYa8M2UAcyk5Gt6h
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-11 12:23:35 -04:00
|
|
|
def _validate_command(self, command: str) -> str | None:
|
2026-03-06 09:01:24 -05:00
|
|
|
"""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
|
|
|
|
|
|
2026-03-19 21:04:10 -04:00
|
|
|
@staticmethod
|
|
|
|
|
def _build_run_env(env: dict | None) -> dict:
|
|
|
|
|
"""Merge *env* overrides into a copy of the current environment."""
|
|
|
|
|
import os
|
|
|
|
|
|
|
|
|
|
run_env = os.environ.copy()
|
|
|
|
|
if env:
|
|
|
|
|
run_env.update(env)
|
|
|
|
|
return run_env
|
|
|
|
|
|
|
|
|
|
async def _execute_subprocess(
|
|
|
|
|
self,
|
|
|
|
|
command: str,
|
|
|
|
|
effective_timeout: int,
|
|
|
|
|
cwd: str | None,
|
|
|
|
|
run_env: dict,
|
|
|
|
|
start: float,
|
|
|
|
|
) -> ShellResult:
|
|
|
|
|
"""Run *command* as a subprocess with timeout enforcement."""
|
|
|
|
|
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 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 if proc.returncode is not None else -1
|
|
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-06 09:01:24 -05:00
|
|
|
async def run(
|
|
|
|
|
self,
|
|
|
|
|
command: str,
|
ruff (#169)
* polish: streamline nav, extract inline styles, improve tablet UX
- Restructure desktop nav from 8+ flat links + overflow dropdown into
5 grouped dropdowns (Core, Agents, Intel, System, More) matching
the mobile menu structure to reduce decision fatigue
- Extract all inline styles from mission_control.html and base.html
notification elements into mission-control.css with semantic classes
- Replace JS-built innerHTML with secure DOM construction in
notification loader and chat history
- Add CONNECTING state to connection indicator (amber) instead of
showing OFFLINE before WebSocket connects
- Add tablet breakpoint (1024px) with larger touch targets for
Apple Pencil / stylus use and safe-area padding for iPad toolbar
- Add active-link highlighting in desktop dropdown menus
- Rename "Mission Control" page title to "System Overview" to
disambiguate from the chat home page
- Add "Home — Timmy Time" page title to index.html
https://claude.ai/code/session_015uPUoKyYa8M2UAcyk5Gt6h
* fix(security): move auth-gate credentials to environment variables
Hardcoded username, password, and HMAC secret in auth-gate.py replaced
with os.environ lookups. Startup now refuses to run if any variable is
unset. Added AUTH_GATE_SECRET/USER/PASS to .env.example.
https://claude.ai/code/session_015uPUoKyYa8M2UAcyk5Gt6h
* refactor(tooling): migrate from black+isort+bandit to ruff
Replace three separate linting/formatting tools with a single ruff
invocation. Updates tox.ini (lint, format, pre-push, pre-commit envs),
.pre-commit-config.yaml, and CI workflow. Fixes all ruff errors
including unused imports, missing raise-from, and undefined names.
Ruff config maps existing bandit skips to equivalent S-rules.
https://claude.ai/code/session_015uPUoKyYa8M2UAcyk5Gt6h
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-11 12:23:35 -04:00
|
|
|
timeout: int | None = None,
|
|
|
|
|
working_dir: str | None = None,
|
|
|
|
|
env: dict | None = None,
|
2026-03-06 09:01:24 -05:00
|
|
|
) -> 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()
|
|
|
|
|
|
|
|
|
|
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:
|
2026-03-19 21:04:10 -04:00
|
|
|
run_env = self._build_run_env(env)
|
|
|
|
|
return await self._execute_subprocess(command, effective_timeout, cwd, run_env, start)
|
2026-03-06 09:01:24 -05:00
|
|
|
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()
|