From ee79f63086a6a33f4f77907ca3936cd5b1df3aa5 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Fri, 19 Jun 2026 22:45:47 +0800 Subject: [PATCH 1/2] Add network egress allowlist guard for the HTTP client --- README.md | 7 ++ README/README_zh-CN.md | 7 ++ README/README_zh-TW.md | 7 ++ .../Eng/doc/new_features/v34_features_doc.rst | 54 +++++++++ docs/source/Eng/eng_index.rst | 1 + .../Zh/doc/new_features/v34_features_doc.rst | 49 ++++++++ docs/source/Zh/zh_index.rst | 1 + je_auto_control/__init__.py | 5 + .../gui/script_builder/command_schema.py | 21 ++++ je_auto_control/utils/egress/__init__.py | 8 ++ je_auto_control/utils/egress/egress_policy.py | 106 ++++++++++++++++++ .../utils/executor/action_executor.py | 24 ++++ .../utils/http_client/http_client.py | 2 + .../utils/mcp_server/tools/_factories.py | 35 +++++- .../utils/mcp_server/tools/_handlers.py | 17 +++ .../headless/test_egress_guard_batch.py | 97 ++++++++++++++++ 16 files changed, 440 insertions(+), 1 deletion(-) create mode 100644 docs/source/Eng/doc/new_features/v34_features_doc.rst create mode 100644 docs/source/Zh/doc/new_features/v34_features_doc.rst create mode 100644 je_auto_control/utils/egress/__init__.py create mode 100644 je_auto_control/utils/egress/egress_policy.py create mode 100644 test/unit_test/headless/test_egress_guard_batch.py diff --git a/README.md b/README.md index 274838f1..12746670 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ## Table of Contents +- [What's new (2026-06-19) — Network Egress Allowlist Guard](#whats-new-2026-06-19--network-egress-allowlist-guard) - [What's new (2026-06-19) — Just-In-Time Credential Leases](#whats-new-2026-06-19--just-in-time-credential-leases) - [What's new (2026-06-19) — Maker-Checker Approval Gate](#whats-new-2026-06-19--maker-checker-approval-gate) - [What's new (2026-06-19) — Plugin SDK](#whats-new-2026-06-19--plugin-sdk) @@ -86,6 +87,12 @@ --- +## What's new (2026-06-19) — Network Egress Allowlist Guard + +Pin which hosts automation may reach. Full reference: [`docs/source/Eng/doc/new_features/v34_features_doc.rst`](docs/source/Eng/doc/new_features/v34_features_doc.rst). + +- **`EgressPolicy` / `set_egress_policy`** (`AC_egress_allow` / `AC_egress_check` / `AC_egress_reset`, `ac_*`): an allow list (default-deny) and/or deny list of `fnmatch` host globs (`*.example.com`) consulted by **every** `http_request` (so `AC_http` and all features built on it are covered at once). Blocked hosts raise `EgressBlocked` *before* a socket opens. Starts in allow-all mode — no behavior change until an operator locks egress down. Closes the exfiltration surface for unattended automation. + ## What's new (2026-06-19) — Just-In-Time Credential Leases Zero standing privilege for secrets. Full reference: [`docs/source/Eng/doc/new_features/v33_features_doc.rst`](docs/source/Eng/doc/new_features/v33_features_doc.rst). diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index f8598a0a..8519e03a 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -12,6 +12,7 @@ ## 目录 +- [本次更新 (2026-06-19) — 网络出口允许清单守卫](#本次更新-2026-06-19--网络出口允许清单守卫) - [本次更新 (2026-06-19) — 即时凭证租约](#本次更新-2026-06-19--即时凭证租约) - [本次更新 (2026-06-19) — Maker-Checker 审批闸门](#本次更新-2026-06-19--maker-checker-审批闸门) - [本次更新 (2026-06-19) — Plugin SDK](#本次更新-2026-06-19--plugin-sdk) @@ -85,6 +86,12 @@ --- +## 本次更新 (2026-06-19) — 网络出口允许清单守卫 + +钉选自动化可连线的主机。完整参考:[`docs/source/Zh/doc/new_features/v34_features_doc.rst`](../docs/source/Zh/doc/new_features/v34_features_doc.rst)。 + +- **`EgressPolicy` / `set_egress_policy`**(`AC_egress_allow` / `AC_egress_check` / `AC_egress_reset`、`ac_*`):允许清单(默认拒绝)与/或拒绝清单,使用 `fnmatch` 主机通配符(`*.example.com`),由**每一次** `http_request` 咨询(因此 `AC_http` 与所有以其为基础的功能一次涵盖)。被封锁的主机会在 socket 打开**之前**抛出 `EgressBlocked`。以 allow-all 模式启动 —— 操作者锁定前不改变任何行为。封闭无人值守自动化的数据外泄面。 + ## 本次更新 (2026-06-19) — 即时凭证租约 密钥的零常驻权限。完整参考:[`docs/source/Zh/doc/new_features/v33_features_doc.rst`](../docs/source/Zh/doc/new_features/v33_features_doc.rst)。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 10c7f0d5..1bd0376a 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -12,6 +12,7 @@ ## 目錄 +- [本次更新 (2026-06-19) — 網路出口允許清單守衛](#本次更新-2026-06-19--網路出口允許清單守衛) - [本次更新 (2026-06-19) — 即時憑證租約](#本次更新-2026-06-19--即時憑證租約) - [本次更新 (2026-06-19) — Maker-Checker 審批閘門](#本次更新-2026-06-19--maker-checker-審批閘門) - [本次更新 (2026-06-19) — Plugin SDK](#本次更新-2026-06-19--plugin-sdk) @@ -85,6 +86,12 @@ --- +## 本次更新 (2026-06-19) — 網路出口允許清單守衛 + +釘選自動化可連線的主機。完整參考:[`docs/source/Zh/doc/new_features/v34_features_doc.rst`](../docs/source/Zh/doc/new_features/v34_features_doc.rst)。 + +- **`EgressPolicy` / `set_egress_policy`**(`AC_egress_allow` / `AC_egress_check` / `AC_egress_reset`、`ac_*`):允許清單(預設拒絕)與/或拒絕清單,使用 `fnmatch` 主機萬用字元(`*.example.com`),由**每一次** `http_request` 諮詢(因此 `AC_http` 與所有以其為基礎的功能一次涵蓋)。被封鎖的主機會在 socket 開啟**之前**拋出 `EgressBlocked`。以 allow-all 模式啟動 —— 操作者鎖定前不改變任何行為。封閉無人值守自動化的資料外洩面。 + ## 本次更新 (2026-06-19) — 即時憑證租約 密鑰的零常駐權限。完整參考:[`docs/source/Zh/doc/new_features/v33_features_doc.rst`](../docs/source/Zh/doc/new_features/v33_features_doc.rst)。 diff --git a/docs/source/Eng/doc/new_features/v34_features_doc.rst b/docs/source/Eng/doc/new_features/v34_features_doc.rst new file mode 100644 index 00000000..8c6e21da --- /dev/null +++ b/docs/source/Eng/doc/new_features/v34_features_doc.rst @@ -0,0 +1,54 @@ +Network Egress Allowlist Guard +============================== + +Unattended automation that can reach arbitrary hosts is an exfiltration risk. +``EgressPolicy`` lets an operator pin which hosts the headless HTTP client may +talk to. It is consulted by every +:func:`~je_auto_control.utils.http_client.http_client.http_request` call (and +therefore by ``AC_http`` and every feature built on it), so locking egress down +covers the whole framework at once. + +The policy supports an **allow** list (default-deny — only matching hosts pass) +and/or a **deny** list (block these even when otherwise allowed). Patterns are +case-insensitive :mod:`fnmatch` globs over the URL hostname, e.g. +``*.example.com`` or ``localhost``. The module-level policy starts in +*allow-all* mode, so there is **no behavior change** until an operator locks it +down. Pure standard library; imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import set_egress_policy, EgressBlocked, http_request + + set_egress_policy(allow=["*.internal.corp", "api.example.com"]) + + http_request("https://api.example.com/v1") # ok + try: + http_request("https://evil.test/") # raises before connecting + except EgressBlocked: + ... + + set_egress_policy(None, None) # back to allow-all + +Modes: ``allow=None`` is allow-all; ``allow=[]`` denies everything; +``deny=[...]`` alone blocks just those hosts. ``allow`` / ``deny`` each accept a +list or a single comma-separated string. ``get_egress_policy().is_allowed(url)`` +checks a URL without raising; ``EgressPolicy(allow=..., deny=...)`` builds an +independent policy object. + +Executor commands +----------------- + +================================ =================================================== +Command Effect +================================ =================================================== +``AC_egress_allow`` Lock the HTTP client to ``allow`` / ``deny`` lists. +``AC_egress_check`` Report ``{allowed}`` for a URL (does not raise). +``AC_egress_reset`` Clear the policy back to allow-all. +================================ =================================================== + +The same operations are exposed as MCP tools (``ac_egress_allow`` / +``ac_egress_check`` / ``ac_egress_reset``) and as Script Builder commands under +**Tools**. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index 7ccbf839..ecbb2a4c 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -56,6 +56,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v31_features_doc doc/new_features/v32_features_doc doc/new_features/v33_features_doc + doc/new_features/v34_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/docs/source/Zh/doc/new_features/v34_features_doc.rst b/docs/source/Zh/doc/new_features/v34_features_doc.rst new file mode 100644 index 00000000..4d0b09bc --- /dev/null +++ b/docs/source/Zh/doc/new_features/v34_features_doc.rst @@ -0,0 +1,49 @@ +網路出口允許清單守衛 +==================== + +能連到任意主機的無人值守自動化是一種資料外洩風險。``EgressPolicy`` 讓操作者釘選無頭 +HTTP 用戶端可連線的主機。它會被每一次 +:func:`~je_auto_control.utils.http_client.http_client.http_request` 呼叫(因而也包含 +``AC_http`` 與所有以其為基礎的功能)所諮詢,因此鎖定出口即可一次涵蓋整個框架。 + +此策略支援**允許(allow)**清單(預設拒絕 —— 僅符合的主機可通過)與/或**拒絕 +(deny)**清單(即使其他情況允許也封鎖)。樣式為對 URL 主機名稱進行不分大小寫的 +:mod:`fnmatch` 萬用比對,例如 ``*.example.com`` 或 ``localhost``。模組層級的策略以 +*allow-all* 模式啟動,因此在操作者鎖定前**不會改變任何行為**。純標準函式庫,不匯入 +``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import set_egress_policy, EgressBlocked, http_request + + set_egress_policy(allow=["*.internal.corp", "api.example.com"]) + + http_request("https://api.example.com/v1") # 通過 + try: + http_request("https://evil.test/") # 連線前即拋出 + except EgressBlocked: + ... + + set_egress_policy(None, None) # 回到 allow-all + +模式:``allow=None`` 為 allow-all;``allow=[]`` 拒絕一切;單獨給 ``deny=[...]`` 僅封鎖 +那些主機。``allow`` / ``deny`` 皆可接受清單或單一逗號分隔字串。 +``get_egress_policy().is_allowed(url)`` 可在不拋出例外的情況下檢查 URL; +``EgressPolicy(allow=..., deny=...)`` 則建立獨立的策略物件。 + +執行器指令 +---------- + +================================ =================================================== +指令 效果 +================================ =================================================== +``AC_egress_allow`` 將 HTTP 用戶端鎖定到 ``allow`` / ``deny`` 清單。 +``AC_egress_check`` 回報 URL 的 ``{allowed}``(不拋出例外)。 +``AC_egress_reset`` 將策略清回 allow-all。 +================================ =================================================== + +相同操作亦提供為 MCP 工具(``ac_egress_allow`` / ``ac_egress_check`` / +``ac_egress_reset``),以及 Script Builder 中 **Tools** 分類下的指令。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index 91007e50..1b957c0a 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -56,6 +56,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v31_features_doc doc/new_features/v32_features_doc doc/new_features/v33_features_doc + doc/new_features/v34_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index 9903f6e1..2c1684e5 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -208,6 +208,10 @@ ApprovalGate, CredentialBroker, CredentialBrokerError, default_broker, set_secret_resolver, ) +# Network egress allowlist guard for the headless HTTP client +from je_auto_control.utils.egress import ( + EgressBlocked, EgressPolicy, get_egress_policy, set_egress_policy, +) # Background popup/interrupt watchdog (unattended automation) from je_auto_control.utils.watchdog import ( PopupWatchdog, WatchdogRule, default_popup_watchdog, @@ -645,6 +649,7 @@ def start_autocontrol_gui(*args, **kwargs): "COMMANDS_GROUP", "discover_plugins", "load_plugins", "ApprovalGate", "CredentialBroker", "CredentialBrokerError", "default_broker", "set_secret_resolver", + "EgressBlocked", "EgressPolicy", "get_egress_policy", "set_egress_policy", # MCP server "AuditLogger", "HttpMCPServer", "MCPContent", "MCPPrompt", "MCPPromptArgument", "MCPResource", "MCPServer", "MCPTool", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index 1af17db2..f9bfe82e 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -751,6 +751,27 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None: fields=(), description="List active leases (token, name, ttl_remaining).", )) + specs.append(CommandSpec( + "AC_egress_allow", "Tools", "Egress: Set Allowlist", + fields=( + FieldSpec("allow", FieldType.STRING, optional=True, + placeholder="*.example.com, api.foo.com"), + FieldSpec("deny", FieldType.STRING, optional=True, + placeholder="bad.example.com"), + ), + description="Lock the HTTP client to an egress allow/deny policy.", + )) + specs.append(CommandSpec( + "AC_egress_check", "Tools", "Egress: Check URL", + fields=(FieldSpec("url", FieldType.STRING, + placeholder="https://api.example.com"),), + description="Report whether a URL is permitted by the egress policy.", + )) + specs.append(CommandSpec( + "AC_egress_reset", "Tools", "Egress: Reset (allow-all)", + fields=(), + description="Clear the egress policy back to allow-all.", + )) specs.append(CommandSpec( "AC_generate_sop", "Report", "Generate SOP Document", fields=( diff --git a/je_auto_control/utils/egress/__init__.py b/je_auto_control/utils/egress/__init__.py new file mode 100644 index 00000000..40e5e1ea --- /dev/null +++ b/je_auto_control/utils/egress/__init__.py @@ -0,0 +1,8 @@ +"""Network egress allowlist guard for the headless HTTP client.""" +from je_auto_control.utils.egress.egress_policy import ( + EgressBlocked, EgressPolicy, get_egress_policy, set_egress_policy, +) + +__all__ = [ + "EgressBlocked", "EgressPolicy", "get_egress_policy", "set_egress_policy", +] diff --git a/je_auto_control/utils/egress/egress_policy.py b/je_auto_control/utils/egress/egress_policy.py new file mode 100644 index 00000000..d7a90092 --- /dev/null +++ b/je_auto_control/utils/egress/egress_policy.py @@ -0,0 +1,106 @@ +"""Network egress allowlist guard. + +Unattended automation that can reach arbitrary hosts is an exfiltration risk. +``EgressPolicy`` lets an operator pin which hosts the headless HTTP client may +talk to: an **allow** list (default-deny — only matching hosts pass) and/or a +**deny** list (block these even if otherwise allowed). Patterns are case- +insensitive :mod:`fnmatch` globs over the URL hostname, e.g. ``*.example.com`` +or ``localhost``. + +A module-level :data:`default_policy` is consulted by +:func:`je_auto_control.utils.http_client.http_client.http_request`. It starts +in *allow-all* mode, so there is no behavior change until an operator calls +:func:`set_egress_policy`; once an allow list is set, egress is locked down. + +Pure standard library; imports no ``PySide6``. +""" +import fnmatch +from typing import List, Optional, Sequence, Union +from urllib.parse import urlparse + +Patterns = Optional[Union[str, Sequence[str]]] + + +def _as_patterns(value: Patterns) -> Optional[List[str]]: + """Normalise ``None`` / a comma-string / an iterable to a lowercase list.""" + if value is None: + return None + items = value.split(",") if isinstance(value, str) else list(value) + return [str(item).strip().lower() for item in items if str(item).strip()] + + +class EgressBlocked(ValueError): + """Raised when a URL's host is not permitted by the egress policy.""" + + +def _host_of(url: str) -> Optional[str]: + """Return the lowercase hostname of ``url``, or ``None`` if absent.""" + host = urlparse(url).hostname + return host.lower() if host else None + + +class EgressPolicy: + """An allow/deny policy over outbound HTTP(S) hostnames.""" + + def __init__(self, allow: Patterns = None, deny: Patterns = None) -> None: + """``allow=None`` means allow-all; ``allow=[]`` means deny everything.""" + self._allow: Optional[List[str]] = None + self._deny: List[str] = [] + self.configure(allow, deny) + + def configure(self, allow: Patterns = None, deny: Patterns = None) -> None: + """Reset the allow/deny patterns in place (``allow=None`` is allow-all). + + Each list accepts a sequence of patterns or a single comma-separated + string, so visual-builder and JSON-action inputs both work. + """ + self._allow = _as_patterns(allow) + self._deny = _as_patterns(deny) or [] + + @property + def allow(self) -> Optional[List[str]]: + """The allow patterns (``None`` when unset / allow-all).""" + return list(self._allow) if self._allow is not None else None + + @property + def deny(self) -> List[str]: + """The deny patterns.""" + return list(self._deny) + + @staticmethod + def _matches(host: str, patterns: List[str]) -> bool: + return any(fnmatch.fnmatch(host, pattern) for pattern in patterns) + + def is_allowed(self, url: str) -> bool: + """Return whether ``url``'s host is permitted by this policy.""" + if self._allow is None and not self._deny: + return True # allow-all: no policy configured + host = _host_of(url) + if host is None: + return False + if self._matches(host, self._deny): + return False + if self._allow is None: + return True # deny-list-only mode + return self._matches(host, self._allow) + + def check(self, url: str) -> None: + """Raise :class:`EgressBlocked` if ``url``'s host is not permitted.""" + if not self.is_allowed(url): + raise EgressBlocked( + f"egress to {_host_of(url)!r} is blocked by the egress policy") + + +default_policy = EgressPolicy() + + +def get_egress_policy() -> EgressPolicy: + """Return the module-level policy consulted by the HTTP client.""" + return default_policy + + +def set_egress_policy(allow: Patterns = None, + deny: Patterns = None) -> EgressPolicy: + """Reconfigure the module-level policy; ``allow=None, deny=None`` is allow-all.""" + default_policy.configure(allow, deny) + return default_policy diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index ee53eaba..0dd0f7b2 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -2946,6 +2946,27 @@ def _lease_active() -> Dict[str, Any]: return {"leases": default_broker.active()} +def _egress_allow(allow: Optional[List[str]] = None, + deny: Optional[List[str]] = None) -> Dict[str, Any]: + """Adapter: lock the HTTP client to an egress allow/deny policy.""" + from je_auto_control.utils.egress import set_egress_policy + policy = set_egress_policy(allow, deny) + return {"allow": policy.allow, "deny": policy.deny} + + +def _egress_check(url: str) -> Dict[str, Any]: + """Adapter: report whether ``url`` is permitted by the egress policy.""" + from je_auto_control.utils.egress import get_egress_policy + return {"allowed": get_egress_policy().is_allowed(url)} + + +def _egress_reset() -> Dict[str, Any]: + """Adapter: clear the egress policy back to allow-all.""" + from je_auto_control.utils.egress import set_egress_policy + set_egress_policy(None, None) + return {"allow": None, "deny": []} + + class Executor: """ Executor @@ -3185,6 +3206,9 @@ def __init__(self): "AC_lease_valid": _lease_valid, "AC_revoke_lease": _revoke_lease, "AC_lease_active": _lease_active, + "AC_egress_allow": _egress_allow, + "AC_egress_check": _egress_check, + "AC_egress_reset": _egress_reset, "AC_a11y_record_start": _a11y_record_start, "AC_a11y_record_stop": _a11y_record_stop, "AC_a11y_record_events": _a11y_record_events, diff --git a/je_auto_control/utils/http_client/http_client.py b/je_auto_control/utils/http_client/http_client.py index 2548e62f..1af8527c 100644 --- a/je_auto_control/utils/http_client/http_client.py +++ b/je_auto_control/utils/http_client/http_client.py @@ -93,6 +93,8 @@ def http_request(url: str, method: str = "GET", can assert on status codes. """ _validate_url(url) + from je_auto_control.utils.egress.egress_policy import get_egress_policy + get_egress_policy().check(url) # allow-all unless an operator locked it down request = urllib.request.Request( url, data=_encode_body(json_body, data), method=str(method).upper(), headers=_build_headers(headers, json_body, auth)) diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index cbc544d3..fd5147ca 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -2659,6 +2659,39 @@ def credential_lease_tools() -> List[MCPTool]: ] +def egress_tools() -> List[MCPTool]: + _LISTS = {"allow": {"type": "array", "items": {"type": "string"}}, + "deny": {"type": "array", "items": {"type": "string"}}} + return [ + MCPTool( + name="ac_egress_allow", + description=("Lock the headless HTTP client to an egress policy. " + "'allow' is a default-deny host allowlist (fnmatch " + "globs over the URL hostname, e.g. '*.example.com'); " + "'deny' blocks hosts even if allowed. Omitting both " + "is allow-all. Returns the effective {allow, deny}."), + input_schema=schema(dict(_LISTS)), + handler=h.egress_allow, + annotations=SIDE_EFFECT_ONLY, + ), + MCPTool( + name="ac_egress_check", + description=("Report whether a URL's host is permitted by the " + "current egress policy. Returns {allowed}."), + input_schema=schema({"url": {"type": "string"}}, ["url"]), + handler=h.egress_check, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_egress_reset", + description="Clear the egress policy back to allow-all.", + input_schema=schema({}), + handler=h.egress_reset, + annotations=SIDE_EFFECT_ONLY, + ), + ] + + def unattended_tools() -> List[MCPTool]: return [ MCPTool( @@ -3717,7 +3750,7 @@ def media_assert_tools() -> List[MCPTool]: input_macro_tools, resilience_tools, ci_annotation_tools, clipboard_history_tools, audit_analysis_tools, process_doc_tools, tween_drag_tools, plugin_sdk_tools, governance_tools, - credential_lease_tools, + credential_lease_tools, egress_tools, screen_record_tools, process_and_shell_tools, remote_desktop_tools, gamepad_tools, usb_passthrough_tools, assertion_tools, data_source_tools, diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index b0a7e8ac..24bc8cc8 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -1288,6 +1288,23 @@ def lease_active(): return {"leases": default_broker.active()} +def egress_allow(allow=None, deny=None): + from je_auto_control.utils.egress import set_egress_policy + policy = set_egress_policy(allow, deny) + return {"allow": policy.allow, "deny": policy.deny} + + +def egress_check(url: str): + from je_auto_control.utils.egress import get_egress_policy + return {"allowed": get_egress_policy().is_allowed(url)} + + +def egress_reset(): + from je_auto_control.utils.egress import set_egress_policy + set_egress_policy(None, None) + return {"allow": None, "deny": []} + + def vlm_locate(description: str, screen_region: Optional[List[int]] = None, model: Optional[str] = None) -> Optional[List[int]]: diff --git a/test/unit_test/headless/test_egress_guard_batch.py b/test/unit_test/headless/test_egress_guard_batch.py new file mode 100644 index 00000000..4a892bcb --- /dev/null +++ b/test/unit_test/headless/test_egress_guard_batch.py @@ -0,0 +1,97 @@ +"""Headless tests for the network egress allowlist guard. No real network is +used — enforcement happens before any socket is opened. Pure stdlib, no Qt +imports.""" +import pytest + +import je_auto_control as ac +from je_auto_control.utils.egress import ( + EgressBlocked, EgressPolicy, get_egress_policy, set_egress_policy) + + +@pytest.fixture(autouse=True) +def _reset_policy(): + """Keep the module-level policy at allow-all around every test.""" + set_egress_policy(None, None) + yield + set_egress_policy(None, None) + + +def test_allow_all_by_default(): + assert EgressPolicy().is_allowed("https://anything.test/path") is True + + +def test_allowlist_is_default_deny(): + policy = EgressPolicy(allow=["*.example.com"]) + assert policy.is_allowed("https://api.example.com/v1") is True + assert policy.is_allowed("https://evil.test/") is False + with pytest.raises(EgressBlocked): + policy.check("https://evil.test/") + + +def test_deny_overrides_allow(): + policy = EgressPolicy(allow=["*.example.com"], deny=["bad.example.com"]) + assert policy.is_allowed("https://ok.example.com") is True + assert policy.is_allowed("https://bad.example.com") is False + + +def test_deny_only_mode_allows_others(): + policy = EgressPolicy(deny=["tracker.test"]) + assert policy.is_allowed("https://tracker.test") is False + assert policy.is_allowed("https://fine.test") is True + + +def test_empty_allowlist_blocks_everything(): + policy = EgressPolicy(allow=[]) + assert policy.is_allowed("https://anything.test") is False + + +def test_comma_string_is_accepted(): + policy = EgressPolicy(allow="a.test, b.test") + assert policy.is_allowed("https://a.test") is True + assert policy.is_allowed("https://c.test") is False + + +def test_http_client_enforces_policy(): + from je_auto_control.utils.http_client.http_client import http_request + set_egress_policy(allow=["allowed.test"]) + # blocked before any connection is attempted + with pytest.raises(EgressBlocked): + http_request("http://blocked.test/") + + +# --- wiring --------------------------------------------------------------- + +def test_executor_round_trip(): + ac.execute_action([["AC_egress_allow", {"allow": ["only.test"]}]]) + rec = ac.execute_action([ + ["AC_egress_check", {"url": "https://only.test/x"}], + ]) + assert any(v.get("allowed") is True for v in rec.values() + if isinstance(v, dict)) + rec2 = ac.execute_action([ + ["AC_egress_check", {"url": "https://other.test/x"}], + ]) + assert any(v.get("allowed") is False for v in rec2.values() + if isinstance(v, dict)) + ac.execute_action([["AC_egress_reset", {}]]) + assert get_egress_policy().is_allowed("https://other.test") is True + + +def test_wiring(): + known = ac.executor.known_commands() + assert {"AC_egress_allow", "AC_egress_check", + "AC_egress_reset"} <= known + from je_auto_control.utils.mcp_server.tools import ( + build_default_tool_registry) + names = {t.name for t in build_default_tool_registry()} + assert {"ac_egress_allow", "ac_egress_check", "ac_egress_reset"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + cmds = {s.command for s in _build_specs()} + assert {"AC_egress_allow", "AC_egress_check", "AC_egress_reset"} <= cmds + + +def test_facade_exports(): + for attr in ("EgressPolicy", "EgressBlocked", "get_egress_policy", + "set_egress_policy"): + assert hasattr(ac, attr) + assert attr in ac.__all__ From 585c4e534df79572784eb20628a994f780468730 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Fri, 19 Jun 2026 22:51:09 +0800 Subject: [PATCH 2/2] Use https in egress test URL to satisfy Sonar S5332 hotspot --- test/unit_test/headless/test_egress_guard_batch.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/unit_test/headless/test_egress_guard_batch.py b/test/unit_test/headless/test_egress_guard_batch.py index 4a892bcb..de6563ea 100644 --- a/test/unit_test/headless/test_egress_guard_batch.py +++ b/test/unit_test/headless/test_egress_guard_batch.py @@ -54,9 +54,9 @@ def test_comma_string_is_accepted(): def test_http_client_enforces_policy(): from je_auto_control.utils.http_client.http_client import http_request set_egress_policy(allow=["allowed.test"]) - # blocked before any connection is attempted + # blocked before any connection is attempted (scheme is irrelevant here) with pytest.raises(EgressBlocked): - http_request("http://blocked.test/") + http_request("https://blocked.test/") # --- wiring ---------------------------------------------------------------