File size: 13,893 Bytes
7d580fd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
03e5649
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7d580fd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
03e5649
7d580fd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
03e5649
7d580fd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
03e5649
7d580fd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
"""
exec_sandbox.py β€” Reusable secure execution sandbox (S365)

Design invariants:
- Isolated working dir per task_id (tmpfs, cleaned up guaranteed via finally)
- Stripped environment: no API keys, tokens, secrets pass through
- Hard ceiling _HARD_TIMEOUT_S β€” cannot be overridden by callers
- Sync variant (run_in_sandbox) for quality_guardian thread pool usage
- Async variant (run_in_sandbox_async) for FastAPI route usage
- Never raises exceptions β€” returns error dict on all failure paths
- RLIMIT_NOFILE/NPROC/FSIZE enforced via preexec_fn (GAP-SANDBOX-RLIMIT)

Usage:
    from api.exec_sandbox import run_in_sandbox, run_in_sandbox_async
"""
from __future__ import annotations

import asyncio
import logging
import os
import signal as _signal
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path

# ── Resource limits (GAP-SANDBOX-RLIMIT) ──────────────────────────────────────
# Gap reale confermato: subprocess senza setrlimit β†’ fd exhaustion + fork bomb.
# Fix: preexec_fn con RLIMIT_NOFILE/NPROC/FSIZE β€” gira nel figlio pre-execve.
try:
    import resource as _resource
    _RLIMIT_AVAILABLE = True
except ImportError:
    _RLIMIT_AVAILABLE = False  # Windows / ambienti senza resource module

def _set_resource_limits() -> None:
    """
    Imposta limiti hard nel subprocess figlio PRIMA di execve.
    Fail-safe per limite: se un tipo non Γ¨ supportato, salta silenziosamente.
    NON applicato ai sandbox di sessione (stato persistente li renderebbe rotti).

    Protezioni:
    - RLIMIT_NOFILE 64: previene fd exhaustion (fork + open flooding)
    - RLIMIT_NPROC  32: previene fork bomb
    - RLIMIT_FSIZE  50MB: previene filesystem flooding
    """
    if not _RLIMIT_AVAILABLE:
        return
    _limits = [
        (_resource.RLIMIT_NOFILE, (64, 64)),
        (_resource.RLIMIT_NPROC,  (32, 32)),
        (_resource.RLIMIT_FSIZE,  (50 * 1024 * 1024, 50 * 1024 * 1024)),
    ]
    for limit_type, (soft, hard) in _limits:
        try:
            _resource.setrlimit(limit_type, (soft, hard))
        except (ValueError, OSError):
            pass  # container/OS potrebbe non supportare questo limite


def _set_resource_limits_setsid() -> None:
    """GAP-PTY-FIX: setsid() + resource limits nel subprocess figlio.

    os.setsid() crea un nuovo process group leader β†’ tutti i processi figli
    del subprocess appartengono al nuovo group. Su timeout, os.killpg() invia
    SIGKILL a tutto il process group, non solo al PID diretto.
    Questo elimina i processi orfani (bash shell β†’ subcomandi β†’ pipeline)
    che prima sopravvivevano dopo proc.kill() (solo PID diretto).
    """
    os.setsid()            # nuovo session leader β†’ nuovo process group
    _set_resource_limits() # poi applica RLIMIT come prima

# ── Constants ─────────────────────────────────────────────────────────────────
_HARD_TIMEOUT_S: int = 12       # absolute ceiling per task generici β€” quality_guardian uses 12
_EXTENDED_TIMEOUT_S: int = 120  # Shadow Execution Kernel β€” build/test/install task

# Pattern che attivano il timeout esteso (build, test, install β€” legittimamente lenti)
_EXTENDED_CMD_PATTERNS: frozenset[str] = frozenset({
    'npm install', 'pnpm install', 'pip install', 'pip3 install',
    'pnpm build', 'npm run build', 'yarn build',
    'pytest', 'python -m pytest', 'npm test', 'cargo build',
    'go build', 'mvn', 'gradle', 'make', 'cmake',
})

def _resolve_timeout(code: str, requested: int) -> int:
    """Shadow Execution Kernel: usa _EXTENDED_TIMEOUT_S per build/test/install.
    Doppio token: il validatore B puΓ² autorizzare task con complexity=high.
    """;
    if any(p in code for p in _EXTENDED_CMD_PATTERNS):
        return min(requested, _EXTENDED_TIMEOUT_S)
    return min(requested, _HARD_TIMEOUT_S)

# Env vars that are safe to pass into the sandbox (pure computation, no secrets)
_SAFE_ENV_KEYS: frozenset[str] = frozenset({
    "PATH", "LANG", "LC_ALL", "LC_CTYPE", "TERM",
    "PYTHONDONTWRITEBYTECODE", "PYTHONUNBUFFERED",
    "NODE_ENV",
})


