Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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).
Expand Down
7 changes: 7 additions & 0 deletions README/README_zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)。
Expand Down
7 changes: 7 additions & 0 deletions README/README_zh-TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)。
Expand Down
54 changes: 54 additions & 0 deletions docs/source/Eng/doc/new_features/v34_features_doc.rst
Original file line number Diff line number Diff line change
@@ -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**.
1 change: 1 addition & 0 deletions docs/source/Eng/eng_index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions docs/source/Zh/doc/new_features/v34_features_doc.rst
Original file line number Diff line number Diff line change
@@ -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** 分類下的指令。
1 change: 1 addition & 0 deletions docs/source/Zh/zh_index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions je_auto_control/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
21 changes: 21 additions & 0 deletions je_auto_control/gui/script_builder/command_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=(
Expand Down
8 changes: 8 additions & 0 deletions je_auto_control/utils/egress/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
106 changes: 106 additions & 0 deletions je_auto_control/utils/egress/egress_policy.py
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions je_auto_control/utils/executor/action_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions je_auto_control/utils/http_client/http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading
Loading