From 3cd09069d9e765e10eeb711d2045387af6542a38 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 1 Oct 2026 14:03:30 +0800 Subject: [PATCH] Gate AC_add_package_to_executor behind a package allowlist so an action list cannot load os or subprocess --- CHANGELOG.md | 13 +++ Progress.md | 10 ++ README.md | 2 + README/README_zh-CN.md | 2 + README/README_zh-TW.md | 2 + architecture.md | 4 + architecture_explore.md | 20 ++-- docs/source/API/utils/package_manager.rst | 12 +++ .../keyword_and_executor_doc.rst | 16 ++++ .../keyword_and_executor_doc.rst | 15 +++ docs/updates/2026-10.md | 24 +++++ docs/updates/README.md | 3 +- .../utils/executor/action_executor.py | 15 +++ .../package_manager/package_manager_class.py | 52 +++++++++++ test/unit_test/headless/test_package_gate.py | 93 +++++++++++++++++++ 15 files changed, 272 insertions(+), 11 deletions(-) create mode 100644 test/unit_test/headless/test_package_gate.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c6bb5fd9..ff872ca83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,12 @@ it shipped into a `## [x.y.z] - date` section of their own; the tag's ### Added +- A package gate in front of `AC_add_package_to_executor` and + `AC_add_package_to_callback_executor`: `executor.allow_packages(*names)` (submodules + included) and `executor.set_allow_arbitrary_packages(enabled)` (also on + `package_manager`). A refused package raises `AutoControlExecuteActionException` + before it is imported. + - `message_format.MessageFormatError` (an `AutoControlException` and a `ValueError`); locales other than en/fr use Babel's CLDR plural rules when Babel is installed. @@ -101,6 +107,13 @@ it shipped into a `## [x.y.z] - date` section of their own; the tag's generating values from the schema alone used to produce a plain string and get a `ValueError` out of `datetime.fromisoformat`. +### Deprecated + +- Loading a package that is not on the allowlist while the package gate is unconfigured. + It still works, with a `DeprecationWarning`; a future release will refuse it by default. + Migration: call `executor.allow_packages(...)` for the packages your action lists load, + or `executor.set_allow_arbitrary_packages(True)` to keep loading any package. + ### Changed - `format_message` raises `MessageFormatError` for patterns ICU rejects diff --git a/Progress.md b/Progress.md index 85a522de4..458851511 100644 --- a/Progress.md +++ b/Progress.md @@ -510,3 +510,13 @@ be at 2x if on a Retina screen」,`scale_down=True` 只在帶 `bbox` 時生效 ## `test_usb_acl_prompt.py` 讓 Python 3.10 的 headless 測試間歇 segfault `TODO` — `test/unit_test/headless/test_usb_acl_prompt.py::test_bridge_remember_persists_acl_rule` 在 `coverage run -m pytest` 下讓行程 SIGSEGV(exit 139),整個 `pytest-headless` job 因此失敗:2026-09-26 連續三次 AutoControl Code Quality(ubuntu-22.04/3.10),2026-09-30 一次(macos-14/3.10);同一次其他版本都過,之後的 run 又過,所以是間歇的。原因還沒查:先在 3.10 開 `faulthandler` 重跑這一支,看崩在哪個原生呼叫。 + +--- + +## 套件閘門的預設改成拒絕 + +`BLOCKED` — 等含警告的版本出去之後再發兩版 + +`AC_add_package_to_executor`/`AC_add_package_to_callback_executor` 前面已有套件閘門(工作區 X-12),但沒設定時仍會載入任何套件、只發 `DeprecationWarning`。兩個版本之後,在 `utils/package_manager/package_manager_class.py` 的 `PackageManager.__init__` 把 `allow_arbitrary_packages` 改成 `False`,拿掉 `_check_allowed` 裡的警告分支,並更新三份 README 的「Package gate」段落、`docs/source/{Eng,Zh}/doc/keyword_and_executor/keyword_and_executor_doc.rst` 與 `docs/source/API/utils/package_manager.rst`,`CHANGELOG.md` 記成破壞性變更。 + +**先決定**:只跑動作檔、沒有 Python 宿主程式的使用者(`je_auto_control` CLI、socket/REST/MCP server、排程器)要怎麼放行套件。 diff --git a/README.md b/README.md index fa7e40b3f..e7672370e 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,8 @@ still goes on to the end), so a CI step fails with it. The legacy All servers bind to `127.0.0.1` unless you opt in explicitly. +**Package gate.** `AC_add_package_to_executor` and `AC_add_package_to_callback_executor` import a Python package and register its members as commands, so an action list arriving over any of these surfaces could load `os` or `subprocess`. The host program decides what may load: `executor.allow_packages("name", …)` lists the packages (submodules included) and `executor.set_allow_arbitrary_packages(False)` refuses the rest before importing them. Neither is an `AC_*` command, so an action list cannot open its own gate. A refused package fails that action with `AutoControlExecuteActionException`. Until the host calls either switch, any package still loads but raises a `DeprecationWarning`: a future release will refuse unlisted packages by default. + ### How the remote-desktop wire protocol works Worth knowing before you expose a host, and described nowhere else in the diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 69e548699..c3513ef65 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -200,6 +200,8 @@ je_auto_control version 除非明确指定,所有服务器都绑定在 `127.0.0.1`。 +**包闸门。** `AC_add_package_to_executor` 与 `AC_add_package_to_callback_executor` 会导入 Python 包并把成员注册成命令,所以从上面任何一个入口送来的动作列表都可能加载 `os` 或 `subprocess`。哪些包可以加载,由宿主程序决定:`executor.allow_packages("name", …)` 列出可以加载的包(含子模块),`executor.set_allow_arbitrary_packages(False)` 会在导入前拒绝其他包。这两个都不是 `AC_*` 命令,所以动作列表不能自己打开闸门。被拒绝的包会让该动作以 `AutoControlExecuteActionException` 失败。宿主程序调用任一个开关之前,任何包仍会加载,但会发出 `DeprecationWarning`:之后的版本会默认拒绝清单以外的包。 + ### 远程桌面的线路协议 把主机开放出去之前值得先了解,而且这一段在其他文档里都没有写。默认传输是**裸 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 61af44442..61c5ee13e 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -200,6 +200,8 @@ je_auto_control version 除非明確指定,所有伺服器都綁在 `127.0.0.1`。 +**套件閘門。** `AC_add_package_to_executor` 與 `AC_add_package_to_callback_executor` 會匯入 Python 套件並把成員註冊成命令,所以從上面任何一個入口送來的動作清單都可能載入 `os` 或 `subprocess`。哪些套件可以載入,由宿主程式決定:`executor.allow_packages("name", …)` 列出可以載入的套件(含子模組),`executor.set_allow_arbitrary_packages(False)` 會在匯入前拒絕其他套件。這兩個都不是 `AC_*` 命令,所以動作清單不能自己打開閘門。被拒絕的套件會讓該動作以 `AutoControlExecuteActionException` 失敗。宿主程式呼叫任一個開關之前,任何套件仍會載入,但會發出 `DeprecationWarning`:之後的版本會預設拒絕清單以外的套件。 + ### 遠端桌面的線路協定 把主機開出去之前值得先知道,而且這段在其他文件裡都沒有寫。預設傳輸是**裸 TCP diff --git a/architecture.md b/architecture.md index d3838503f..a45f27434 100644 --- a/architecture.md +++ b/architecture.md @@ -188,6 +188,10 @@ first. `AC_*` command names and the legacy CLI flags are public too (action file - Flat exception hierarchy: every framework error derives from `AutoControlException`; assertion failures keep propagating. → CLAUDE.md › Coding Standards › Project-specific rules - Validate at boundaries and reject unknown command names; servers bind `127.0.0.1` unless explicitly opted in. → same +- `AC_add_package_to_executor` / `AC_add_package_to_callback_executor` pass the package gate in + `utils/package_manager/package_manager_class.py` before importing: `executor.allow_packages(...)` and + `executor.set_allow_arbitrary_packages(...)` are Python-only switches, never `AC_*` commands, so an action list + cannot open its own gate. Unconfigured, any package loads with a `DeprecationWarning` (workspace X-12). - No `print()` or runtime `assert` in library code; lazy imports for optional and platform deps; release platform resources in `finally` / `with`; guard shared state with locks or queues; pin dependency versions. → same - Size limits (cyclomatic ≤ 10, cognitive ≤ 15, function ≤ 75 lines, file ≤ 750 lines, line ≤ 120) are a review diff --git a/architecture_explore.md b/architecture_explore.md index eb0d49ec8..d87052223 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -20,7 +20,7 @@ iOS(WebDriverAgent)。核心能力是滑鼠/鍵盤控制、影像辨識、 | 指標 | 數值 | | --- | ---: | | Python 模組總數(含周邊子專案) | 1,063 | -| 程式碼總行數 | 156,988 | +| 程式碼總行數 | 157,055 | | `je_auto_control/utils/` 子套件數 | 310 | | `AC_*` 動作指令數(`known_commands()` 實測) | 775 | | 套件門面 `__all__` 公開名稱數 | 1,244 | @@ -272,7 +272,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.1 執行引擎與腳本資產 -> 24 個套件、約 14,670 行。 +> 24 個套件、約 14,685 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | @@ -283,7 +283,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/dag/` | 536 | 跨主機 DAG 編排器(圖模型 + runner) | | `utils/decision_table/` | 112 | DMN 風格決策表:規則 + 命中策略,把分支外部化 | | `utils/deterministic/` | 116 | 決定性執行控制:固定亂數種子 + 凍結時鐘 | -| `utils/executor/` | 9,541 | **核心**。`Executor` 指令分派表(775 個 `AC_*`)、參數插值、乾跑、逐步 callback;`flow_control` 提供 34 個區塊指令(迴圈/分支/try/巨集/變數) | +| `utils/executor/` | 9,556 | **核心**。`Executor` 指令分派表(775 個 `AC_*`)、參數插值、乾跑、逐步 callback;`flow_control` 提供 34 個區塊指令(迴圈/分支/try/巨集/變數) | | `utils/flow_debugger/` | 166 | action list 的單步除錯器與追蹤器 | | `utils/input_macro/` | 462 | 定時輸入事件:錄製結果的整形(`timeline`/`InputRecorder`,Windows 與 macOS 共用)、重播與宣告式輸入序列 DSL | | `utils/json/` | 99 | action JSON 檔讀寫與正規化格式化(`fmt --check` 的後端) | @@ -303,7 +303,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.2 框架基礎設施 -> 14 個套件、約 3,053 行。 +> 14 個套件、約 3,105 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | @@ -316,7 +316,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/failure_bundle/` | 229 | 可攜、已遮蔽的失敗診斷 ZIP(截圖 + 診斷 + log 尾段) | | `utils/file_process/` | 40 | 目錄檔案列舉(`execute_dir` 的後端) | | `utils/logging/` | 168 | `autocontrol_logger` 單例 + 家目錄共用記錄檔 handler(`JE_AUTOCONTROL_LOG_FILE` 可改) | -| `utils/package_manager/` | 103 | 動態載入套件並把 executor 注入其中 | +| `utils/package_manager/` | 155 | 動態載入套件並把 executor 注入其中;載入前先過套件閘門(允許清單加上只能從 Python 呼叫的開關,工作區 X-12) | | `utils/path_guard/` | 114 | 命令列傳入路徑的正規化與邊界檢查(防路徑穿越) | | `utils/platform_id/` | 62 | 作業系統家族的單一判定點。`sys.platform` 原本在一百多處跟字面清單比對,而那些清單都沒有 BSD;`is_x11_unix()` 問的是「這是不是 X11 unix」,這才是守衛一直想問的問題 | | `utils/shell_process/` | 279 | `ShellManager`:以 argv list 執行外部命令(禁用 `shell=True`) | @@ -696,11 +696,11 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 上表以子套件為單位;以下把行數最大的幾個子系統展開到檔案層。 -#### `utils/executor/`(9,541 行)— 執行核心 +#### `utils/executor/`(9,556 行)— 執行核心 | 檔案 | 行數 | 職責 | | --- | ---: | --- | -| `action_executor.py` | 8,325 | `Executor` 類別與 `event_dict` 分派表(775 個指令),另含數百個把 utils 能力接成指令的 adapter 函式;全域單例 `executor` 與 `add_command_to_executor()` 擴充點。 | +| `action_executor.py` | 8,340 | `Executor` 類別與 `event_dict` 分派表(775 個指令),另含數百個把 utils 能力接成指令的 adapter 函式;全域單例 `executor` 與 `add_command_to_executor()` 擴充點。 | | `flow_control.py` | 644 | 真正的流程控制:`AC_loop`/`AC_for_each`/`AC_while_*`/`AC_if_*`/`AC_try`/`AC_retry`/`AC_parallel`/`AC_define_macro`/`AC_call_macro`/變數指令(`AC_set_var`/`AC_get_var`/`AC_inc_var`)。`LoopBreak`/`LoopContinue` 以例外實作。34 個區塊指令的分派表 `BLOCK_COMMANDS` 也在這裡,含下一列匯入的資料來源指令。 | | `flow_data_commands.py` | 272 | `AC_*_to_var` 資料來源與轉換指令:shell、時鐘、亂數、PDF、TOTP、SQL、檔案、HTTP、OCR,加上 `AC_assert_var`/`AC_assert_db`/`AC_assert_duration`/`AC_transform_var`。都不執行巢狀 action list,所以沒有迴圈/分支語意。 | | `action_schema.py` | 159 | action list 的結構驗證:形狀、參數型別、未知指令拒絕。單一走訪同時支援兩種消費方式:`validate_actions()` 遇到第一個問題就拋、`unknown_command_names()` 收齊全部不認得的名字(REST `/execute` 用它回 400)。 | @@ -1073,7 +1073,7 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | `gui/` | 95 | 27,824 | | `utils/mcp_server/` | 35 | 18,846 | | `utils/remote_desktop/` | 56 | 13,014 | -| `utils/executor/` | 8 | 9,541 | +| `utils/executor/` | 8 | 9,556 | | `utils/usb/` | 17 | 4,572 | | `je_auto_control/`(頂層 3 檔) | 3 | 2,410 | | `utils/accessibility/` | 14 | 3,143 | @@ -1090,6 +1090,6 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | `osx/` | 17 | 925 | | `autocontrol-lsp/` | 8 | 744 | | `utils/hotkey/` | 7 | 852 | -| 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 681 | 56,986 | -| **總計** | **1,057** | **156,923** | +| 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 681 | 57,038 | +| **總計** | **1,057** | **156,990** | diff --git a/docs/source/API/utils/package_manager.rst b/docs/source/API/utils/package_manager.rst index f3fa093d1..1b211d46d 100644 --- a/docs/source/API/utils/package_manager.rst +++ b/docs/source/API/utils/package_manager.rst @@ -45,6 +45,18 @@ PackageManager :param predicate: Inspection predicate (e.g., ``isfunction``, ``isclass``). :param target: Target executor whose ``event_dict`` will be updated. + .. method:: allow_packages(*packages) + + Adds packages, and their submodules, to the package gate's allowlist. + + .. method:: set_allow_arbitrary_packages(enabled) + + Allows (``True``) or refuses (``False``) packages outside the allowlist. Until either switch + is called, any package loads with a ``DeprecationWarning``. ``add_package_to_executor`` and + ``add_package_to_callback_executor`` check the gate before importing and raise + ``AutoControlExecuteActionException`` for a refused package. The ``Executor`` has the same two + static methods; neither is an ``AC_*`` command. + .. method:: add_package_to_target(package, target) Loads functions, built-ins, and classes from a package into the specified target executor. diff --git a/docs/source/Eng/doc/keyword_and_executor/keyword_and_executor_doc.rst b/docs/source/Eng/doc/keyword_and_executor/keyword_and_executor_doc.rst index 3c8e4c3a2..b5ac30a5e 100644 --- a/docs/source/Eng/doc/keyword_and_executor/keyword_and_executor_doc.rst +++ b/docs/source/Eng/doc/keyword_and_executor/keyword_and_executor_doc.rst @@ -83,6 +83,22 @@ Extending the Executor You can dynamically load external Python packages into the executor: +The package gate decides which packages may load. ``AC_add_package_to_executor`` can import ``os`` or +``subprocess`` for any action list, so the host program lists what it needs: + +.. code-block:: python + + from je_auto_control import executor + + executor.allow_packages("time") # these, and their submodules + executor.set_allow_arbitrary_packages(False) # refuse everything else before importing it + +Neither switch is an ``AC_*`` command, so an action list cannot open its own gate; a refused package fails +its action with ``AutoControlExecuteActionException``. Until the host calls either switch, any package still +loads but raises a ``DeprecationWarning``; a future release will refuse packages outside the allowlist by +default. + + .. code-block:: python from je_auto_control import package_manager diff --git a/docs/source/Zh/doc/keyword_and_executor/keyword_and_executor_doc.rst b/docs/source/Zh/doc/keyword_and_executor/keyword_and_executor_doc.rst index d5d383382..a56da5c47 100644 --- a/docs/source/Zh/doc/keyword_and_executor/keyword_and_executor_doc.rst +++ b/docs/source/Zh/doc/keyword_and_executor/keyword_and_executor_doc.rst @@ -82,6 +82,21 @@ JSON 陣列(關鍵字),由執行者解析並執行。 你可以動態載入外部 Python 套件到執行者中: +哪些套件可以載入由套件閘門決定。``AC_add_package_to_executor`` 能替任何動作清單匯入 ``os`` 或 +``subprocess``,所以由宿主程式列出它需要的套件: + +.. code-block:: python + + from je_auto_control import executor + + executor.allow_packages("time") # 這些套件與其子模組 + executor.set_allow_arbitrary_packages(False) # 其他套件在匯入前就拒絕 + +這兩個開關都不是 ``AC_*`` 命令,所以動作清單不能自己打開閘門;被拒絕的套件會讓該動作以 +``AutoControlExecuteActionException`` 失敗。宿主程式呼叫任一個開關之前,任何套件仍會載入,但會發出 +``DeprecationWarning``;之後的版本會預設拒絕允許清單以外的套件。 + + .. code-block:: python from je_auto_control import package_manager diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index f9ed6b773..1142400cc 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -36,3 +36,27 @@ WebRunner's native commands (its U-20261001-28 and -29), `WR_ac_fill_native_file §6's WebRunner row lists them. - `Progress.md`, in the keyboard fixes waiting for Jeffrey_RPA's batch to stop, records the missing piece for WebRunner's `WR_ac_basic_auth`. `write()` and `type_keyboard()` log the typed text at INFO, record it and return it, so a password typed for WebRunner would reach this package's log and both run records. A typing call that does none of that is needed. - **Files**: `architecture.md`, `Progress.md`, `test/unit_test/headless/test_cross_project_contracts.py`. + +## U-20261001-07 · 2026-10-01 · Package gate in front of AC_add_package_to_executor · #security #X-12 + +- **What**: workspace X-12. `AC_add_package_to_executor` / `AC_add_package_to_callback_executor` imported any installed package and registered its members as commands. Any action list could therefore load `os` or `subprocess`, whether it came from a JSON file, the socket / REST / MCP servers or the scheduler. + - `PackageManager` now checks the name before importing. This is the same gate WebRunner, APITestka, LoadDensity and MailThunder use: + - `allow_packages(*names)` sets the allowlist (a listed package also allows its submodules); + - `set_allow_arbitrary_packages(bool)` opens the gate (`True`) or refuses everything else (`False`); + - a refusal raises `AutoControlExecuteActionException`. + - The `Executor` has the same two static methods. Neither is an `AC_*` command, and a test checks that no command name contains them. + - Unconfigured, any package still loads with a `DeprecationWarning`. Flipping that default is a `Progress.md` item. + - ID 07 because `feat/jeffrey-rpa-gui-apis` already uses 03–06. +- **Docs**: + - the "Package gate" paragraph in the three READMEs (Servers and integrations); + - `docs/source/API/utils/package_manager.rst`; + - the "Extending the Executor" section of `docs/source/{Eng,Zh}/doc/keyword_and_executor/keyword_and_executor_doc.rst`; + - `architecture.md` §7 and the `utils/package_manager/` row in `architecture_explore.md`, with its line counts re-measured by `test_doc_line_counts.py --fix`; + - `CHANGELOG.md` (Added and Deprecated). +- **Result / numbers**: `test/unit_test/headless/test_package_gate.py` adds 8 tests. The full headless suite on this branch: 10559 passed, 46 skipped. +- **Files**: + - `je_auto_control/utils/package_manager/package_manager_class.py`, `je_auto_control/utils/executor/action_executor.py`; + - `test/unit_test/headless/test_package_gate.py`; + - `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`; + - `docs/source/API/utils/package_manager.rst`, `docs/source/{Eng,Zh}/doc/keyword_and_executor/keyword_and_executor_doc.rst`; + - `architecture.md`, `architecture_explore.md`, `CHANGELOG.md`, `Progress.md`. diff --git a/docs/updates/README.md b/docs/updates/README.md index 325d83f7c..debf4c728 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261001-07 | 2026-10-01 | Package gate in front of AC_add_package_to_executor | #security #X-12 | [2026-10](2026-10.md) | | U-20261001-02 | 2026-10-01 | Pin the AC commands WebRunner's native WR_ac_* send; record the missing secret typing | #contract #webrunner | [2026-10](2026-10.md) | | U-20261001-01 | 2026-10-01 | Run AC_web_run's documented form, record WebRunner failures, gate through execute_one | #bugfix #webrunner | [2026-10](2026-10.md) | | U-20260926-41 | 2026-09-26 | Pin ruff's rule set in pyproject.toml so a ruff upgrade does not change what CI enforces | #ci #tooling | [2026-09-e](2026-09-e.md) | @@ -338,7 +339,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 2 | +| [2026-10.md](2026-10.md) | 2026-10 | 3 | | [2026-09-e.md](2026-09-e.md) | 2026-09 | 7 | | [2026-09-d.md](2026-09-d.md) | 2026-09 | 55 | | [2026-09-c.md](2026-09-c.md) | 2026-09 | 38 | diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index b8b770ce9..8fa141903 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -8008,6 +8008,21 @@ def __init__(self): "AC_arrange_cascade": _arrange_cascade, } + @staticmethod + def set_allow_arbitrary_packages(enabled: bool) -> None: + """ + Allow (True) or refuse (False) ``AC_add_package_to_executor`` / + ``AC_add_package_to_callback_executor`` for packages outside the allowlist. Python only, + never an action command, so an action list cannot open its own gate. Until it is called, + any package loads with a ``DeprecationWarning``. + """ + package_manager.set_allow_arbitrary_packages(enabled) + + @staticmethod + def allow_packages(*packages: str) -> None: + """Add packages, and their submodules, to the allowlist of ``AC_add_package_to_executor``.""" + package_manager.allow_packages(*packages) + def known_commands(self) -> set: """Return the set of all command names the executor recognises.""" return set(self.event_dict.keys()) | set(self._block_commands.keys()) diff --git a/je_auto_control/utils/package_manager/package_manager_class.py b/je_auto_control/utils/package_manager/package_manager_class.py index 39fd8ff38..8c64fd375 100644 --- a/je_auto_control/utils/package_manager/package_manager_class.py +++ b/je_auto_control/utils/package_manager/package_manager_class.py @@ -1,13 +1,17 @@ import importlib import re +import warnings from importlib.util import find_spec from inspect import getmembers, isfunction, isbuiltin, isclass from types import ModuleType from typing import Optional +from je_auto_control.utils.exception.exceptions import AutoControlExecuteActionException from je_auto_control.utils.logging.logging_instance import autocontrol_logger _PACKAGE_NAME_RE = re.compile(r"^[A-Za-z_]\w*(\.[A-Za-z_]\w*)*$") +# warnings.warn -> _check_allowed -> add_package_to_* -> the command's caller +_GATE_WARNING_STACKLEVEL = 3 class PackageManager: @@ -22,6 +26,48 @@ def __init__(self): self.installed_package_dict: dict[str, ModuleType] = {} self.executor = None self.callback_executor = None + # Package gate (workspace X-12). None = not configured: any package still loads, with a + # DeprecationWarning. False = only ``allowed_packages``; True = any package, silently. + self.allow_arbitrary_packages: Optional[bool] = None + self.allowed_packages: set[str] = set() + + def set_allow_arbitrary_packages(self, enabled: bool) -> None: + """ + 設定是否允許載入允許清單以外的套件 + Allow (True) or refuse (False) packages outside :attr:`allowed_packages`. + Deliberately not an ``AC_*`` command: an action list must not open its own gate. + """ + self.allow_arbitrary_packages = bool(enabled) + + def allow_packages(self, *packages: str) -> None: + """ + 把套件加入允許清單(連同其子模組) + Add packages to the allowlist; a listed package also allows its submodules. + """ + self.allowed_packages.update(packages) + + def _is_allowlisted(self, package: str) -> bool: + return any(package == allowed or package.startswith(allowed + ".") + for allowed in self.allowed_packages) + + def _check_allowed(self, package: object) -> None: + """Refuse ``package`` before it is imported, unless the gate lets it through.""" + if isinstance(package, str) and self._is_allowlisted(package): + return + if self.allow_arbitrary_packages is True: + return + if self.allow_arbitrary_packages is False: + raise AutoControlExecuteActionException( + f"package {package!r} is not allowed; the host must call " + "executor.allow_packages(...) or executor.set_allow_arbitrary_packages(True)" + ) + warnings.warn( + f"loading package {package!r} that is not on the allowlist; a future release will refuse " + "it by default. Call executor.allow_packages(...) for the packages you load, or " + "executor.set_allow_arbitrary_packages(True) to keep loading any package.", + DeprecationWarning, + stacklevel=_GATE_WARNING_STACKLEVEL, + ) def check_package(self, package: str) -> Optional[ModuleType]: """ @@ -54,16 +100,22 @@ def add_package_to_executor(self, package: str) -> None: """ 將套件成員加入 Executor Add package members to Executor + + :raises AutoControlExecuteActionException: the package gate refused ``package`` (nothing is imported) """ autocontrol_logger.info(f"add_package_to_executor, package: {package}") + self._check_allowed(package) self.add_package_to_target(package, self.executor) def add_package_to_callback_executor(self, package: str) -> None: """ 將套件成員加入 CallbackExecutor Add package members to CallbackExecutor + + :raises AutoControlExecuteActionException: the package gate refused ``package`` (nothing is imported) """ autocontrol_logger.info(f"add_package_to_callback_executor, package: {package}") + self._check_allowed(package) self.add_package_to_target(package, self.callback_executor) def get_member(self, package: str, predicate, target) -> None: diff --git a/test/unit_test/headless/test_package_gate.py b/test/unit_test/headless/test_package_gate.py new file mode 100644 index 000000000..cf0600049 --- /dev/null +++ b/test/unit_test/headless/test_package_gate.py @@ -0,0 +1,93 @@ +"""The package gate in front of AC_add_package_to_executor (workspace X-12).""" +import types +import warnings +from unittest.mock import patch + +import pytest + +from je_auto_control.utils.exception.exceptions import AutoControlExecuteActionException +from je_auto_control.utils.executor.action_executor import executor +from je_auto_control.utils.package_manager import package_manager_class +from je_auto_control.utils.package_manager.package_manager_class import PackageManager + + +def _manager() -> PackageManager: + manager = PackageManager() + manager.executor = types.SimpleNamespace(event_dict={}) + return manager + + +@pytest.fixture +def shared_gate(): + manager = package_manager_class.package_manager + saved = (manager.allow_arbitrary_packages, set(manager.allowed_packages)) + yield manager + manager.allow_arbitrary_packages, manager.allowed_packages = saved[0], set(saved[1]) + + +def test_unconfigured_gate_still_loads_but_warns(): + manager = _manager() + with pytest.warns(DeprecationWarning, match="not on the allowlist"): + manager.add_package_to_executor("json") + assert "json_dumps" in manager.executor.event_dict + + +def test_closed_gate_refuses_before_importing(): + manager = _manager() + manager.set_allow_arbitrary_packages(False) + with patch.object(package_manager_class.importlib, "import_module") as importer: + with pytest.raises(AutoControlExecuteActionException, match="not allowed"): + manager.add_package_to_executor("os") + importer.assert_not_called() + assert manager.executor.event_dict == {} + + +def test_closed_gate_refuses_the_callback_executor_too(): + manager = PackageManager() + manager.callback_executor = types.SimpleNamespace(event_dict={}) + manager.set_allow_arbitrary_packages(False) + with pytest.raises(AutoControlExecuteActionException): + manager.add_package_to_callback_executor("subprocess") + assert manager.callback_executor.event_dict == {} + + +def test_allowlisted_package_and_its_submodules_load_without_warning(): + manager = _manager() + manager.set_allow_arbitrary_packages(False) + manager.allow_packages("json") + with warnings.catch_warnings(): + warnings.simplefilter("error") + manager.add_package_to_executor("json") + manager.add_package_to_executor("json.decoder") + assert "json.decoder_JSONDecoder" in manager.executor.event_dict + with pytest.raises(AutoControlExecuteActionException): + manager.add_package_to_executor("jsonschema_lookalike") + + +def test_open_gate_loads_anything_without_warning(): + manager = _manager() + manager.set_allow_arbitrary_packages(True) + with warnings.catch_warnings(): + warnings.simplefilter("error") + manager.add_package_to_executor("json") + assert "json_dumps" in manager.executor.event_dict + + +def test_executor_configures_the_shared_gate(shared_gate): + executor.set_allow_arbitrary_packages(False) + executor.allow_packages("my_company_helpers") + assert shared_gate.allow_arbitrary_packages is False + assert "my_company_helpers" in shared_gate.allowed_packages + + +def test_no_action_command_can_open_the_gate(): + for name in executor.known_commands(): + assert "allow_arbitrary_packages" not in name + assert "allow_packages" not in name + + +def test_refusal_reaches_the_action_record(shared_gate): + executor.set_allow_arbitrary_packages(False) + record = executor.execute_action([["AC_add_package_to_executor", {"package": "os"}]]) + assert any("not allowed" in str(value) for value in record.values()) + assert "os_system" not in executor.event_dict