Spaces:
Running
Running
| """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} | |