# ── Environment stripping ─────────────────────────────────────────────────────

def _safe_env(sandbox_dir: str) -> dict[str, str]:
    """Return env with no secrets/tokens β€” only safe computation vars."""
    env: dict[str, str] = {k: v for k, v in os.environ.items() if k in _SAFE_ENV_KEYS}
    env["HOME"]   = sandbox_dir
    env["TMPDIR"] = sandbox_dir
    env["PWD"]    = sandbox_dir
    env.setdefault("PATH", "/usr/local/bin:/usr/bin:/bin")
    return env



# ── Venv Python helper (S756) ─────────────────────────────────────────────────
_VENV_PYTHON_PATH = '/data/venv/bin/python3'
_logger = logging.getLogger('agente_ai')
if not os.path.exists(_VENV_PYTHON_PATH):  # GAP-5: visibilitΓ  diagnostica
    _logger.warning('S756: /data/venv non trovato β€” pip install non persistente tra restart')

def _venv_python() -> str:
    """Usa /data/venv/bin/python3 se il venv S755 esiste, altrimenti sys.executable."""
    return _VENV_PYTHON_PATH if os.path.exists(_VENV_PYTHON_PATH) else sys.executable


# ── Sandbox lifecycle ─────────────────────────────────────────────────────────

def create_sandbox(task_id: str = "default") -> Path:
    """Create isolated working dir. Caller MUST call cleanup_sandbox() afterwards."""
    prefix = f"agent_{task_id[:28].replace('/', '_')}_"
    return Path(tempfile.mkdtemp(prefix=prefix))


def cleanup_sandbox(sandbox_dir: "Path | str") -> None:
    """Remove sandbox dir. Silent on any failure."""
    try:
        shutil.rmtree(str(sandbox_dir), ignore_errors=True)
    except Exception:
        pass  # S365: cleanup is best-effort, never fatal


# ── Sync execution (quality_guardian runs this in a thread-pool executor) ─────

def run_in_sandbox(
    code: str,
    task_id: str = "default",
    timeout: int = _HARD_TIMEOUT_S,
) -> dict:
    """
    Execute Python code string in an isolated sandbox.
    Returns: {returncode, stdout, stderr}
    Never raises.
    """
    effective_timeout = _resolve_timeout(code, int(timeout)) if 'code' in dir() else min(int(timeout), _HARD_TIMEOUT_S)
    sandbox = create_sandbox(task_id)
    try:
        proc = subprocess.run(
            [_venv_python(), "-c", code],
            capture_output=True,
            text=True,
            timeout=effective_timeout,
            cwd=str(sandbox),
            env=_safe_env(str(sandbox)),
            preexec_fn=_set_resource_limits if _RLIMIT_AVAILABLE else None,  # GAP-SANDBOX-RLIMIT
        )
        return {
            "returncode": proc.returncode,
            "stdout":     proc.stdout[:8000],   # S568: allineato con async variant (era 800)
            "stderr":     proc.stderr[:4000],   # S568: allineato con async variant (era 500)
        }
    except subprocess.TimeoutExpired:
        return {"returncode": 1, "stdout": "", "stderr": f"timeout after {effective_timeout}s"}
    except Exception as e:
        return {"returncode": 1, "stdout": "", "stderr": str(e)[:300]}  # S588: 200β†’300
    finally:
        cleanup_sandbox(sandbox)  # S365: guaranteed cleanup even on exception


# ── Async execution (FastAPI routes / unified_loop) ───────────────────────────

async def run_in_sandbox_async(
    code: str,
    lang: str = "python",
    task_id: str = "default",
    timeout: float = float(_HARD_TIMEOUT_S),
) -> dict:
    """
    Async sandbox execution for FastAPI context.
    Supports: python, js/javascript, node.
    Returns: {returncode, stdout, stderr}
    Never raises.
    """
    effective_timeout = float(_resolve_timeout(code, int(timeout)))
    sandbox = create_sandbox(task_id)
    try:
        if lang in ("python", "py"):
            cmd = [_venv_python(), "-c", code]  # S756: usa venv se disponibile
        elif lang in ("js", "javascript", "node"):
            cmd = ["node", "-e", code]
        elif lang in ("bash", "shell", "sh"):
            cmd = ["bash", "-c", code]  # S679: bash support per execute_shell sandbox
        else:
            return {"returncode": 1, "stdout": "", "stderr": f"unsupported lang: {lang}"}

        proc = await asyncio.create_subprocess_exec(
            *cmd,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
            cwd=str(sandbox),
            env=_safe_env(str(sandbox)),
            preexec_fn=_set_resource_limits_setsid if _RLIMIT_AVAILABLE else os.setsid,  # GAP-PTY-FIX
        )
        try:
            stdout, stderr = await asyncio.wait_for(
                proc.communicate(), timeout=effective_timeout
            )
        except asyncio.TimeoutError:
            # GAP-PTY-FIX: killpg per terminare tutto il process group (figli inclusi)
            try:
                os.killpg(os.getpgid(proc.pid), _signal.SIGKILL)
            except (ProcessLookupError, PermissionError):
                proc.kill()  # fallback se il gruppo non Γ¨ accessibile
            await proc.wait()
            return {"returncode": -1, "stdout": "", "stderr": f"timeout after {effective_timeout}s"}

        return {
            "returncode": proc.returncode,
            "stdout":     stdout.decode("utf-8", errors="replace")[:8000],
            "stderr":     stderr.decode("utf-8", errors="replace")[:4000],
        }
    except Exception as e:
        return {"returncode": 1, "stdout": "", "stderr": str(e)[:300]}  # S589: 200β†’300
    finally:
        cleanup_sandbox(sandbox)  # S365: guaranteed cleanup


