Files
cowork-local/tests/test_network_guard_lanes.py
T
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

390 lines
15 KiB
Python

"""Công tắc "Chặn mạng" phải chặn mọi đường ra mạng của app, TRỪ nhà cung cấp AI.
Bản đồ ``infura/network-map.html`` liệt kê các làn trước đây "không kiểm soát"
hoặc chỉ chặn một phần: M365, Teams và nút Test, connector/MCP đang chạy,
task script và link đính kèm task, tự cài thư viện, xem trước HTML, lệnh shell
chỉ bị proxy giả. Mỗi làn có ít nhất một bài ở đây:
* **bật** — làn từ chối TRƯỚC khi chạm mạng (mọi lời gọi HTTP thật đều nổ);
* **tắt** — đường cũ giữ nguyên, vì chặn một chiều là hỏng tính năng.
"""
from __future__ import annotations
import socket
import sys
import threading
from pathlib import Path
import pytest
from cowork_local.application.network import network_guard
def _no_network(*args, **kwargs):
raise AssertionError("đã chạm mạng dù 'Chặn mạng' đang bật")
@pytest.fixture
def chan_mang(monkeypatch):
"""Bật công tắc qua cổng chung và làm nổ mọi lời gọi HTTP thật."""
import requests
from cowork_local.core import tls_trust
for name in ("request", "get", "post", "put", "patch", "delete"):
monkeypatch.setattr(requests, name, _no_network)
monkeypatch.setattr(tls_trust, "request", _no_network)
monkeypatch.setattr(tls_trust, "request_any_method", _no_network)
network_guard.bind(lambda: True)
yield
network_guard.bind(None)
@pytest.fixture(autouse=True)
def _go_cong_sau_moi_bai():
yield
network_guard.bind(None)
# ---- cổng chung ------------------------------------------------------------
def test_chua_noi_day_thi_khong_chan():
network_guard.bind(None)
assert network_guard.is_blocked() is False
network_guard.ensure_allowed("x")
def test_doc_cong_tac_loi_thi_coi_nhu_dang_chan():
"""Không đọc được công tắc thì đóng cửa, không mở toang."""
def hong():
raise KeyError("agent_security")
network_guard.bind(hong)
with pytest.raises(network_guard.NetworkBlockedError):
network_guard.ensure_allowed("Teams")
def test_cong_doc_cau_hinh_song_khong_chup_lai(tmp_path):
"""Đổi công tắc trong Settings có hiệu lực ngay, không cần mở lại app."""
from cowork_local.presentation.shell.bootstrap import build_context
ctx = build_context(tmp_path / "config.json")
ctx.config.agent_security["block_network"] = True
assert network_guard.is_blocked() is True
ctx.config.agent_security["block_network"] = False
assert network_guard.is_blocked() is False
# ---- Microsoft 365 -----------------------------------------------------------
def test_graph_tu_choi_bang_loi_ma_noi_goi_da_bat(chan_mang):
from cowork_local.core import ms365_graph
with pytest.raises(ms365_graph.Ms365GraphError, match="Sandbox Security Layer"):
ms365_graph.list_onedrive_files("token")
def test_dang_nhap_va_lay_token_tu_choi(chan_mang, monkeypatch):
from cowork_local.core import ms365_auth
monkeypatch.setattr(ms365_auth, "_app", _no_network)
with pytest.raises(ms365_auth.Ms365AuthError, match="blocked"):
ms365_auth.get_access_token("", "")
with pytest.raises(ms365_auth.Ms365AuthError, match="blocked"):
ms365_auth.sign_in_device_code("", "", lambda flow: None)
def test_kiem_tra_da_dang_nhap_khong_dung_msal_khi_chan(chan_mang, monkeypatch):
"""Dựng app MSAL là tải cấu hình OpenID của tenant — phải đọc kho token trực tiếp."""
from cowork_local.core import ms365_auth
monkeypatch.setattr(ms365_auth, "_app", _no_network)
monkeypatch.setattr(ms365_auth, "_cached_account_offline",
lambda: {"username": "a@b.c"})
assert ms365_auth.signed_in_account("", "") == {"username": "a@b.c"}
def test_mail_canh_bao_khong_gui_khi_chan(chan_mang, monkeypatch):
from types import SimpleNamespace
from cowork_local.core import agent_security_alert, ms365_auth
from cowork_local.core.agent_security_types import SecurityVerdict
monkeypatch.setattr(ms365_auth, "_app", _no_network)
config = SimpleNamespace(data={"agent_security": {"admin_email": "admin@x.y"}}, ms365={})
verdict = SecurityVerdict(allowed=False, layer="command", reason="test")
sent, note = agent_security_alert.notify_admin(config, verdict)
assert sent is False
assert "blocked" in note
# ---- Teams, connector REST, Jira, nút Test ---------------------------------
def test_teams_tu_choi(chan_mang):
from cowork_local.core.teams import TeamsNotifier
ok, detail = TeamsNotifier("https://x.webhook.office.com/hook").send("t", "b")
assert ok is False
assert "blocked" in detail
def test_connector_rest_va_nut_test_tu_choi(chan_mang):
from cowork_local.core.ext_connectors import RestApiConnector
rc = RestApiConnector({"id": "erp", "name": "ERP", "base_url": "https://erp.example"})
assert rc.call({"method": "GET", "path": "items"})["ok"] is False
ok, message = rc.test_connection()
assert ok is False and "blocked" in message
def test_nut_test_jira_tu_choi(chan_mang):
from cowork_local.core import jira_tool
cfg = {"base_url": "https://jira.example", "email": "a@b.c", "api_token": "t"}
out = jira_tool.search(cfg, "order by created DESC", 1)
assert out.startswith("Jira search failed") and "blocked" in out
# ---- MCP -------------------------------------------------------------------
def test_mcp_khong_khoi_dong_va_khong_goi_khi_chan(chan_mang):
from cowork_local.core.mcp_client import McpServerConnection, McpServerError
conn = McpServerConnection("srv", "definitely-not-run")
with pytest.raises(McpServerError, match="blocked"):
conn.start(timeout=1)
assert conn.call_tool("srv__search", {})["ok"] is False
def _context(tmp_path, block: bool):
from cowork_local.config import AppConfig
from cowork_local.state import AppContext
ctx = AppContext(AppConfig.load(tmp_path / "config.json"))
ctx.config.agent_security["block_network"] = block
return ctx
def test_chuan_bi_connector_dung_mcp_dang_chay_va_giu_onedrive_cuc_bo(tmp_path, monkeypatch):
from cowork_local.core import ms365_local
ctx = _context(tmp_path, block=True)
stopped = []
monkeypatch.setattr(ctx, "stop_mcp_connections", lambda: stopped.append(True))
local = (["ms365_local__onedrive_list"], object())
monkeypatch.setattr(ms365_local, "build_ms365_local_tools", lambda config: local)
monkeypatch.setattr(ctx._mcp_manager, "ensure", _no_network)
assert ctx.build_mcp_tools() == local
assert stopped == [True]
# ---- task lập lịch -----------------------------------------------------------
def test_link_dinh_kem_task_khong_tai(chan_mang):
from cowork_local.core.tasks import resolve_input_text
text = resolve_input_text({"input": {"mode": "empty", "links": ["https://example.com/a"]}})
assert "not fetched" in text
def test_task_script_chay_trong_tien_trinh_khong_mang(chan_mang, monkeypatch, tmp_path):
from cowork_local.core import deps, task_script
calls = []
def fake_run(command, **kwargs):
calls.append(kwargs)
return 0, "done", False, False, False
monkeypatch.setattr(deps, "run_cancellable", fake_run)
assert task_script.run_script("echo hi", tmp_path, 30) == "done"
assert calls and calls[0]["isolate_network"] is True
def test_task_script_khong_co_lap_duoc_thi_khong_chay(chan_mang, monkeypatch, tmp_path):
from cowork_local.core import deps, task_script
monkeypatch.setattr(deps, "run_cancellable",
lambda command, **kw: (None, "no AppContainer", False, False, False))
with pytest.raises(RuntimeError, match="could not be isolated"):
task_script.run_script("echo hi", tmp_path, 30)
def test_tat_chan_mang_thi_task_script_chay_nhu_cu(tmp_path):
from cowork_local.core import task_script
assert "hi" in task_script.run_script("echo hi", tmp_path, 30)
# ---- tự cài thư viện ---------------------------------------------------------
def test_khong_tu_cai_thu_vien_khi_chan(chan_mang, monkeypatch):
from cowork_local.core import deps
monkeypatch.setattr(deps, "run_cancellable", _no_network)
assert deps.ensure_module("khong_ton_tai_xyz_123") is None
assert "khong_ton_tai_xyz_123" not in deps._FAILED # thử lại khi mở mạng
ok, detail = deps.pip_install("requests")
assert ok is False and "blocked" in detail
def test_pptx_khong_bi_nho_la_thieu_khi_chi_do_chan_mang(chan_mang, monkeypatch):
from cowork_local.application.workspaces import file_preview_helpers as fph
from cowork_local.core import deps
monkeypatch.setattr(fph, "_PPTX_READY", None)
monkeypatch.setattr(deps, "ensure_module", lambda *a, **k: None)
assert fph.pptx_available() is False
assert fph._PPTX_READY is None
# ---- lệnh shell của agent ----------------------------------------------------
@pytest.mark.parametrize("backend", ["direct", "integrity_job_wfp", "appcontainer", "windows_sandbox"])
def test_moi_backend_deu_chay_co_lap_mang(monkeypatch, tmp_path, backend):
"""Các backend cũ chỉ đặt biến proxy — khi chặn mạng, tất cả đi qua lớp cô lập của OS."""
from cowork_local.core import deps
from cowork_local.core.sandbox_manager import SandboxManager
calls = []
def fake_run(command, **kwargs):
calls.append(kwargs)
return 0, "ok", False, False, False
monkeypatch.setattr(deps, "run_cancellable", fake_run)
result = SandboxManager()._execute_with_backend(backend, "echo hi", str(tmp_path), True, 30)
assert calls and calls[0]["isolate_network"] is True
assert result["ok"] is True and result["sandbox"] == "network_isolated"
def test_lenh_khong_co_lap_duoc_thi_bi_tu_choi(monkeypatch, tmp_path):
from cowork_local.core import deps
from cowork_local.core.sandbox_manager import SandboxManager
monkeypatch.setattr(deps, "run_cancellable",
lambda command, **kw: (None, "no isolation", False, False, False))
result = SandboxManager()._execute_with_backend("direct", "echo hi", str(tmp_path), True, 30)
assert result["ok"] is False and result["sandbox"] == "blocked"
def test_he_dieu_hanh_khong_ho_tro_thi_bao_loi_khong_chay(monkeypatch):
from cowork_local.infrastructure.sandbox import network_isolation
monkeypatch.setattr(network_isolation.sys, "platform", "sunos5")
with pytest.raises(network_isolation.NetworkIsolationUnavailable):
network_isolation.spawn_without_network("echo hi", None, None)
def _listening_socket():
server = socket.socket()
server.bind(("127.0.0.1", 0))
server.listen(4)
def serve():
try:
for _ in range(4):
server.accept()
except OSError:
pass # socket closed at the end of the test
threading.Thread(target=serve, daemon=True).start()
return server
@pytest.mark.skipif(sys.platform != "win32", reason="AppContainer chỉ có trên Windows")
def test_appcontainer_that_su_cat_mang_nhung_van_ghi_duoc_thu_muc(tmp_path):
"""Bài thật, không giả lập: cùng một lệnh Python nối tới một cổng đang nghe,
ngoài sandbox thì được, trong sandbox thì bị kernel từ chối."""
from cowork_local.core.deps import run_cancellable
server = _listening_socket()
port = server.getsockname()[1]
probe = (f'"{sys.executable}" -c "import socket;'
"print('PYTHON'+'_RAN');"
f"socket.create_connection(('127.0.0.1',{port}),5);print('CONN'+'ECTED')\"")
try:
rc, out, *_ = run_cancellable(probe, cwd=str(tmp_path), timeout=60, shell=True)
assert rc == 0 and "CONNECTED" in out
rc, out, *_ = run_cancellable(probe + " & echo written> marker.txt",
cwd=str(tmp_path), timeout=60, isolate_network=True)
assert "PYTHON_RAN" in out, out # Python itself starts in the sandbox
assert "CONNECTED" not in out, out
assert (Path(tmp_path) / "marker.txt").read_text().strip() == "written"
finally:
server.close()
# ---- xem trước HTML ------------------------------------------------------------
class _FakeUrl:
def __init__(self, scheme):
self._scheme = scheme
def scheme(self):
return self._scheme
class _FakeRequest:
def __init__(self, scheme):
self._url = _FakeUrl(scheme)
self.blocked = False
def requestUrl(self): # noqa: N802 - Qt name
return self._url
def block(self, flag):
self.blocked = flag
@pytest.mark.parametrize("scheme,expected", [
("https", True), ("http", True), ("wss", True), ("file", False), ("data", False),
])
def test_xem_truoc_html_chan_tai_nguyen_web(chan_mang, scheme, expected):
pytest.importorskip("PySide6.QtWebEngineCore")
from cowork_local.presentation.folder.offline_web_page import RemoteRequestBlocker
req = _FakeRequest(scheme)
RemoteRequestBlocker().interceptRequest(req)
assert req.blocked is expected
def test_xem_truoc_html_tai_binh_thuong_khi_tat():
pytest.importorskip("PySide6.QtWebEngineCore")
from cowork_local.presentation.folder.offline_web_page import RemoteRequestBlocker
req = _FakeRequest("https")
RemoteRequestBlocker().interceptRequest(req)
assert req.blocked is False
@pytest.mark.skipif(sys.platform != "win32", reason="AppContainer chỉ có trên Windows")
def test_khong_cap_quyen_ke_thua_len_thu_muc_chua_qt(tmp_path, monkeypatch):
"""Quyền AppContainer kế thừa xuống Qt6WebEngineCore.dll làm tiến trình render
của WebEngine không nạp được DLL — tab Graph và xem trước HTML trắng trơn."""
from cowork_local.infrastructure.sandbox import appcontainer_process as ac
qt_dir = tmp_path / "venv" / "Lib" / "site-packages" / "PySide6"
qt_dir.mkdir(parents=True)
monkeypatch.setattr(ac, "_qt_package_dir", lambda: str(qt_dir))
monkeypatch.setattr(ac, "_icacls", _no_network)
with pytest.raises(ac.NetworkIsolationUnavailable, match="Qt runtime"):
ac.grant_access(str(tmp_path / "venv"), "S-1-15-2-1", "read")
with pytest.raises(ac.NetworkIsolationUnavailable, match="Qt runtime"):
ac.grant_access(str(tmp_path), "S-1-15-2-1", "write")
def test_quyen_cho_python_khong_bao_gio_phu_len_thu_vien_qt(monkeypatch, tmp_path):
from cowork_local.infrastructure.sandbox import appcontainer_process as ac
venv, base = tmp_path / "venv", tmp_path / "base"
monkeypatch.setattr(ac.sys, "prefix", str(venv))
monkeypatch.setattr(ac.sys, "base_prefix", str(base))
for qt_dir in (venv / "Lib" / "site-packages" / "PySide6",
base / "Lib" / "site-packages" / "PySide6"):
monkeypatch.setattr(ac, "_qt_package_dir", lambda d=qt_dir: str(d))
for folder, mode in ac._interpreter_grants():
assert mode == "read_here" or not ac._covers(folder, str(qt_dir)), (folder, mode)