Files
cowork-local/core/sandbox_manager.py
a03a740ea1
CI / test (push) Canceled after 0s
Feature/perf ui logic (#13)
## Summary

Nhánh `feature/perf-ui-logic`: tối ưu hiệu năng/UI, sửa lỗi workspace và điều hướng, và làm cho công tắc **"Block network for agent-run commands"** chặn thật mọi đường ra mạng của app, **trừ nhà cung cấp AI**.

**Chặn mạng (b78d483, 8c497cf, 10b8379)**
- Bộ kiểm tra chung `application/network/network_guard.py`, nối vào cấu hình đang chạy ở Composition Root: đổi công tắc trong Settings là có hiệu lực ngay.
- Lệnh shell của agent và task script chạy trong **Windows AppContainer không có quyền mạng**: kernel chặn socket, ping, DNS, Invoke-WebRequest… Không cần quyền admin. Không cô lập được thì lệnh bị từ chối, không chạy khi mạng còn mở. macOS dùng `sandbox-exec`, Linux dùng `unshare --net`.
- Bật chặn thì: dừng MCP đang chạy, không khởi động server mới, từ chối lời gọi connector; Microsoft 365 (đăng nhập, Graph, đồng bộ cloud, rules, mail), Teams, nút Test REST/Jira/MCP, link đính kèm task, pip tự cài và tài nguyên web trong xem trước HTML đều bị từ chối.
- Vẫn dùng được: chat, tải danh sách model, thử model; tool OneDrive đã đồng bộ trên máy.
- Công tắc **mặc định tắt** khi mở app lần đầu; nhãn giữ nguyên như cũ.
- Xem trước HTML trong tab Folder giờ hiện được ảnh/CSS/JS từ web khi mạng mở (trước đây trang `file://` không tải được).
- Sửa lỗi app văng khi chuyển tab Graph → Folder: profile WebEngine của trang xem trước bị huỷ trước trang (`0xc0000409` trong Qt6Core.dll); giờ dùng một profile chung thuộc QApplication.
- Không cấp quyền AppContainer kế thừa lên thư mục chứa PySide6 (nếu có, Chromium không nạp được `Qt6WebEngineCore.dll` và tab Graph trắng).
- Cột mục lục trong Settings tính độ rộng theo kiểu chữ của mục đang chọn, "Sandbox Security Layer" không còn bị cắt.

**Các commit khác trong nhánh**
- `b7a41b3` mỗi thư mục làm việc chỉ thuộc về một project · `bbdf146` bật nút Sửa project khi đã có project đang mở
- `35f24e0`, `cc8d5c8`, `2e3e719`, `c699beb` canh hàng / khoảng cách thanh điều hướng
- `2759ed9` không refresh workspace khi chuyển tab Cowork · `7607f44` checkpoint hiệu năng và UI
- `8548c1e` chặn tool mạng của agent · `caf3b74` renderer GraphRAG native trên macOS · `c00b83c` khoảng cách metadata hàng project · `a04f8a9` ẩn picker workspace cloud

## Change Type

- [x] Cowork feature
- [x] Bug fix
- [ ] Core AI contribution
- [x] Test / hardening
- [x] Performance
- [ ] Documentation

## Related Work

Cowork Task:

Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?
- Mọi đường ra mạng do app tự mở, trừ nhà cung cấp AI (xem Summary).
- Test: `tests/test_network_guard_lanes.py` (có bài chạy AppContainer thật trên Windows), `tests/ui/test_html_preview_remote_images.py`.

What is intentionally NOT included?
- Chặn cả nhà cung cấp AI / chạy model trên máy (Phương án 2).
- Terminal người dùng tự gõ trong tab Folder, sinh ảnh, cơ chế tự tin chứng chỉ lạ (`tls_trust`).
- Huy hiệu trạng thái "đang chặn" trên thanh trên cùng.

## Validation

- [x] Unit tests
- [x] Integration tests
- [x] Manual verification
- [x] Regression check

Commands / evidence:
- `python -m pytest tests/test_network_guard_lanes.py tests/test_sandbox_block_network.py tests/ui -q` → chỉ còn 1 bài fail, fail cả trên `b7a41b3` (nhãn `ProjectRow` 'Project' chưa dịch, `tests/ui/test_i18n_khong_con_chu_cu.py`).
- `python -m pytest tests -q --ignore=tests/ui` → 4 bài fail, cả 4 cũng fail trên `b7a41b3` (`test_canonical_audit_logger`, 2 bài `test_mcp_audit_security`, `test_monitoring_tab_container`).
- Chạy cả `tests` trong một lượt thì treo ở các test dựng MainWindow trong `tests/ui`; `b7a41b3` cũng treo đúng chỗ đó.
- `check_imports.py` và `check_orphan_modules.py` PASS. `check_loc.py` báo 9 file quá dài, giống hệt trước khi sửa (không file nào do nhánh này làm dài thêm).
- Kiểm tra tay trên Windows 11: trong AppContainer, Python báo `WinError 10013`, ping/nslookup/PowerShell/curl đều không ra được mạng; cmd, git, python chạy bình thường.
- Kiểm tra tay trên Windows 11: xem trước HTML tải được 4/4 tài nguyên web khi mạng mở, 0/4 khi bật chặn; tab Graph hoạt động; tạo/huỷ trang xem trước nhiều lần không còn cảnh báo profile của Qt.

## Security Impact

Permission / credential / network / customer data impact:
- Network: khi bật công tắc, chỉ nhà cung cấp AI còn ra mạng; nội dung chat vẫn gửi tới nhà cung cấp AI.
- Permission: lần đầu chạy lệnh trong sandbox, app **thêm quyền (ACE) cho SID AppContainer** trên thư mục làm việc (ghi), thư mục cài Python gốc (đọc), gốc venv và `Scripts` (đọc). Không xoá quyền nào. Thư mục chứa PySide6 không bao giờ nhận quyền kế thừa; một quyền kế thừa sai trên venv (từ bản dev trước) được tự gỡ.
- Credential: không đổi. Khi chặn, trạng thái đăng nhập M365 được đọc thẳng từ kho token trên máy, không dựng MSAL.

## Compatibility

- [x] No breaking change
- [ ] Breaking change documented

Ghi chú: `block_network` mặc định đổi từ bật sang tắt cho cấu hình mới; máy đã lưu `true` thì giữ nguyên. Khi đang chặn, lệnh dùng công cụ cài trong thư mục người dùng (ngoài Program Files) có thể báo Access denied; thư viện trong venv của app không dùng được trong sandbox.

## Reviewer Notes

- `infrastructure/sandbox/appcontainer_process.py` gọi Win32 bằng ctypes (CreateAppContainerProfile, CreateProcessW với SECURITY_CAPABILITIES) và dùng `icacls` để cấp quyền: nên xem kỹ phần cấp quyền.
- `tests/conftest.py` thêm fixture autouse gỡ `network_guard` sau mỗi test, vì `build_context()` gắn cổng này ở mức process.
- `core/task_executors.py` đang đúng bằng trần LOC nên `_run_script` được tách sang `core/task_script.py`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: minhanhpkpro <minhanhpkpro@gmail.com>
Co-authored-by: Duy Le Huu <duylh19@fpt.com>
Co-authored-by: thanhnv <thanhnv.ip@gmail.com>
Reviewed-on: #13
2026-09-20 12:26:03 +00:00

382 lines
14 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):
"""Chưa dựng backend nào — chúng được tạo muộn, lúc thật sự cần chạy lệnh."""
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.
With the network blocked every backend is replaced by the same OS-level
isolation: the backends below only ever set proxy env vars, which
anything that ignores proxies (raw sockets, ping, .NET WebClient...)
walked straight past."""
if block_network or 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) # belt and braces on top of the OS block
return self._run_network_isolated(command, workdir, env, timeout_sec, cancel)
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",
}
@staticmethod
def _run_network_isolated(
command: str,
workdir: str,
env: Dict[str, str],
timeout_sec: int,
cancel: Optional[Callable[[], bool]] = None,
) -> Dict[str, Any]:
"""Run ``command`` in a process the OS keeps off the network.
Fail-closed: when the isolation cannot be set up the command is
refused (``sandbox == "blocked"``), never run with the network open."""
from .deps import run_cancellable
rc, output, cancelled, timed_out, exceeded = run_cancellable(
command, cwd=workdir or None, timeout=timeout_sec, cancel=cancel,
shell=True, env=env, isolate_network=True,
)
if rc is None and not (cancelled or timed_out or exceeded):
return {"ok": False, "stdout": "", "returncode": -1, "sandbox": "blocked",
"stderr": ("Command refused: network is blocked and the command could "
f"not be isolated from the network ({output.strip()}).")}
if cancelled:
stderr = "Cancelled by user."
elif timed_out:
stderr = f"Timeout after {timeout_sec}s"
elif exceeded:
stderr = "Resource limit exceeded."
else:
stderr = ""
return {
"ok": rc == 0 and not (cancelled or timed_out or exceeded),
"stdout": output,
"stderr": stderr,
"returncode": rc if rc is not None else -1,
"sandbox": "network_isolated",
}