diff --git a/application/network/__init__.py b/application/network/__init__.py new file mode 100644 index 0000000..d1abf7e --- /dev/null +++ b/application/network/__init__.py @@ -0,0 +1,5 @@ +"""Application services for the "Block network" switch (Sandbox Security Layer).""" + +from .network_guard import NetworkBlockedError, bind, ensure_allowed, is_blocked, refusal + +__all__ = ["NetworkBlockedError", "bind", "ensure_allowed", "is_blocked", "refusal"] diff --git a/application/network/network_guard.py b/application/network/network_guard.py new file mode 100644 index 0000000..0d655f4 --- /dev/null +++ b/application/network/network_guard.py @@ -0,0 +1,61 @@ +"""One gate for every outbound connection the app makes on its own. + +The "Block network" switch (``agent_security.block_network``) used to be read +only where agent tools run, so Microsoft 365, Teams, connector test buttons, +scheduled task scripts, pip auto-installs and HTML previews still reached the +internet while Monitoring said "Network: blocked". Each of those now asks +this module first. + +The AI provider path (chat, model list, model test) deliberately does NOT go +through here: with the switch on the user still talks to the model, but the +app fetches nothing else on the model's or its own behalf. + +The module holds a *reader*, not a copy of the flag: the Composition Root +binds it to the live config once (``presentation/shell/bootstrap.py``), so a +change saved in Settings takes effect on the very next call. Nothing bound +(unit tests, helper subprocesses) means "not blocked", the pre-switch default. +""" +from __future__ import annotations + +import threading +from typing import Callable, Optional + +_lock = threading.Lock() +_reader: Optional[Callable[[], bool]] = None + + +class NetworkBlockedError(PermissionError): + """Raised by :func:`ensure_allowed` while the switch is on.""" + + +def bind(reader: Optional[Callable[[], bool]]) -> None: + """Install the callable that says whether the network is blocked right now.""" + global _reader + with _lock: + _reader = reader + + +def is_blocked() -> bool: + """True while "Block network" is on. A failing reader counts as blocked.""" + reader = _reader + if reader is None: + return False + try: + return bool(reader()) + except Exception: # noqa: BLE001 - fail closed: an unreadable switch must not open the network + return True + + +def refusal(purpose: str) -> str: + """The message shown (to the user or the model) when ``purpose`` is refused.""" + return (f"{purpose}: network access is blocked by the Sandbox Security Layer " + "(\"Block network\" is on in Settings). Only the AI provider may be reached.") + + +def ensure_allowed(purpose: str) -> None: + """Raise :class:`NetworkBlockedError` if ``purpose`` may not go online now.""" + if is_blocked(): + raise NetworkBlockedError(refusal(purpose)) + + +__all__ = ["NetworkBlockedError", "bind", "ensure_allowed", "is_blocked", "refusal"] diff --git a/application/workspaces/file_preview_helpers.py b/application/workspaces/file_preview_helpers.py index 3f1c78f..868db5c 100644 --- a/application/workspaces/file_preview_helpers.py +++ b/application/workspaces/file_preview_helpers.py @@ -21,9 +21,14 @@ def pptx_available() -> bool: try: from cowork_local.core.deps import ensure_module - _PPTX_READY = ensure_module("pptx", "python-pptx") is not None + ready = ensure_module("pptx", "python-pptx") is not None except Exception: # noqa: BLE001 - _PPTX_READY = False + ready = False + from ..network import network_guard + + if ready or not network_guard.is_blocked(): + _PPTX_READY = ready # a refusal under "Block network" is retried later + return ready return _PPTX_READY diff --git a/config.py b/config.py index e40be0c..b05951e 100644 --- a/config.py +++ b/config.py @@ -100,11 +100,11 @@ DEFAULT_CONFIG: Dict[str, Any] = { "resource_limit_cpu_percent": 80, # 0 = unlimited; caps a run_command/install_package process TREE's total CPU% "resource_limit_memory_mb": 2048, # 0 = unlimited; caps total RSS memory (MB) "resource_limit_disk_mb": 512, # 0 = unlimited; caps total disk read+write (MB) - # Cut the agent off the network: proxy env pointed at a black hole for - # agent-run shell commands, PLUS a flat refusal from every tool tagged - # ToolCapability.NETWORK (fetch_url, jira_*, install_package) — those - # reach the net in-process, where the proxy trick has nothing to act on. - "block_network": True, + # "Block network": every outbound connection except the AI provider is + # refused (application/network/network_guard.py), and agent shell + # commands / task scripts run in a network-less AppContainer. + # OFF on first launch — the user turns it on in Settings. + "block_network": False, # Allow the agent's fetch_url tool to read web pages / online documents / # SharePoint-OneDrive share links. Its own toggle — reading a URL for info # is safe and useful, so this defaults ON — but block_network outranks it: diff --git a/core/chat_agent.py b/core/chat_agent.py index 1157059..4cdb452 100644 --- a/core/chat_agent.py +++ b/core/chat_agent.py @@ -527,7 +527,7 @@ def run_cowork( preview = {"kind": "info", "title": name, "text": str(args)} emit({"type": "tool_proposed", "id": tc_id, "name": name, "args": args, "preview": preview}) - if ctx.block_network: + if ctx.block_network and not name.startswith("ms365_local__"): result = {"ok": False, "output": ( f"{name}: network access is blocked by the Sandbox Security Layer " '("Block network for agent-run commands" is on in Settings).')} diff --git a/core/code_agent.py b/core/code_agent.py index a31cdc3..f65259d 100644 --- a/core/code_agent.py +++ b/core/code_agent.py @@ -327,7 +327,7 @@ def run_code( else: emit({"type": "tool_start", "id": tc_id, "name": name}) if is_extra and extra_executor is not None: - if ctx.block_network: + if ctx.block_network and not name.startswith("ms365_local__"): result = {"ok": False, "output": ( f"{name}: network access is blocked by the Sandbox Security Layer " '("Block network for agent-run commands" is on in Settings).')} diff --git a/core/deps.py b/core/deps.py index 8ec5d10..dfc99c7 100644 --- a/core/deps.py +++ b/core/deps.py @@ -91,6 +91,7 @@ def run_cancellable( on_output: Optional[Callable[[str], None]] = None, env: Optional[Dict[str, str]] = None, limits: Optional[Dict[str, float]] = None, + isolate_network: bool = False, ) -> Tuple[Optional[int], str, bool, bool, bool]: """Run a subprocess so the Stop button can actually interrupt it. @@ -117,17 +118,27 @@ def run_cancellable( a failure to create/assign the job just means the existing taskkill fallback is used, same as before this was added. + ``isolate_network`` runs ``args`` as a shell command that the OS keeps + off the network (see ``infrastructure/sandbox/network_isolation.py``); + if that isolation cannot be set up the command is NOT run. + Returns ``(returncode, combined_output, cancelled, timed_out, resource_exceeded)``; on a failure to even launch the process, ``returncode`` is ``None`` and the output holds the launch error.""" cancel = cancel or (lambda: False) popen_kwargs = {} if sys.platform == "win32" else {"start_new_session": True} try: - proc = subprocess.Popen( - args, shell=shell, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - text=True, bufsize=1, env=env, **popen_kwargs, - ) - except OSError as exc: + if isolate_network: + from ..infrastructure.sandbox.network_isolation import spawn_without_network + + command = args if isinstance(args, str) else subprocess.list2cmdline(args) + proc = spawn_without_network(command, cwd, env) + else: + proc = subprocess.Popen( + args, shell=shell, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, bufsize=1, env=env, **popen_kwargs, + ) + except (OSError, RuntimeError) as exc: # RuntimeError: NetworkIsolationUnavailable return None, str(exc), False, False, False with _active_pids_lock: @@ -266,6 +277,10 @@ def ensure_module(module: str, package: str | None = None): pkg = package or module if pkg in _FAILED or not _can_pip(): return None + from ..application.network import network_guard + + if network_guard.is_blocked(): + return None # not cached in _FAILED: retried once the network is back ok, _ = pip_install(pkg) if not ok: _FAILED.add(pkg) @@ -339,6 +354,10 @@ def pip_install(package: str, cancel: Optional[CancelFn] = None, name) is NOT retried, since repeating it would just waste time.""" if not _can_pip(): return False, "This packaged build can't install packages at runtime." + from ..application.network import network_guard + + if network_guard.is_blocked(): + return False, network_guard.refusal(f"pip install {package}") exe = python or sys.executable attempt = 0 while True: diff --git a/core/ext_connectors.py b/core/ext_connectors.py index ea2cd27..5d4890e 100644 --- a/core/ext_connectors.py +++ b/core/ext_connectors.py @@ -132,8 +132,11 @@ class RestApiConnector: def call(self, args: Dict[str, Any]) -> Dict[str, Any]: """Gọi API theo tham số model đưa ra, đi qua lớp TLS có ghim chứng chỉ nội bộ.""" + from ..application.network import network_guard from .tls_trust import request_any_method as tls_request + if network_guard.is_blocked(): + return {"ok": False, "output": network_guard.refusal(self.display_name)} method = str(args.get("method", "GET")).upper() path = str(args.get("path", "")).lstrip("/") url = urljoin(self.base_url, path) @@ -164,10 +167,13 @@ class RestApiConnector: def test_connection(self) -> Tuple[bool, str]: """Thử kết nối tới endpoint; trả về (thành công, thông điệp).""" + from ..application.network import network_guard from .tls_trust import request as tls_request if not self.base_url.strip("/"): return False, "No base URL configured." + if network_guard.is_blocked(): + return False, network_guard.refusal(self.display_name) headers = {} if self.api_key: headers[self.auth_header] = ( diff --git a/core/jira_tool.py b/core/jira_tool.py index ef5db00..167724c 100644 --- a/core/jira_tool.py +++ b/core/jira_tool.py @@ -85,8 +85,10 @@ def get_issue_by_url(config: Dict[str, Any] | None, url: str) -> str: def _get(config: Dict[str, Any], path: str, params: dict = None): """Gọi Jira REST API bằng xác thực cơ bản, qua lớp TLS có ghim chứng chỉ nội bộ.""" + from ..application.network import network_guard from . import tls_trust + network_guard.ensure_allowed("Jira") c = _conf(config) url = c["base_url"].rstrip("/") + path # Same TLS auto-recovery the LLM provider calls get (core/tls_trust.py) — diff --git a/core/link_fetch.py b/core/link_fetch.py index 455e5b4..0344e31 100644 --- a/core/link_fetch.py +++ b/core/link_fetch.py @@ -147,6 +147,12 @@ def fetch_link_preview(url: str) -> str: return "" if not re.match(r"^https?://", url, re.IGNORECASE): return f"[Link: {url}] (not a fetchable http(s) URL — referenced by address only)" + # Every caller (fetch_url, task link attachments, ...) passes through here, + # so this one check covers the paths that never saw a ToolContext. + from ..application.network import network_guard + + if network_guard.is_blocked(): + return f"[Link: {url}] (not fetched — {network_guard.refusal('link fetch')})" # SharePoint / OneDrive share links are rewritten to their direct-download # form so the shared FILE itself is fetched and parsed (like an attachment), # not the share page's HTML shell. diff --git a/core/mcp_client.py b/core/mcp_client.py index aa4c9de..37bf990 100644 --- a/core/mcp_client.py +++ b/core/mcp_client.py @@ -88,7 +88,14 @@ class McpServerConnection: def start(self, timeout: float = 15.0) -> None: """Spawn the server subprocess and complete the MCP handshake. Raises :class:`McpServerError` on failure (bad command, the server - crashed on startup, the handshake timed out, ...).""" + crashed on startup, the handshake timed out, ...). + + Refused while "Block network" is on: a server process is free to open + any socket it likes, so the only safe server is one never started.""" + from ..application.network import network_guard + + if network_guard.is_blocked(): + raise McpServerError(network_guard.refusal(f"MCP server '{self.name}'")) self._thread = threading.Thread(target=self._run_loop, daemon=True) self._thread.start() if not self._ready.wait(timeout): @@ -174,6 +181,10 @@ class McpServerConnection: def call_tool(self, qualified_name: str, args: Dict[str, Any]) -> Dict[str, Any]: """``extra_executor``-shaped result: ``{"ok": bool, "output": str}``.""" + from ..application.network import network_guard + + if network_guard.is_blocked(): + return {"ok": False, "output": network_guard.refusal(f"MCP server '{self.name}'")} tool_name = qualified_name.split(_SEP, 1)[1] if _SEP in qualified_name else qualified_name try: result = self._run_coro(self._session.call_tool(tool_name, args or {})) diff --git a/core/ms365_auth.py b/core/ms365_auth.py index 693640e..0a7fd88 100644 --- a/core/ms365_auth.py +++ b/core/ms365_auth.py @@ -137,8 +137,34 @@ def _app(tenant_id: str, client_id: str): return app, cache +def _cached_account_offline() -> Optional[dict]: + """First account in the saved token cache, read without any MSAL network setup.""" + try: + import msal + + accounts = _load_cache().find(msal.TokenCache.CredentialType.ACCOUNT) + except Exception: # noqa: BLE001 - no msal / unreadable cache = not signed in + return None + return accounts[0] if accounts else None + + +def _ensure_network(action: str) -> None: + """Turn a "Block network" refusal into the error type callers already handle.""" + from ..application.network import network_guard + + if network_guard.is_blocked(): + raise Ms365AuthError(network_guard.refusal(action)) + + def signed_in_account(tenant_id: str, client_id: str) -> Optional[dict]: """The cached account, if any — a local cache lookup, no network call.""" + from ..application.network import network_guard + + if network_guard.is_blocked(): + # Building the MSAL app fetches the tenant's OpenID configuration, so + # read the token cache directly instead: the UI still sees who is + # signed in without the app reaching login.microsoftonline.com. + return _cached_account_offline() try: app, _cache = _app(tenant_id, client_id) except Ms365AuthError: @@ -156,6 +182,7 @@ def sign_in_device_code(tenant_id: str, client_id: str, on_code: Callable[[dict] ``verification_uri_complete`` (URL with the code pre-filled, when the tenant returns it) and ``message`` (the full human-readable instruction). Returns the MSAL token result dict; raises Ms365AuthError on failure/timeout.""" + _ensure_network("Microsoft 365 sign-in") app, cache = _app(tenant_id, client_id) flow = app.initiate_device_flow(scopes=SCOPES) if "user_code" not in flow: @@ -183,6 +210,7 @@ def get_access_token(tenant_id: str, client_id: str) -> str: """Silently reuse the cached sign-in. Raises Ms365AuthError when there is no valid session — the caller (a Graph call) should surface that as a normal tool failure telling the user to sign in again from Settings.""" + _ensure_network("Microsoft 365") app, cache = _app(tenant_id, client_id) accounts = app.get_accounts() if not accounts: @@ -228,6 +256,7 @@ def sign_out_default(config=None) -> None: def sign_out(tenant_id: str, client_id: str) -> None: """Đăng xuất và xoá token của một tenant/client khỏi kho.""" try: + _ensure_network("Microsoft 365 sign-out") # chặn mạng: chỉ xoá kho token bên dưới app, cache = _app(tenant_id, client_id) for acc in app.get_accounts(): app.remove_account(acc) diff --git a/core/ms365_graph.py b/core/ms365_graph.py index 402ae16..1b00012 100644 --- a/core/ms365_graph.py +++ b/core/ms365_graph.py @@ -43,6 +43,10 @@ def _request(method: str, url: str, token: str, **kwargs) -> requests.Response: """Gọi Graph API, tự ghép ``GRAPH_BASE`` cho đường dẫn tương đối và đổi lỗi HTTP thành :class:`Ms365GraphError` kèm thông điệp đọc được. """ + from ..application.network import network_guard + + if network_guard.is_blocked(): + raise Ms365GraphError(network_guard.refusal("Microsoft 365 (Graph)")) if not url.startswith("http"): url = f"{GRAPH_BASE}{url}" headers = _headers(token, kwargs.pop("headers", None)) diff --git a/core/sandbox_manager.py b/core/sandbox_manager.py index 6264106..0a4c665 100644 --- a/core/sandbox_manager.py +++ b/core/sandbox_manager.py @@ -218,8 +218,13 @@ class SandboxManager: timeout_sec: int, cancel: Optional[Callable[[], bool]] = None, ) -> Dict[str, Any]: - """Dispatch execution to the selected backend.""" - if backend == "direct": + """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": @@ -274,7 +279,8 @@ class SandboxManager: env = os.environ.copy() if block_network: from .deps import network_blocked_env - env = network_blocked_env(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 @@ -334,4 +340,42 @@ class SandboxManager: "stderr": str(exc), "returncode": -1, "sandbox": "direct", - } \ No newline at end of file + } + + @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", + } diff --git a/core/task_executors.py b/core/task_executors.py index 57de9a9..1147f10 100644 --- a/core/task_executors.py +++ b/core/task_executors.py @@ -19,7 +19,6 @@ in Waiting Input), so executors here run with an auto gate. """ from __future__ import annotations -import subprocess import time import uuid from datetime import datetime @@ -30,6 +29,7 @@ from . import agent_roles from . import agent_security from . import projects from .permissions import PermissionGate +from .task_script import run_script as _run_script from .tasks import ARTIFACTS_DIR, resolve_input_text from .tools import ToolContext @@ -358,18 +358,6 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path, return _last_assistant_text(messages), timed_out(), incomplete -def _run_script(command: str, out_dir: Path, timeout_sec: int) -> str: - """Chạy một task kiểu script bằng shell trong thư mục kết quả, có hạn giờ.""" - if not command.strip(): - raise RuntimeError("Script task has no command configured.") - proc = subprocess.run(command, shell=True, cwd=str(out_dir), - capture_output=True, text=True, timeout=max(1, timeout_sec)) - output = (proc.stdout or "") + (("\n[stderr]\n" + proc.stderr) if proc.stderr else "") - if proc.returncode != 0: - raise RuntimeError(f"Script exited with code {proc.returncode}:\n{output[-2000:]}") - return output - - def execute_task(ctx, task: Dict[str, Any], run_id: str, emit: Optional[EmitFn] = None, cancel: Optional[CancelFn] = None, tasks_dir: Path = None) -> Dict[str, Any]: diff --git a/core/task_script.py b/core/task_script.py new file mode 100644 index 0000000..213ac59 --- /dev/null +++ b/core/task_script.py @@ -0,0 +1,47 @@ +"""Run a scheduled task of type ``script`` (tách khỏi ``task_executors.py``). + +Khi công tắc "Chặn mạng" đang bật, lệnh của task chạy trong tiến trình bị hệ +điều hành cắt mạng — giống ``run_command`` của agent. Trước đây task script +chạy thẳng bằng ``subprocess.run``, không sandbox, nên lên mạng tự do. +""" +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def run_script(command: str, out_dir: Path, timeout_sec: int) -> str: + """Chạy một task kiểu script bằng shell trong thư mục kết quả, có hạn giờ.""" + if not command.strip(): + raise RuntimeError("Script task has no command configured.") + from ..application.network import network_guard + + if network_guard.is_blocked(): + return _run_script_without_network(command, out_dir, timeout_sec) + proc = subprocess.run(command, shell=True, cwd=str(out_dir), + capture_output=True, text=True, timeout=max(1, timeout_sec)) + output = (proc.stdout or "") + (("\n[stderr]\n" + proc.stderr) if proc.stderr else "") + if proc.returncode != 0: + raise RuntimeError(f"Script exited with code {proc.returncode}:\n{output[-2000:]}") + return output + + +def _run_script_without_network(command: str, out_dir: Path, timeout_sec: int) -> str: + """Như :func:`run_script`, nhưng tiến trình không có mạng; không cô lập được thì không chạy.""" + from .deps import network_blocked_env, run_cancellable + + rc, output, _cancelled, timed_out, _exceeded = run_cancellable( + command, cwd=str(out_dir), timeout=max(1, timeout_sec), shell=True, + env=network_blocked_env(), isolate_network=True, + ) + if timed_out: + raise subprocess.TimeoutExpired(command, timeout_sec) + if rc is None: + raise RuntimeError("Script not run: network is blocked and the script could not be " + f"isolated from the network ({output.strip()}).") + if rc != 0: + raise RuntimeError(f"Script exited with code {rc} (network blocked):\n{output[-2000:]}") + return output + + +__all__ = ["run_script"] diff --git a/core/teams.py b/core/teams.py index fd3fe92..578cc0e 100644 --- a/core/teams.py +++ b/core/teams.py @@ -43,6 +43,10 @@ class TeamsNotifier: """Post a notification. Returns ``(ok, detail)``.""" if not self.configured: return False, "Teams webhook URL is not configured." + from ..application.network import network_guard + + if network_guard.is_blocked(): + return False, network_guard.refusal("Teams notification") # Workflows webhooks expect an Adaptive Card; classic connectors expect a # MessageCard. Try both, then a plain-text fallback. diff --git a/i18n/settings_dialog.py b/i18n/settings_dialog.py index 2544101..6ffb9d3 100644 --- a/i18n/settings_dialog.py +++ b/i18n/settings_dialog.py @@ -10,22 +10,22 @@ from typing import Dict STRINGS: Dict[str, Dict[str, str]] = { "settings.sandbox_block_network": { - "en": "Block network for agent-run commands", - "ja": "エージェントが実行するコマンドのネットワークをブロック", - "vi": "Chặn mạng cho lệnh do agent chạy"}, + "en": "Block network (AI provider still allowed)", + "ja": "ネットワークをブロック(AIプロバイダーのみ許可)", + "vi": "Chặn mạng (vẫn cho gọi nhà cung cấp AI)"}, "settings.allow_url_fetch": { "en": "Allow the agent to fetch URLs (web pages, SharePoint / OneDrive links)", "ja": "エージェントによるURL取得を許可(Webページ、SharePoint / OneDriveリンク)", "vi": "Cho phép agent lấy dữ liệu từ URL (trang web, link SharePoint / OneDrive)"}, "settings.allow_url_fetch_tooltip": { "en": ("Lets the agent's fetch_url tool read web pages, online documents and " - "SharePoint/OneDrive share links to search & process them. Separate from " - "'Block network' (which only sandboxes shell commands). Default: on."), + "SharePoint/OneDrive share links to search & process them. 'Block network' " + "overrides this switch. Default: on."), "ja": "エージェントのfetch_urlツールがWebページ・オンライン文書・SharePoint/OneDrive共有リンクを" - "読み取れるようにします。「ネットワークをブロック」(シェルコマンド用)とは別です。既定: オン。", + "読み取れるようにします。「ネットワークをブロック」がオンの場合はそちらが優先されます。既定: オン。", "vi": ("Cho phép tool fetch_url của agent đọc trang web, tài liệu online và link chia sẻ " - "SharePoint/OneDrive để tìm kiếm & xử lý. Tách biệt với 'Chặn mạng' (chỉ áp cho lệnh " - "shell). Mặc định: bật.")}, + "SharePoint/OneDrive để tìm kiếm & xử lý. 'Chặn mạng' được ưu tiên hơn công tắc " + "này. Mặc định: bật.")}, "settings.test_internet": { "en": "Test Internet", "ja": "インターネット接続テスト", "vi": "Kiểm tra Internet"}, "settings.test_internet_tooltip": { @@ -39,12 +39,18 @@ STRINGS: Dict[str, Dict[str, str]] = { "en": "Testing internet access…", "ja": "インターネット接続をテスト中…", "vi": "Đang kiểm tra truy cập internet…"}, "settings.sandbox_block_network_tooltip": { - "en": ("Policy-level control (proxy env vars point at a black hole) — not a kernel " - "firewall. Combine with the command whitelist above for defense in depth."), - "ja": "ポリシーレベルの制御です(プロキシ環境変数をブラックホールに向ける)— カーネルレベルの" - "ファイアウォールではありません。上のコマンドホワイトリストと併用してください。", - "vi": "Kiểm soát ở tầng chính sách (trỏ biến môi trường proxy vào hố đen) — không phải " - "firewall tầng kernel. Kết hợp với whitelist lệnh ở trên để phòng thủ nhiều lớp."}, + "en": ("Only the AI provider (chat, model list, model test) may reach the network. " + "Agent shell commands and task scripts run in a Windows AppContainer with no " + "network access; fetch_url, Jira, connectors/MCP, Microsoft 365, Teams, " + "connector tests, pip auto-install and remote content in HTML previews are refused."), + "ja": "ネットワークに接続できるのはAIプロバイダー(チャット・モデル一覧・モデルテスト)のみです。" + "エージェントのシェルコマンドとタスクスクリプトはネットワークなしのWindows AppContainerで実行され、" + "fetch_url・Jira・コネクタ/MCP・Microsoft 365・Teams・接続テスト・pip自動インストール・" + "HTMLプレビューの外部リソースは拒否されます。", + "vi": "Chỉ nhà cung cấp AI (chat, tải danh sách model, thử model) được ra mạng. Lệnh shell " + "của agent và task script chạy trong Windows AppContainer không có mạng; fetch_url, " + "Jira, connector/MCP, Microsoft 365, Teams, nút Test, tự cài thư viện và tài nguyên " + "web trong xem trước HTML đều bị từ chối."}, "settings.sandbox_unlimited": {"en": "Unlimited", "ja": "無制限", "vi": "Không giới hạn"}, "settings.sandbox_cpu_label": {"en": "CPU limit", "ja": "CPU 制限", "vi": "Giới hạn CPU"}, "settings.sandbox_memory_label": {"en": "Memory limit", "ja": "メモリ制限", "vi": "Giới hạn bộ nhớ"}, diff --git a/infrastructure/filesystem/command_tools.py b/infrastructure/filesystem/command_tools.py index 8d88e40..06f6a39 100644 --- a/infrastructure/filesystem/command_tools.py +++ b/infrastructure/filesystem/command_tools.py @@ -76,11 +76,11 @@ def run_command(ctx: ToolContext, args: Dict[str, Any], denial = "Command blocked by security policy: " + "; ".join(risk.reasons) return {"ok": False, "output": denial} - # Every sandbox backend's network block is a proxy-env-var trick (see - # core/deps.py::network_blocked_env) — it does nothing against a tool - # that reaches the network without an HTTP proxy (ping/ICMP, nslookup/ - # direct DNS, ssh/ftp/raw TCP...). Deny those BY NAME here instead, so - # "Chặn mạng cho lệnh do agent chạy" actually blocks them too. + # With the network blocked, SandboxManager runs the command in an OS-level + # network-less process (AppContainer on Windows — see + # infrastructure/sandbox/network_isolation.py). Tools that exist only to + # reach the network (ping, nslookup, ssh...) are still denied BY NAME + # first: the model gets a clear reason instead of a cryptic socket error. if ctx.block_network: bypass_tool = command_bypasses_network_proxy(command) if bypass_tool: diff --git a/infrastructure/sandbox/appcontainer_process.py b/infrastructure/sandbox/appcontainer_process.py new file mode 100644 index 0000000..04a8e61 --- /dev/null +++ b/infrastructure/sandbox/appcontainer_process.py @@ -0,0 +1,392 @@ +"""Spawn a shell command inside a Windows AppContainer with NO network capability. + +Why this exists +--------------- +The old "block network" for agent shell commands only pointed the proxy env +vars at a dead port (``core/deps.py::network_blocked_env``). Anything that +ignores proxies (``Invoke-WebRequest -NoProxy``, raw sockets, ``certutil``, +.NET ``WebClient``...) still reached the internet. An AppContainer token that +is granted no ``internetClient``/``privateNetworkClientServer`` capability is +refused by the kernel firewall for every outbound connection, loopback +included, no matter which tool makes it. No admin rights are needed. + +An AppContainer can only open files whose ACL admits its SID (or ALL +APPLICATION PACKAGES). System32 and Program Files already do; the workdir and +the app's own Python install do not, so :func:`spawn` grants the profile SID +access to those folders first (an extra ACE, nothing is removed). + +:class:`AppContainerProcess` quacks like ``subprocess.Popen`` for the subset +``core/deps.py::_run_cancellable_body`` uses (``pid``, ``stdout``/``stderr`` +text streams, ``poll``/``wait``/``kill``/``returncode``), so the Stop button, +timeouts, Job Objects and resource limits keep working unchanged. +""" +from __future__ import annotations + +import io +import locale +import os +import subprocess +import sys +import threading +from typing import Dict, Iterable, Optional + +PROFILE_NAME = "cowork_local.agent_netblock" +_IS_WINDOWS = sys.platform == "win32" + +if _IS_WINDOWS: + import ctypes + import msvcrt + from ctypes import wintypes + + _k32 = ctypes.WinDLL("kernel32", use_last_error=True) + _adv = ctypes.WinDLL("advapi32", use_last_error=True) + _uenv = ctypes.WinDLL("userenv", use_last_error=True) + + class _SECURITY_CAPABILITIES(ctypes.Structure): + _fields_ = [("AppContainerSid", ctypes.c_void_p), ("Capabilities", ctypes.c_void_p), + ("CapabilityCount", wintypes.DWORD), ("Reserved", wintypes.DWORD)] + + class _STARTUPINFOW(ctypes.Structure): + _fields_ = [("cb", wintypes.DWORD), ("lpReserved", wintypes.LPWSTR), + ("lpDesktop", wintypes.LPWSTR), ("lpTitle", wintypes.LPWSTR), + ("dwX", wintypes.DWORD), ("dwY", wintypes.DWORD), + ("dwXSize", wintypes.DWORD), ("dwYSize", wintypes.DWORD), + ("dwXCountChars", wintypes.DWORD), ("dwYCountChars", wintypes.DWORD), + ("dwFillAttribute", wintypes.DWORD), ("dwFlags", wintypes.DWORD), + ("wShowWindow", wintypes.WORD), ("cbReserved2", wintypes.WORD), + ("lpReserved2", ctypes.c_void_p), ("hStdInput", wintypes.HANDLE), + ("hStdOutput", wintypes.HANDLE), ("hStdError", wintypes.HANDLE)] + + class _STARTUPINFOEXW(ctypes.Structure): + _fields_ = [("StartupInfo", _STARTUPINFOW), ("lpAttributeList", ctypes.c_void_p)] + + class _PROCESS_INFORMATION(ctypes.Structure): + _fields_ = [("hProcess", wintypes.HANDLE), ("hThread", wintypes.HANDLE), + ("dwProcessId", wintypes.DWORD), ("dwThreadId", wintypes.DWORD)] + + class _SECURITY_ATTRIBUTES(ctypes.Structure): + _fields_ = [("nLength", wintypes.DWORD), ("lpSecurityDescriptor", ctypes.c_void_p), + ("bInheritHandle", wintypes.BOOL)] + + _uenv.CreateAppContainerProfile.restype = ctypes.c_long + _uenv.CreateAppContainerProfile.argtypes = [ + wintypes.LPCWSTR, wintypes.LPCWSTR, wintypes.LPCWSTR, ctypes.c_void_p, + wintypes.DWORD, ctypes.POINTER(ctypes.c_void_p)] + _uenv.DeriveAppContainerSidFromAppContainerName.restype = ctypes.c_long + _uenv.DeriveAppContainerSidFromAppContainerName.argtypes = [ + wintypes.LPCWSTR, ctypes.POINTER(ctypes.c_void_p)] + _uenv.GetAppContainerFolderPath.restype = ctypes.c_long + _uenv.GetAppContainerFolderPath.argtypes = [ + wintypes.LPCWSTR, ctypes.POINTER(ctypes.c_wchar_p)] + _adv.ConvertSidToStringSidW.restype = wintypes.BOOL + _adv.ConvertSidToStringSidW.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_wchar_p)] + _k32.InitializeProcThreadAttributeList.restype = wintypes.BOOL + _k32.InitializeProcThreadAttributeList.argtypes = [ + ctypes.c_void_p, wintypes.DWORD, wintypes.DWORD, ctypes.POINTER(ctypes.c_size_t)] + _k32.UpdateProcThreadAttribute.restype = wintypes.BOOL + _k32.UpdateProcThreadAttribute.argtypes = [ + ctypes.c_void_p, wintypes.DWORD, ctypes.c_size_t, ctypes.c_void_p, + ctypes.c_size_t, ctypes.c_void_p, ctypes.c_void_p] + _k32.DeleteProcThreadAttributeList.argtypes = [ctypes.c_void_p] + _k32.CreatePipe.restype = wintypes.BOOL + _k32.CreatePipe.argtypes = [ctypes.POINTER(wintypes.HANDLE), ctypes.POINTER(wintypes.HANDLE), + ctypes.POINTER(_SECURITY_ATTRIBUTES), wintypes.DWORD] + _k32.SetHandleInformation.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD] + _k32.CreateProcessW.restype = wintypes.BOOL + _k32.CreateProcessW.argtypes = [ + wintypes.LPCWSTR, wintypes.LPWSTR, ctypes.c_void_p, ctypes.c_void_p, wintypes.BOOL, + wintypes.DWORD, ctypes.c_void_p, wintypes.LPCWSTR, ctypes.POINTER(_STARTUPINFOEXW), + ctypes.POINTER(_PROCESS_INFORMATION)] + _k32.ResumeThread.argtypes = [wintypes.HANDLE] + _k32.WaitForSingleObject.restype = wintypes.DWORD + _k32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD] + _k32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)] + _k32.TerminateProcess.argtypes = [wintypes.HANDLE, wintypes.UINT] + _k32.CloseHandle.argtypes = [wintypes.HANDLE] + _k32.GetStdHandle.restype = wintypes.HANDLE + _k32.OpenProcess.restype = wintypes.HANDLE + + _ALREADY_EXISTS = ctypes.c_long(0x800700B7).value + _PROC_THREAD_ATTRIBUTE_HANDLE_LIST = 0x00020002 + _PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES = 0x00020009 + _EXTENDED_STARTUPINFO_PRESENT = 0x00080000 + _CREATE_UNICODE_ENVIRONMENT = 0x00000400 + _CREATE_NO_WINDOW = 0x08000000 + _CREATE_SUSPENDED = 0x00000004 + _STARTF_USESTDHANDLES = 0x00000100 + _HANDLE_FLAG_INHERIT = 0x00000001 + _STILL_ACTIVE = 259 + +_profile_lock = threading.Lock() +_profile: Dict[str, object] = {} +_granted: set = set() + + +class NetworkIsolationUnavailable(RuntimeError): + """This machine cannot start a network-less process — callers must refuse to run.""" + + +def is_supported() -> bool: + """True on Windows builds that ship the AppContainer API (Windows 8+).""" + if not _IS_WINDOWS: + return False + try: + return bool(_uenv.CreateAppContainerProfile) + except AttributeError: + return False + + +def _profile_sid(): + """``(sid pointer, sid string, temp folder)`` of the shared no-network profile.""" + with _profile_lock: + if _profile: + return _profile["sid"], _profile["sid_str"], _profile["temp"] + sid = ctypes.c_void_p() + hr = _uenv.CreateAppContainerProfile(PROFILE_NAME, "Cowork Local agent (no network)", + "Agent shell commands with the network blocked", + None, 0, ctypes.byref(sid)) + if hr == _ALREADY_EXISTS: + hr = _uenv.DeriveAppContainerSidFromAppContainerName(PROFILE_NAME, ctypes.byref(sid)) + if hr != 0 or not sid.value: + raise NetworkIsolationUnavailable(f"AppContainer profile error 0x{hr & 0xFFFFFFFF:08X}") + text = ctypes.c_wchar_p() + if not _adv.ConvertSidToStringSidW(sid, ctypes.byref(text)): + raise NetworkIsolationUnavailable("Could not read the AppContainer SID") + sid_str = text.value + folder = ctypes.c_wchar_p() + temp = "" + if _uenv.GetAppContainerFolderPath(sid_str, ctypes.byref(folder)) == 0 and folder.value: + temp = os.path.join(folder.value, "Temp") + os.makedirs(temp, exist_ok=True) + _profile.update(sid=sid, sid_str=sid_str, temp=temp) + return sid, sid_str, temp + + +_PERMS = {"write": "(OI)(CI)(M)", "read": "(OI)(CI)(RX)", "read_here": "(OI)(NP)(RX)"} + + +def _qt_package_dir() -> str: + """Folder of the installed PySide6/Qt binaries ('' if PySide6 is absent).""" + try: + import importlib.util + + spec = importlib.util.find_spec("PySide6") + except (ImportError, ValueError): + return "" + return os.path.dirname(spec.origin) if spec and spec.origin else "" + + +def _covers(folder: str, target: str) -> bool: + """True if ``target`` is ``folder`` itself or lies somewhere below it.""" + if not folder or not target: + return False + folder, target = os.path.normcase(folder), os.path.normcase(target) + return target == folder or target.startswith(folder.rstrip("\\/") + os.sep) + + +def _icacls(*args: str) -> subprocess.CompletedProcess: + return subprocess.run(["icacls", *args], capture_output=True, text=True, + creationflags=_CREATE_NO_WINDOW) + + +def grant_access(path: str, sid_str: str, mode: str) -> None: + """Let the AppContainer SID open ``path``. + + ``mode`` is ``"write"``/``"read"`` (inherited by everything below) or + ``"read_here"`` (this folder and the files directly in it, no deeper). + + An inherited ACE must never reach the Qt WebEngine binaries: Chromium's + sandboxed render process then fails to load Qt6WebEngineCore.dll + (STATUS_DLL_NOT_FOUND) and every web view in the app goes blank. A folder + that contains the PySide6 install is therefore refused. + """ + path = os.path.abspath(path) + key = (os.path.normcase(path), mode) + if key in _granted or not os.path.exists(path): + return + if mode != "read_here" and _covers(path, _qt_package_dir()): + raise NetworkIsolationUnavailable( + f"Refusing to sandbox a folder that contains the app's Qt runtime: {path}") + perm = _PERMS[mode] + listing = (_icacls(path).stdout or "").lower() + if f"{sid_str}:{perm}".lower() not in listing: + done = _icacls(path, "/grant", f"*{sid_str}:{perm}", "/Q", "/C") + if done.returncode != 0: + raise NetworkIsolationUnavailable( + f"Could not grant the sandbox access to {path}: {(done.stderr or done.stdout).strip()}") + _granted.add(key) + + +def _repair_inherited_grant(path: str, sid_str: str) -> None: + """Drop an inherited grant that an earlier build put on the app's venv.""" + key = (os.path.normcase(os.path.abspath(path)), "repaired") + if key in _granted or not os.path.isdir(path): + return + listing = (_icacls(path).stdout or "").lower() + if f"{sid_str}:(oi)(ci)".lower() in listing: + _icacls(path, "/remove:g", f"*{sid_str}", "/Q", "/C") + _granted.add(key) + + +def _interpreter_grants(): + """``(folder, mode)`` pairs that let the sandbox run the app's Python. + + The base install is read-only and holds no Qt; a venv only needs its root + (``pyvenv.cfg``) and ``Scripts`` — never ``Lib/site-packages``. + """ + qt_dir = _qt_package_dir() + if _covers(sys.base_prefix, qt_dir): + # PySide6 lives in the base install itself: expose only the executable + # and the compiled stdlib modules, never the tree holding Qt. + grants = [(sys.base_prefix, "read_here"), (os.path.join(sys.base_prefix, "DLLs"), "read")] + else: + grants = [(sys.base_prefix, "read")] + if os.path.normcase(sys.prefix) != os.path.normcase(sys.base_prefix): + grants += [(sys.prefix, "read_here"), (os.path.join(sys.prefix, "Scripts"), "read")] + return [(folder, mode) for folder, mode in grants + if mode == "read_here" or not _covers(folder, qt_dir)] + + +def _env_block(env: Dict[str, str]) -> ctypes.Array: + """Sorted, double-NUL-terminated UTF-16 environment block for CreateProcessW.""" + items = sorted(env.items(), key=lambda kv: kv[0].upper()) + text = "".join(f"{k}={v}\0" for k, v in items if k and "=" not in k) + "\0" + return ctypes.create_unicode_buffer(text, len(text)) + + +def _pipe(): + """Anonymous pipe; only the child's (write) end is inheritable.""" + sa = _SECURITY_ATTRIBUTES(ctypes.sizeof(_SECURITY_ATTRIBUTES), None, True) + read, write = wintypes.HANDLE(), wintypes.HANDLE() + if not _k32.CreatePipe(ctypes.byref(read), ctypes.byref(write), ctypes.byref(sa), 0): + raise ctypes.WinError(ctypes.get_last_error()) + _k32.SetHandleInformation(read, _HANDLE_FLAG_INHERIT, 0) + return read, write + + +class AppContainerProcess: + """The ``subprocess.Popen`` subset that ``deps._run_cancellable_body`` relies on.""" + + def __init__(self, handle, pid: int, stdout: io.TextIOBase, stderr: io.TextIOBase): + """Wrap an already-started process handle and its two output streams.""" + self._handle = handle + self.pid = pid + self.stdout = stdout + self.stderr = stderr + self.returncode: Optional[int] = None + + def poll(self) -> Optional[int]: + """Exit code if the process has finished, else None.""" + if self.returncode is None and self._handle: + code = wintypes.DWORD() + if _k32.GetExitCodeProcess(self._handle, ctypes.byref(code)) and code.value != _STILL_ACTIVE: + self.returncode = ctypes.c_int32(code.value).value + _k32.CloseHandle(self._handle) + self._handle = None + return self.returncode + + def wait(self, timeout: Optional[float] = None) -> int: + """Block until exit; raise ``subprocess.TimeoutExpired`` like Popen does.""" + if self.returncode is None and self._handle: + ms = 0xFFFFFFFF if timeout is None else int(timeout * 1000) + if _k32.WaitForSingleObject(self._handle, ms) != 0: + raise subprocess.TimeoutExpired("appcontainer", timeout) + return self.poll() + + def kill(self) -> None: + """Terminate the process (the Job Object in deps kills its children).""" + if self.returncode is None and self._handle: + _k32.TerminateProcess(self._handle, 1) + + def communicate(self, timeout: Optional[float] = None): + """Read both streams to the end and wait; returns ``(stdout, stderr)``.""" + chunks: Dict[str, str] = {} + + def _drain(name, stream): + chunks[name] = stream.read() + + readers = [threading.Thread(target=_drain, args=(n, s), daemon=True) + for n, s in (("out", self.stdout), ("err", self.stderr))] + for t in readers: + t.start() + for t in readers: + t.join(timeout) + self.wait(timeout) + return chunks.get("out", ""), chunks.get("err", "") + + +def spawn(command: str, cwd: Optional[str], env: Optional[Dict[str, str]], + readable_dirs: Iterable[str] = ()) -> AppContainerProcess: + """Start ``cmd.exe /c command`` in the no-network AppContainer. + + Raises :class:`NetworkIsolationUnavailable` when that cannot be done — + callers must then refuse the command rather than run it with the network on. + """ + if not is_supported(): + raise NetworkIsolationUnavailable("AppContainer is only available on Windows") + sid, sid_str, temp = _profile_sid() + cwd = os.path.abspath(cwd or os.getcwd()) + _repair_inherited_grant(sys.prefix, sid_str) + grant_access(cwd, sid_str, "write") + for folder, mode in [*_interpreter_grants(), *((d, "read") for d in readable_dirs if d)]: + grant_access(folder, sid_str, mode) + + child_env = dict(os.environ if env is None else env) + if temp: + child_env["TEMP"] = child_env["TMP"] = temp + child_env.setdefault("PYTHONIOENCODING", "utf-8") + env_block = _env_block(child_env) + + out_r, out_w = _pipe() + err_r, err_w = _pipe() + handles = (wintypes.HANDLE * 2)(out_w, err_w) + caps = _SECURITY_CAPABILITIES(sid, None, 0, 0) + size = ctypes.c_size_t() + _k32.InitializeProcThreadAttributeList(None, 2, 0, ctypes.byref(size)) + attr = ctypes.create_string_buffer(size.value) + pi = _PROCESS_INFORMATION() + try: + if not (_k32.InitializeProcThreadAttributeList(attr, 2, 0, ctypes.byref(size)) + and _k32.UpdateProcThreadAttribute( + attr, 0, _PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, ctypes.byref(caps), + ctypes.sizeof(caps), None, None) + and _k32.UpdateProcThreadAttribute( + attr, 0, _PROC_THREAD_ATTRIBUTE_HANDLE_LIST, handles, + ctypes.sizeof(handles), None, None)): + raise NetworkIsolationUnavailable(str(ctypes.WinError(ctypes.get_last_error()))) + si = _STARTUPINFOEXW() + si.StartupInfo.cb = ctypes.sizeof(si) + si.StartupInfo.dwFlags = _STARTF_USESTDHANDLES + si.StartupInfo.hStdOutput = out_w + si.StartupInfo.hStdError = err_w + si.lpAttributeList = ctypes.addressof(attr) + comspec = os.environ.get("COMSPEC") or r"C:\Windows\System32\cmd.exe" + cmdline = ctypes.create_unicode_buffer(f'"{comspec}" /d /s /c "{command}"') + flags = (_EXTENDED_STARTUPINFO_PRESENT | _CREATE_UNICODE_ENVIRONMENT + | _CREATE_NO_WINDOW | _CREATE_SUSPENDED) + if not _k32.CreateProcessW(None, cmdline, None, None, True, flags, + ctypes.addressof(env_block), cwd, + ctypes.byref(si), ctypes.byref(pi)): + raise NetworkIsolationUnavailable( + f"Could not start the sandboxed command: {ctypes.WinError(ctypes.get_last_error())}") + _k32.ResumeThread(pi.hThread) + _k32.CloseHandle(pi.hThread) + except BaseException: + for h in (out_r, err_r): + _k32.CloseHandle(h) + raise + finally: + _k32.DeleteProcThreadAttributeList(attr) + _k32.CloseHandle(out_w) + _k32.CloseHandle(err_w) + + def _stream(handle) -> io.TextIOBase: + fd = msvcrt.open_osfhandle(handle.value, os.O_RDONLY) + return io.TextIOWrapper(io.FileIO(fd, "rb"), encoding=locale.getpreferredencoding(False), + errors="replace") + + return AppContainerProcess(pi.hProcess, pi.dwProcessId, _stream(out_r), _stream(err_r)) + + +__all__ = ["AppContainerProcess", "NetworkIsolationUnavailable", "PROFILE_NAME", + "grant_access", "is_supported", "spawn"] diff --git a/infrastructure/sandbox/network_isolation.py b/infrastructure/sandbox/network_isolation.py new file mode 100644 index 0000000..210e7d5 --- /dev/null +++ b/infrastructure/sandbox/network_isolation.py @@ -0,0 +1,46 @@ +"""Start a shell command that the operating system keeps off the network. + +Used whenever "Block network" is on for agent ``run_command`` calls and for +scheduled script tasks. The contract is fail-closed: if isolation cannot be +set up, :class:`NetworkIsolationUnavailable` is raised and the caller refuses +the command instead of running it with the network open. + +* Windows: an AppContainer with no network capability + (:mod:`.appcontainer_process`). +* macOS: ``sandbox-exec`` with a profile that denies every network operation. +* Linux: ``unshare --net`` in a new user namespace (an empty network namespace + has only a downed loopback). If unprivileged namespaces are disabled, + ``unshare`` itself fails and the command never runs. +""" +from __future__ import annotations + +import shutil +import subprocess +import sys +from typing import Dict, Optional + +from .appcontainer_process import NetworkIsolationUnavailable + +_MACOS_PROFILE = "(version 1)(allow default)(deny network*)" + + +def spawn_without_network(command: str, cwd: Optional[str], env: Optional[Dict[str, str]]): + """A ``Popen``-like process running ``command`` through the shell, with no network.""" + if sys.platform == "win32": + from . import appcontainer_process + + return appcontainer_process.spawn(command, cwd, env) + if sys.platform == "darwin" and shutil.which("sandbox-exec"): + argv = ["sandbox-exec", "-p", _MACOS_PROFILE, "/bin/sh", "-c", command] + elif sys.platform.startswith("linux") and shutil.which("unshare"): + argv = ["unshare", "--user", "--map-root-user", "--net", "/bin/sh", "-c", command] + else: + raise NetworkIsolationUnavailable( + "No network isolation is available on this system (needs AppContainer, " + "sandbox-exec or unshare).") + return subprocess.Popen(argv, cwd=cwd, env=env, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, bufsize=1, + start_new_session=True) + + +__all__ = ["NetworkIsolationUnavailable", "spawn_without_network"] diff --git a/presentation/folder/office_document_renderer.py b/presentation/folder/office_document_renderer.py index ea6985d..3aef705 100644 --- a/presentation/folder/office_document_renderer.py +++ b/presentation/folder/office_document_renderer.py @@ -110,7 +110,10 @@ class OfficeDocumentRenderer: if self._engine is None: try: from PySide6.QtWebEngineWidgets import QWebEngineView + + from .offline_web_page import install_offline_page self._engine = QWebEngineView() + install_offline_page(self._engine) self._owner.stack.addWidget(self._engine) except Exception: # noqa: BLE001 self._engine = None diff --git a/presentation/folder/offline_web_page.py b/presentation/folder/offline_web_page.py new file mode 100644 index 0000000..44c96ee --- /dev/null +++ b/presentation/folder/offline_web_page.py @@ -0,0 +1,39 @@ +"""HTML preview page that loads nothing from the web while "Block network" is on. + +``QWebEngineView.setHtml`` happily fetches every ````, +``