Terminal / tools /_shell_safety.py
Baida-A
Initial clean deploy (Reverse Proxy removed)
28a08e7
Raw
History Blame
5.59 kB
"""backend/tools/_shell_safety.py — Shell execution safety (single source of truth)
Chiude GAP-11 (registry.py) e GAP-12 (exec.py) con un'unica implementazione.
Importare da qui. Non duplicare la logica altrove.
Algoritmo:
1. Blocca metacaratteri shell (previene bypass allowlist)
2. shlex.split() — tokenizzazione sicura, rileva quoting anomalo
3. frozenset argv[0] — allowlist letterale, non regex di prefisso
4. Regole per git (subcmd) e curl/wget (solo https://)
5. create_subprocess_exec / subprocess.run con lista argv — NO shell=True
"""
from __future__ import annotations
import asyncio, os, re, shlex, subprocess
from typing import Optional
_METACHAR_RE = re.compile(r'[;&|`<>\n\r]|\$[\(\{]')
_ALLOWED: frozenset = frozenset({
"ls", "cat", "echo", "pwd", "whoami", "date", "uname",
"python3", "python", "node", "npm", "pip3", "pip", "pnpm",
"grep", "find", "head", "tail", "wc", "sort", "uniq", "diff",
"mkdir", "touch", "cp", "mv", "chmod", "git", "curl", "wget",
})
_GIT_OK: frozenset = frozenset({"status", "log", "diff", "show", "branch", "remote"})
def safe_shell_env() -> dict:
"""Env pulita — nessun secret del processo padre ereditato."""
return {
"HOME": "/tmp", "TMPDIR": "/tmp",
"PATH": os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin"),
"LANG": os.environ.get("LANG", "en_US.UTF-8"),
"TERM": "xterm-256color",
}
# SEC-FS-JAIL: comandi che accettano path come argomenti — devono restare
# confinati alla stessa root di _safe_fs_path in registry.py, altrimenti
# l'allowlist shell diventa un bypass per leggere/scrivere file arbitrari
# (es. 'cat /etc/passwd', 'cp /run/secrets/x /tmp/y' erano permessi prima).
_PATH_TAKING_CMDS = frozenset({
"cat", "cp", "mv", "touch", "mkdir", "chmod", "find", "head", "tail", "diff",
})
def _fs_jail_root() -> str:
return os.path.realpath(os.getenv("FS_TOOL_ROOT", os.getcwd()))
def _path_is_jailed(candidate: str, root: str) -> bool:
_abs = candidate if os.path.isabs(candidate) else os.path.join(root, candidate)
_resolved = os.path.realpath(_abs)
return _resolved == root or _resolved.startswith(root + os.sep)
def validate_shell_command(command: str) -> Optional[str]:
"""
Valida command. Ritorna None se OK, stringa di errore se rifiutato.
Thread-safe, sincrona, zero I/O.
"""
cmd = command.strip()
if not cmd:
return "comando vuoto"
if _METACHAR_RE.search(cmd):
return "metacaratteri non permessi (; & | ` < > newline $() ${})"
try:
argv = shlex.split(cmd)
except ValueError as e:
return f"parsing fallito: {e}"
if not argv:
return "comando vuoto dopo parsing"
exe = argv[0].lower()
if exe not in _ALLOWED:
return f"comando non permesso: '{exe}' (ammessi: {', '.join(sorted(_ALLOWED))})"
if exe == "git":
if len(argv) < 2 or argv[1].lower() not in _GIT_OK:
return f"git sub-comando non permesso (ammessi: {', '.join(sorted(_GIT_OK))})"
elif exe in ("curl", "wget"):
if not any(a.startswith("https://") for a in argv[1:]):
return f"{exe}: solo URL https://"
if exe in _PATH_TAKING_CMDS:
_root = _fs_jail_root()
for _arg in argv[1:]:
if _arg.startswith("-"):
continue # flag, non un path
if not _path_is_jailed(_arg, _root):
return f"{exe}: path fuori dalla sandbox consentita ('{_arg}')"
return None
async def run_shell_safe(command: str, cwd: Optional[str] = None, timeout: int = 30) -> dict:
"""Asincrona, NO shell=True. Per exec.py / endpoint HTTP."""
err = validate_shell_command(command)
if err:
return {"ok": False, "stdout": "", "stderr": "", "error": err, "code": -1}
argv = shlex.split(command.strip())
try:
proc = await asyncio.create_subprocess_exec(
*argv, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, cwd=cwd, env=safe_shell_env())
out, er2 = await asyncio.wait_for(proc.communicate(), timeout=float(timeout))
return {"ok": proc.returncode == 0,
"stdout": out.decode("utf-8", errors="replace")[:8192],
"stderr": er2.decode("utf-8", errors="replace")[:2048],
"error": None, "code": proc.returncode}
except asyncio.TimeoutError:
try:
proc.kill()
except Exception:
pass
return {"ok": False, "stdout": "", "stderr": "", "error": f"timeout ({timeout}s)", "code": -1}
except Exception as exc:
return {"ok": False, "stdout": "", "stderr": "", "error": str(exc), "code": -1}
def run_shell_safe_sync(command: str, cwd: Optional[str] = None, timeout: int = 30) -> dict:
"""Sincrona, NO shell=True. Per fallback subprocess in registry.py."""
err = validate_shell_command(command)
if err:
return {"ok": False, "stdout": "", "stderr": "", "error": err, "code": -1}
argv = shlex.split(command.strip())
try:
r = subprocess.run(argv, capture_output=True, text=True,
timeout=min(timeout, 120), cwd=cwd, env=safe_shell_env())
return {"ok": r.returncode == 0, "stdout": r.stdout[:8192],
"stderr": r.stderr[:2048], "error": None, "code": r.returncode}
except subprocess.TimeoutExpired:
return {"ok": False, "stdout": "", "stderr": "", "error": f"timeout ({timeout}s)", "code": -1}
except Exception as exc:
return {"ok": False, "stdout": "", "stderr": "", "error": str(exc), "code": -1}