# ── Session-based execution (S694: Persistenza sandbox) ───────────────────
import threading as _threading
import time as _time


class _SessionSandboxManager:
    """Singleton: gestisce sandbox persistenti per session_id.
    La sandbox sopravvive tra le chiamate β€” permette installare lib e riusarle.
    """
    _MAX_SESSIONS = 30
    _TTL_S = 1800  # 30 min inattivitΓ 

    def __init__(self) -> None:
        self._sessions: dict[str, dict] = {}
        self._lock = _threading.Lock()

    def get_or_create(self, session_id: str) -> Path:
        with self._lock:
            if session_id in self._sessions:
                self._sessions[session_id]['last_used'] = _time.monotonic()
                return Path(self._sessions[session_id]['path'])
            if len(self._sessions) >= self._MAX_SESSIONS:
                oldest = min(self._sessions, key=lambda k: self._sessions[k]['last_used'])
                self._evict(oldest)
            path = tempfile.mkdtemp(prefix=f"sess_{session_id[:16].replace('/', '_')}_")
            self._sessions[session_id] = {'path': path, 'last_used': _time.monotonic()}
            return Path(path)

    def evict(self, session_id: str) -> None:
        with self._lock:
            self._evict(session_id)

    def _evict(self, session_id: str) -> None:
        entry = self._sessions.pop(session_id, None)
        if entry:
            shutil.rmtree(entry['path'], ignore_errors=True)

    def prune_stale(self) -> int:
        now = _time.monotonic()
        with self._lock:
            dead = [sid for sid, e in self._sessions.items()
                    if now - e['last_used'] > self._TTL_S]
            for sid in dead:
                self._evict(sid)
        return len(dead)


_session_manager = _SessionSandboxManager()


async def run_in_sandbox_session(
    code: str,
    lang: str = "python",
    session_id: str = "default",
    timeout: float = float(_HARD_TIMEOUT_S),
) -> dict:
    """
    Esegui codice in una sandbox persistente per session_id.
    La sandbox sopravvive tra le chiamate β€” stato, file e librerie installate
    restano disponibili per la stessa sessione (fino a TTL o evict manuale).
    Returns: {returncode, stdout, stderr, session_id}
    Never raises.
    """
    effective_timeout = float(_resolve_timeout(code, int(timeout)))
    sandbox = _session_manager.get_or_create(session_id)
    try:
        if lang in ("python", "py"):
            cmd = [sys.executable, "-c", code]
        elif lang in ("js", "javascript", "node"):
            cmd = ["node", "-e", code]
        elif lang in ("bash", "shell", "sh"):
            cmd = ["bash", "-c", code]
        else:
            return {"returncode": 1, "stdout": "", "stderr": f"unsupported lang: {lang}"}

        proc = await asyncio.create_subprocess_exec(
            *cmd,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
            cwd=str(sandbox),
            env=_safe_env(str(sandbox)),
        )
        try:
            stdout, stderr = await asyncio.wait_for(
                proc.communicate(), timeout=effective_timeout
            )
        except asyncio.TimeoutError:
            proc.kill()
            await proc.wait()
            return {"returncode": -1, "stdout": "", "stderr": f"timeout after {effective_timeout}s", "session_id": session_id}

        return {
            "returncode": proc.returncode,
            "stdout":     stdout.decode("utf-8", errors="replace")[:8000],
            "stderr":     stderr.decode("utf-8", errors="replace")[:4000],
            "session_id": session_id,
        }
    except Exception as e:
        return {"returncode": 1, "stdout": "", "stderr": str(e)[:300], "session_id": session_id}
    # NON chiama cleanup_sandbox β€” la sandbox persiste per tutta la sessione