From 57af5089715ffbf4febff8417547e4206986fa53 Mon Sep 17 00:00:00 2001 From: Duy Le Huu Date: Sat, 12 Sep 2026 08:33:26 +0900 Subject: [PATCH] =?UTF-8?q?fix(sandbox):=20ch=E1=BA=B7n=20m=E1=BA=A1ng=20t?= =?UTF-8?q?h=E1=BA=ADt=20s=E1=BB=B1=20cho=20m=E1=BB=8Di=20tool=20NETWORK?= =?UTF-8?q?=20c=E1=BB=A7a=20agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit block_network trước đây chỉ được run_command đọc tới, nên fetch_url, jira_search, jira_get_issue và install_package vẫn ra internet bình thường trong khi Monitoring báo Mạng - Bị chặn. Thêm cổng chặn ngay đầu bốn tool đó, trước mọi lời gọi mạng, kèm test hồi quy và guardrail theo BUILT_IN_CAPABILITIES. Co-Authored-By: Claude Opus 5 --- config.py | 12 +- infrastructure/filesystem/command_tools.py | 9 ++ infrastructure/filesystem/fetch_tools.py | 26 ++++- infrastructure/filesystem/tool_context.py | 12 +- tests/test_sandbox_block_network.py | 125 +++++++++++++++++++++ 5 files changed, 175 insertions(+), 9 deletions(-) create mode 100644 tests/test_sandbox_block_network.py diff --git a/config.py b/config.py index 9eda41b..e40be0c 100644 --- a/config.py +++ b/config.py @@ -100,11 +100,15 @@ 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) - "block_network": True, # strip proxy env / point at a black-hole address for agent-run commands + # 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, # Allow the agent's fetch_url tool to read web pages / online documents / - # SharePoint-OneDrive share links. SEPARATE from block_network (that only - # sandboxes agent-run shell commands) — reading a URL for info is safe and - # useful, so this defaults ON. Toggle in Settings → Security. + # 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: + # with the network blocked the tool is refused either way. "allow_url_fetch": True, "sandbox_pw": "", # set through COWORK_SANDBOX_PASSWORD "rulebase_path": "", # custom RULEBASE.md — attached to every agent execution diff --git a/infrastructure/filesystem/command_tools.py b/infrastructure/filesystem/command_tools.py index 37aa4c4..8d88e40 100644 --- a/infrastructure/filesystem/command_tools.py +++ b/infrastructure/filesystem/command_tools.py @@ -128,6 +128,15 @@ def install_package(ctx: ToolContext, args: Dict[str, Any], package = str(args.get("package", "")).strip() if not package: return {"ok": False, "output": "No package specified."} + # ``pip install`` bắt buộc phải ra internet, mà ``deps.pip_install`` chạy + # subprocess với ``os.environ`` nguyên vẹn — biến proxy hố đen của + # ``network_blocked_env`` không chạm tới nó. Từ chối thẳng ở đây (giống cách + # run_command chặn theo tên các công cụ không đi qua proxy) thay vì để pip + # thử 600 giây rồi báo một lỗi proxy khó hiểu. + if ctx.block_network: + return {"ok": False, "output": ( + "install_package: network access is blocked by the Sandbox Security Layer " + "(\"Block network for agent-run commands\" is on in Settings).")} python = _sandbox_python(ctx, cancel, on_output) ok, detail = pip_install(package, cancel=cancel, on_output=on_output, python=python) head = f"Installed {package}." if ok else f"Could not install {package}." diff --git a/infrastructure/filesystem/fetch_tools.py b/infrastructure/filesystem/fetch_tools.py index 7e3f73d..4c5534d 100644 --- a/infrastructure/filesystem/fetch_tools.py +++ b/infrastructure/filesystem/fetch_tools.py @@ -6,11 +6,26 @@ tag added in R05-T01/domain/tools/tool_registry.py describes. """ from __future__ import annotations -from typing import Any, Dict +from typing import Any, Dict, Optional from .tool_context import ToolContext +def _network_refusal(ctx: ToolContext, tool: str) -> Optional[Dict[str, Any]]: + """Lời từ chối khi Sandbox Security Layer đang chặn mạng; None nếu được đi. + + ``block_network`` trước đây chỉ được đọc ở ``command_tools.py`` (lệnh shell), + nên ba tool mang ``ToolCapability.NETWORK`` ở file này vẫn ra internet bình + thường trong khi Monitoring báo "Mạng: Bị chặn". Kiểm ở đây, TRƯỚC mọi lời + gọi mạng, để công tắc chặn đúng thứ nó nói là chặn. + """ + if not ctx.block_network: + return None + return {"ok": False, "output": ( + f"{tool}: network access is blocked by the Sandbox Security Layer " + "(\"Block network for agent-run commands\" is on in Settings).")} + + def fetch_url(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]: """Fetch a URL's text content (web page / online document / SharePoint- OneDrive share link) via link_fetch — the same parser task-link attachments @@ -20,6 +35,9 @@ def fetch_url(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]: return {"ok": False, "output": "fetch_url: 'url' is required."} if not url.lower().startswith(("http://", "https://")): return {"ok": False, "output": f"fetch_url: not an http(s) URL: {url}"} + blocked = _network_refusal(ctx, "fetch_url") + if blocked is not None: + return blocked if not ctx.allow_url_fetch: return {"ok": False, "output": ("fetch_url: URL fetching is turned off in Settings → Security " @@ -37,6 +55,9 @@ def fetch_url(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]: def jira_search(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]: """Tìm issue trên Jira bằng JQL.""" + blocked = _network_refusal(ctx, "jira_search") + if blocked is not None: + return blocked from cowork_local.core import jira_tool out = jira_tool.search(ctx.jira, str(args.get("jql", "")), @@ -47,6 +68,9 @@ def jira_search(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]: def jira_get_issue(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]: """Lấy chi tiết một issue Jira theo mã.""" + blocked = _network_refusal(ctx, "jira_get_issue") + if blocked is not None: + return blocked from cowork_local.core import jira_tool out = jira_tool.get_issue(ctx.jira, str(args.get("key", ""))) diff --git a/infrastructure/filesystem/tool_context.py b/infrastructure/filesystem/tool_context.py index ad681d9..eddbf94 100644 --- a/infrastructure/filesystem/tool_context.py +++ b/infrastructure/filesystem/tool_context.py @@ -37,12 +37,16 @@ class ToolContext: # None (default) = no limits, matching pre-existing behavior. resource_limits: Optional[Dict[str, float]] = None # Sandbox Security Layer — Settings' "Block network for agent commands" - # (policy-level, see deps.py::network_blocked_env). False (default) = + # — the proxy-env block for shell commands (deps.py::network_blocked_env) + # AND a flat refusal from every NETWORK-capability tool, which reaches the + # net in-process where proxy env vars mean nothing. False (default) = # unrestricted, matching pre-existing behavior. block_network: bool = False - # Whether the fetch_url tool may read URLs — SEPARATE from block_network - # (reading a web page/share link for info is safe; running networked shell - # commands is the risk). Defaults True; set from agent_security.allow_url_fetch. + # Whether the fetch_url tool may read URLs. Its own toggle, but NOT a way + # around block_network: with the network blocked every NETWORK-capability + # tool is refused first (fetch_tools.py::_network_refusal), so this flag only + # decides anything while the network is open. Defaults True; set from + # agent_security.allow_url_fetch. allow_url_fetch: bool = True # Jira read connector config (base_url/email/api_token) — None disables the # jira_* tools' ability to connect. Populated from config.data["jira"]. diff --git a/tests/test_sandbox_block_network.py b/tests/test_sandbox_block_network.py new file mode 100644 index 0000000..d94b353 --- /dev/null +++ b/tests/test_sandbox_block_network.py @@ -0,0 +1,125 @@ +"""Công tắc "Chặn mạng cho lệnh do agent chạy" phải chặn MỌI đường ra mạng của +agent, không riêng ``run_command``. + +Trước đây ``block_network`` chỉ được đọc ở đúng một chỗ — +``infrastructure/filesystem/command_tools.py`` trong ``run_command`` — nên bốn +tool mang ``ToolCapability.NETWORK`` (``fetch_url``, ``jira_search``, +``jira_get_issue``, ``install_package``) vẫn ra internet bình thường trong khi +màn Monitoring báo "Mạng: Bị chặn" và docstring của ``fetch_tools`` tự nhận là +*"Honors the Sandbox Security Layer's Block network policy"*. Người dùng bật +công tắc rồi thấy agent vẫn search web được — đúng triệu chứng được báo. + +Hai nhóm bài: + +* **hành vi** — bật thì mọi tool NETWORK từ chối TRƯỚC khi chạm mạng, tắt thì + đường cũ giữ nguyên (chặn một chiều là hỏng tính năng); +* **guardrail** — thêm tool mạng mới mà quên chặn thì bài ở đây đỏ ngay. +""" +from __future__ import annotations + +from typing import Dict + +import pytest + +from cowork_local.core.tools import ToolContext +from cowork_local.domain.tools import BUILT_IN_CAPABILITIES, ToolCapability +from cowork_local.infrastructure.filesystem import command_tools, fetch_tools + +# tên tool -> (handler, args hợp lệ tối thiểu). Args phải hợp lệ, nếu không bài +# test sẽ đỏ vì lỗi thiếu tham số chứ không vì cổng chặn mạng. +_TOOL_MANG: Dict[str, tuple] = { + "fetch_url": (fetch_tools.fetch_url, {"url": "https://example.com/"}), + "jira_search": (fetch_tools.jira_search, {"jql": "project = ABC"}), + "jira_get_issue": (fetch_tools.jira_get_issue, {"key": "ABC-1"}), + "install_package": (command_tools.install_package, {"package": "requests"}), +} + + +@pytest.fixture +def cam_ra_mang(monkeypatch): + """Mọi đường ra mạng thật đều nổ. + + Vừa giữ cho bộ test không chạm internet, vừa làm lộ tool nào lọt qua cổng + chặn: nó sẽ đỏ ngay tại lời gọi mạng thay vì im lặng đi ra ngoài. + """ + def no_ra_mang(*args, **kwargs): + raise AssertionError("tool đã chạm mạng dù 'Chặn mạng' đang bật") + + from cowork_local.core import deps, jira_tool, link_fetch + + monkeypatch.setattr(link_fetch, "fetch_link_preview", no_ra_mang) + monkeypatch.setattr(jira_tool, "search", no_ra_mang) + monkeypatch.setattr(jira_tool, "get_issue", no_ra_mang) + monkeypatch.setattr(jira_tool, "get_issue_by_url", no_ra_mang) + monkeypatch.setattr(deps, "pip_install", no_ra_mang) + + +# ---- hành vi: bật công tắc thì mọi tool mạng đều bị chặn ----------------- + +@pytest.mark.parametrize("ten", sorted(_TOOL_MANG)) +def test_bat_chan_mang_thi_tool_tu_choi_truoc_khi_cham_mang(tmp_path, cam_ra_mang, ten): + """Đây là chính triệu chứng người dùng báo: bật rồi mà vẫn ra được web.""" + handler, args = _TOOL_MANG[ten] + ctx = ToolContext(tmp_path, block_network=True) + + ket_qua = handler(ctx, args) + + assert ket_qua["ok"] is False, f"{ten} vẫn chạy khi đang chặn mạng" + assert "Sandbox Security Layer" in ket_qua["output"], ket_qua["output"] + + +def test_allow_url_fetch_khong_lach_duoc_chan_mang(tmp_path, cam_ra_mang): + """Hai công tắc vẫn độc lập, nhưng "Chặn mạng" là cái mạnh hơn: bật nó thì + "Cho phép agent lấy dữ liệu từ URL" không mở lại đường được.""" + ctx = ToolContext(tmp_path, block_network=True, allow_url_fetch=True) + + ket_qua = fetch_tools.fetch_url(ctx, {"url": "https://example.com/"}) + + assert ket_qua["ok"] is False + + +# ---- hành vi: tắt công tắc thì đường cũ giữ nguyên ----------------------- + +def test_tat_chan_mang_thi_fetch_url_van_doc_duoc(tmp_path, monkeypatch): + """Chặn một chiều là hỏng tính năng — cổng phải mở lại được.""" + from cowork_local.core import link_fetch + + monkeypatch.setattr(link_fetch, "fetch_link_preview", + lambda url: f"nội dung của {url}") + ctx = ToolContext(tmp_path, block_network=False) + + ket_qua = fetch_tools.fetch_url(ctx, {"url": "https://example.com/"}) + + assert ket_qua["ok"] is True + assert "example.com" in ket_qua["output"] + + +def test_tat_chan_mang_thi_install_package_van_chay(tmp_path, monkeypatch): + from cowork_local.core import deps + + da_goi = [] + + def gia_lap_pip(package, **kwargs): + da_goi.append(package) + return True, "ok" + + monkeypatch.setattr(deps, "pip_install", gia_lap_pip) + ctx = ToolContext(tmp_path, block_network=False) + + ket_qua = command_tools.install_package(ctx, {"package": "requests"}) + + assert da_goi == ["requests"] + assert ket_qua["ok"] is True + + +# ---- guardrail: danh sách tool mạng không được lệch ---------------------- + +def test_moi_tool_mang_deu_co_bai_o_day(): + """``BUILT_IN_CAPABILITIES`` là nơi duy nhất khai báo tool nào chạm mạng. + Thêm một tool NETWORK mới mà quên chặn thì bài này đỏ ngay.""" + tag_mang = {ten for ten, cap in BUILT_IN_CAPABILITIES.items() + if cap & ToolCapability.NETWORK} + + assert tag_mang == set(_TOOL_MANG), ( + "danh sách tool mạng đã đổi — chặn tool mới ở cổng block_network " + "rồi bổ sung vào _TOOL_MANG")