Files
cowork-local/core/sandbox_manager.py
T
2026-08-09 20:12:05 +07:00

336 lines
12 KiB
Python

"""Sandbox Manager — central strategy manager for risk-based backend selection.
Routes commands through the safest available backend based on:
- Risk level (safe, moderate, high, critical, blocked)
- OS capability
- Policy configuration
- Administrator settings
Backend priority order:
SAFE -> direct or integrity_job_wfp
MODERATE -> integrity_job_wfp
HIGH -> appcontainer
CRITICAL -> windows_sandbox or block
BLOCKED -> always block
"""
from __future__ import annotations
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Dict, Optional
from ..security.command_risk_classifier import (
RiskLevel, RiskResult, classify_command,
)
from ..security.audit_logger import record as audit_record
_IS_WINDOWS = sys.platform == "win32"
@dataclass
class ExecutionConfig:
"""Configuration for sandbox execution."""
enabled: bool = True
default_backend: str = "appcontainer"
allow_direct_fallback: bool = False
allow_docker_fallback: bool = False
block_network_by_default: bool = True
deny_on_unknown_risk: bool = True
is_cowork_mode: bool = True
class SandboxManager:
"""Central sandbox manager that selects and routes to the right backend."""
def __init__(self, config: Optional[ExecutionConfig] = None):
self.config = config or ExecutionConfig()
self._backends: Dict[str, Any] = {}
# ---- Backend availability checks ----
def check_backend_availability(self) -> Dict[str, bool]:
"""Check which backends are available on this system."""
result: Dict[str, bool] = {
"direct": True,
"integrity_job_wfp": _IS_WINDOWS,
}
# AppContainer
try:
from .appcontainer_sandbox import is_appcontainer_available
result["appcontainer"] = is_appcontainer_available()
except Exception:
result["appcontainer"] = False
# Windows Sandbox
try:
from .windows_sandbox_vm import is_windows_sandbox_available
result["windows_sandbox"] = is_windows_sandbox_available()
except Exception:
result["windows_sandbox"] = False
result["blocked"] = True # always available
return result
# ---- Backend selection ----
def select_backend(self, risk_level: RiskLevel, context: Optional[Dict] = None) -> str:
"""Select the sandbox backend based on risk level and policy."""
availability = self.check_backend_availability()
if risk_level == RiskLevel.BLOCKED:
return "blocked"
routing = {
RiskLevel.SAFE: ["integrity_job_wfp", "direct"],
RiskLevel.MODERATE: ["integrity_job_wfp", "direct"],
RiskLevel.HIGH: ["appcontainer", "integrity_job_wfp"],
RiskLevel.CRITICAL: ["windows_sandbox", "appcontainer", "blocked"],
}
preferred = routing.get(risk_level, ["blocked"])
for backend in preferred:
if availability.get(backend):
return backend
# Fallback logic
if self.config.allow_direct_fallback and risk_level != RiskLevel.CRITICAL:
return "direct"
return "blocked"
# ---- Main execution entry point ----
def run(
self,
command: str,
workdir: str = "",
context: Optional[Dict] = None,
block_network: bool = True,
timeout_sec: int = 120,
user: str = "",
project: str = "",
workspace: str = "",
cancel: Optional[Callable[[], bool]] = None,
) -> Dict[str, Any]:
"""Execute a command through the appropriate sandbox backend.
Flow:
1. Classify risk
2. Select backend
3. Execute through sandbox
4. Log audit event
5. Return normalized result
"""
if not self.config.enabled:
# Sandbox disabled — use direct execution (legacy path)
return self._run_direct(command, workdir, block_network, timeout_sec, cancel)
# Step 1: Classify risk
risk = classify_command(command, is_cowork_mode=self.config.is_cowork_mode)
# Step 2: If blocked, deny immediately
if risk.blocked:
denial = "Command blocked by security policy: " + "; ".join(risk.reasons)
audit_record(
action_type="run_command",
result_status="denied",
user=user, project=project, workspace=workspace,
prompt_category="command_execution",
risk_score=risk.score,
backend_selected="blocked",
command=command,
working_directory=workdir,
network_blocked=True,
denial_reason=denial,
)
return {
"ok": False,
"stdout": "",
"stderr": denial,
"returncode": -1,
"sandbox": "blocked",
"risk_level": risk.level.value,
"risk_score": risk.score,
}
# Step 3: Select backend
effective_network = block_network or self.config.block_network_by_default
backend = self.select_backend(risk.level, context)
# Step 4: Execute
if backend == "blocked":
denial = f"Risk level '{risk.level.value}' requires stronger isolation than available"
audit_record(
action_type="run_command",
result_status="denied",
user=user, project=project, workspace=workspace,
prompt_category="command_execution",
risk_score=risk.score,
backend_selected="blocked",
command=command,
working_directory=workdir,
network_blocked=True,
denial_reason=denial,
)
return {
"ok": False,
"stdout": "",
"stderr": denial,
"returncode": -1,
"sandbox": "blocked",
"risk_level": risk.level.value,
"risk_score": risk.score,
}
result = self._execute_with_backend(
backend, command, workdir, effective_network, timeout_sec, cancel
)
# Step 5: Audit log
audit_record(
action_type="run_command",
result_status="executed" if result.get("ok") else "error",
user=user, project=project, workspace=workspace,
prompt_category="command_execution",
risk_score=risk.score,
backend_selected=backend,
command=command,
working_directory=workdir,
network_blocked=effective_network,
return_code=result.get("returncode", -1),
)
result["risk_level"] = risk.level.value
result["risk_score"] = risk.score
return result
# ---- Backend execution dispatch ----
def _execute_with_backend(
self,
backend: str,
command: str,
workdir: str,
block_network: bool,
timeout_sec: int,
cancel: Optional[Callable[[], bool]] = None,
) -> Dict[str, Any]:
"""Dispatch execution to the selected backend."""
if backend == "direct":
return self._run_direct(command, workdir, block_network, timeout_sec, cancel)
if backend == "integrity_job_wfp":
from .integrity_sandbox import IntegritySandbox
sb = IntegritySandbox()
return sb.run_command(
command, workdir=workdir,
block_network=block_network, timeout_sec=timeout_sec, cancel=cancel,
)
if backend == "appcontainer":
from .appcontainer_sandbox import get_sandbox
sb = get_sandbox()
return sb.run_command(
command, workdir=workdir,
block_network=block_network, timeout_sec=timeout_sec,
)
if backend == "windows_sandbox":
from .windows_sandbox_vm import WindowsSandboxVM
sb = WindowsSandboxVM()
return sb.run_command(
command, workdir=workdir,
block_network=block_network, timeout_sec=timeout_sec,
)
return {
"ok": False,
"stdout": "",
"stderr": f"Unknown backend: {backend}",
"returncode": -1,
"sandbox": "error",
}
def _run_direct(
self,
command: str,
workdir: str,
block_network: bool,
timeout_sec: int,
cancel: Optional[Callable[[], bool]] = None,
) -> Dict[str, Any]:
"""Direct process execution (least isolated, fallback only).
When ``cancel`` is given, routes through ``deps.run_cancellable`` so the
Stop button / Kill Switch can interrupt a running command (it polls
cancel and kills the whole process tree). When ``cancel`` is None the
original blocking ``subprocess.run`` path is used unchanged."""
import subprocess
import os
env = os.environ.copy()
if block_network:
from .deps import network_blocked_env
env = network_blocked_env(env)
if cancel is not None:
from .deps import run_cancellable
try:
rc, output, cancelled, timed_out, resource_exceeded = run_cancellable(
command, cwd=workdir or None, timeout=timeout_sec,
cancel=cancel, shell=True, env=env,
)
except Exception as exc: # noqa: BLE001
return {"ok": False, "stdout": "", "stderr": str(exc),
"returncode": -1, "sandbox": "direct"}
if cancelled:
stderr = "Cancelled by user."
elif timed_out:
stderr = f"Timeout after {timeout_sec}s"
elif resource_exceeded:
stderr = "Resource limit exceeded."
else:
stderr = ""
return {
"ok": (rc == 0) and not (cancelled or timed_out or resource_exceeded),
"stdout": output,
"stderr": stderr,
"returncode": rc if rc is not None else -1,
"sandbox": "direct",
}
try:
proc = subprocess.run(
command,
shell=True,
cwd=workdir or None,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout_sec,
)
return {
"ok": proc.returncode == 0,
"stdout": proc.stdout.decode("utf-8", errors="replace"),
"stderr": proc.stderr.decode("utf-8", errors="replace"),
"returncode": proc.returncode,
"sandbox": "direct",
}
except subprocess.TimeoutExpired:
return {
"ok": False,
"stdout": "",
"stderr": f"Timeout after {timeout_sec}s",
"returncode": -1,
"sandbox": "direct",
}
except Exception as exc:
return {
"ok": False,
"stdout": "",
"stderr": str(exc),
"returncode": -1,
"sandbox": "direct",
}