CI / test (push) Canceled after 0s
## 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
382 lines
14 KiB
Python
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",
|
|
}
|