Spaces:
Running
Running
File size: 5,862 Bytes
cbe92de | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 | """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), python -m 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", "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"})
_PYTHON_MODULES_BLOCKED: frozenset = frozenset({"pip", "pip3", "ensurepip"})
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 ("python", "python3") and len(argv) >= 3 and argv[1] == "-m":
if argv[2].lower() in _PYTHON_MODULES_BLOCKED:
return f"modulo Python non permesso: '{argv[2]}'"
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}
|