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
393 lines
18 KiB
Python
393 lines
18 KiB
Python
"""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"]
|