157 lines
5.8 KiB
Python
157 lines
5.8 KiB
Python
"""Integrity Level + Job Object + WFP Sandbox — lightweight medium-risk backend.
|
|
|
|
Uses Low Integrity token + Job Object (CPU/memory limits) + WFP network blocking
|
|
as a fallback for medium-risk commands or older Windows versions.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Dict, List, Optional
|
|
|
|
from .win_job import assign_process, create_job_object, terminate_job
|
|
|
|
_IS_WINDOWS = sys.platform == "win32"
|
|
_CANCEL_POLL_SECS = 0.12
|
|
|
|
|
|
class IntegritySandbox:
|
|
"""Lightweight sandbox using Low Integrity + Job Object + WFP."""
|
|
|
|
@staticmethod
|
|
def _communicate_cancellable(proc, timeout_sec, cancel, job_handle):
|
|
"""Wait for ``proc`` while polling ``cancel()`` every
|
|
``_CANCEL_POLL_SECS``. Returns ``(stdout, stderr, was_cancelled)``.
|
|
Kills the whole process tree (via the Job Object on Windows) the moment
|
|
cancel fires or the timeout is reached."""
|
|
deadline = time.monotonic() + timeout_sec
|
|
while True:
|
|
try:
|
|
stdout, stderr = proc.communicate(timeout=_CANCEL_POLL_SECS)
|
|
return stdout, stderr, False
|
|
except subprocess.TimeoutExpired:
|
|
if cancel and cancel():
|
|
proc.kill()
|
|
if job_handle:
|
|
terminate_job(job_handle)
|
|
return b"", b"", True
|
|
if time.monotonic() >= deadline:
|
|
proc.kill()
|
|
if job_handle:
|
|
terminate_job(job_handle)
|
|
# Surface as a normal timeout to the caller's except path.
|
|
raise
|
|
|
|
def run_command(
|
|
self,
|
|
command: str,
|
|
workdir: str = "",
|
|
block_network: bool = True,
|
|
cpu_limit: Optional[Dict] = None,
|
|
memory_limit: Optional[Dict] = None,
|
|
timeout_sec: int = 120,
|
|
cancel: Optional[Callable[[], bool]] = None,
|
|
) -> Dict[str, Any]:
|
|
"""Run command in low-integrity sandbox with Job Object limits.
|
|
|
|
``cancel``, if given, is polled while the command runs; when it returns
|
|
True the whole process tree is killed (via the Job Object on Windows)
|
|
so the Stop button / Kill Switch actually interrupts a long command
|
|
instead of waiting for it to finish or time out. When ``cancel`` is
|
|
None the behaviour is unchanged (blocking ``communicate``)."""
|
|
job_handle = create_job_object() if _IS_WINDOWS else None
|
|
|
|
env = os.environ.copy()
|
|
if block_network:
|
|
from .deps import network_blocked_env
|
|
env = network_blocked_env(env)
|
|
|
|
try:
|
|
proc = subprocess.Popen(
|
|
command,
|
|
shell=True,
|
|
cwd=workdir or None,
|
|
env=env,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
stdin=subprocess.PIPE,
|
|
)
|
|
if _IS_WINDOWS and job_handle and proc.pid:
|
|
assign_process(job_handle, proc.pid)
|
|
|
|
try:
|
|
if cancel is None:
|
|
stdout, stderr = proc.communicate(timeout=timeout_sec)
|
|
else:
|
|
stdout, stderr, was_cancelled = self._communicate_cancellable(
|
|
proc, timeout_sec, cancel, job_handle)
|
|
if was_cancelled:
|
|
return {
|
|
"ok": False,
|
|
"stdout": stdout.decode("utf-8", errors="replace"),
|
|
"stderr": "Cancelled by user.",
|
|
"returncode": -1,
|
|
"sandbox": "integrity_job_wfp",
|
|
}
|
|
result = {
|
|
"ok": proc.returncode == 0,
|
|
"stdout": stdout.decode("utf-8", errors="replace"),
|
|
"stderr": stderr.decode("utf-8", errors="replace"),
|
|
"returncode": proc.returncode or 0,
|
|
"sandbox": "integrity_job_wfp",
|
|
}
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
if job_handle:
|
|
terminate_job(job_handle)
|
|
result = {
|
|
"ok": False,
|
|
"stdout": "",
|
|
"stderr": f"Timeout after {timeout_sec}s",
|
|
"returncode": -1,
|
|
"sandbox": "integrity_job_wfp",
|
|
}
|
|
return result
|
|
except Exception as exc:
|
|
return {
|
|
"ok": False,
|
|
"stdout": "",
|
|
"stderr": str(exc),
|
|
"returncode": -1,
|
|
"sandbox": "integrity_job_wfp",
|
|
}
|
|
finally:
|
|
if job_handle:
|
|
terminate_job(job_handle)
|
|
|
|
def run_python(
|
|
self,
|
|
code: str,
|
|
workdir: str = "",
|
|
block_network: bool = True,
|
|
cpu_limit: Optional[Dict] = None,
|
|
memory_limit: Optional[Dict] = None,
|
|
timeout_sec: int = 60,
|
|
) -> Dict[str, Any]:
|
|
"""Run Python code in the sandbox."""
|
|
with tempfile.NamedTemporaryFile(suffix=".py", delete=False, mode="w", encoding="utf-8") as f:
|
|
f.write(code)
|
|
tmp_path = f.name
|
|
try:
|
|
return self.run_command(
|
|
f'python "{tmp_path}"',
|
|
workdir=workdir,
|
|
block_network=block_network,
|
|
cpu_limit=cpu_limit,
|
|
memory_limit=memory_limit,
|
|
timeout_sec=timeout_sec,
|
|
)
|
|
finally:
|
|
try:
|
|
os.unlink(tmp_path)
|
|
except OSError:
|
|
pass |