File size: 5,594 Bytes
28a08e7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""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}