From b2cee5f3d9e4bbb340a4269d912f45801496af3f Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Fri, 19 Jun 2026 22:39:10 +0800 Subject: [PATCH] Add just-in-time credential leases (zero standing privilege) --- README.md | 7 ++ README/README_zh-CN.md | 7 ++ README/README_zh-TW.md | 7 ++ .../Eng/doc/new_features/v33_features_doc.rst | 53 +++++++++ docs/source/Eng/eng_index.rst | 1 + .../Zh/doc/new_features/v33_features_doc.rst | 50 ++++++++ docs/source/Zh/zh_index.rst | 1 + je_auto_control/__init__.py | 10 +- .../gui/script_builder/command_schema.py | 23 ++++ .../utils/executor/action_executor.py | 28 +++++ je_auto_control/utils/governance/__init__.py | 10 +- .../utils/governance/credential_broker.py | 104 ++++++++++++++++ .../utils/mcp_server/tools/_factories.py | 41 +++++++ .../utils/mcp_server/tools/_handlers.py | 20 ++++ .../headless/test_credential_lease_batch.py | 111 ++++++++++++++++++ 15 files changed, 468 insertions(+), 5 deletions(-) create mode 100644 docs/source/Eng/doc/new_features/v33_features_doc.rst create mode 100644 docs/source/Zh/doc/new_features/v33_features_doc.rst create mode 100644 je_auto_control/utils/governance/credential_broker.py create mode 100644 test/unit_test/headless/test_credential_lease_batch.py diff --git a/README.md b/README.md index e22c0751..274838f1 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ## Table of Contents +- [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) - [What's new (2026-06-19) — MCP Structured Output](#whats-new-2026-06-19--mcp-structured-output) @@ -85,6 +86,12 @@ --- +## 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). + +- **`CredentialBroker`** (`AC_lease_secret` / `AC_lease_valid` / `AC_revoke_lease` / `AC_lease_active`, `ac_*`): a consumer takes a short-lived *lease* (token bound to a secret name + expiry); the real value is fetched only at `redeem` time, only while valid, through a pluggable resolver (an unlocked `SecretManager`, env, vault). Secret values never enter executor/MCP records — the executor/MCP/Builder surfaces manage the lease lifecycle only; `redeem` is a deliberate Python-API-only escape hatch. Clock and resolver injectable. + ## What's new (2026-06-19) — Maker-Checker Approval Gate Segregation of duties for high-risk steps. Full reference: [`docs/source/Eng/doc/new_features/v32_features_doc.rst`](docs/source/Eng/doc/new_features/v32_features_doc.rst). diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index be131988..f8598a0a 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) — Maker-Checker 审批闸门](#本次更新-2026-06-19--maker-checker-审批闸门) - [本次更新 (2026-06-19) — Plugin SDK](#本次更新-2026-06-19--plugin-sdk) - [本次更新 (2026-06-19) — MCP 结构化输出](#本次更新-2026-06-19--mcp-结构化输出) @@ -84,6 +85,12 @@ --- +## 本次更新 (2026-06-19) — 即时凭证租约 + +密钥的零常驻权限。完整参考:[`docs/source/Zh/doc/new_features/v33_features_doc.rst`](../docs/source/Zh/doc/new_features/v33_features_doc.rst)。 + +- **`CredentialBroker`**(`AC_lease_secret` / `AC_lease_valid` / `AC_revoke_lease` / `AC_lease_active`、`ac_*`):使用者取得短效*租约*(绑定密钥名称 + 到期时间的 token);真正的值仅在 `redeem` 时、且仅在有效期间,通过可插拔解析器(已解锁的 `SecretManager`、环境变量、vault)取得。密钥值永不进入 executor/MCP 记录 —— executor/MCP/Builder 接口仅管理租约生命周期;`redeem` 是刻意设计的仅限 Python API 逃生门。时钟与解析器皆可注入。 + ## 本次更新 (2026-06-19) — Maker-Checker 审批闸门 高风险步骤的职责分离。完整参考:[`docs/source/Zh/doc/new_features/v32_features_doc.rst`](../docs/source/Zh/doc/new_features/v32_features_doc.rst)。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 64b9a5dc..10c7f0d5 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) — Maker-Checker 審批閘門](#本次更新-2026-06-19--maker-checker-審批閘門) - [本次更新 (2026-06-19) — Plugin SDK](#本次更新-2026-06-19--plugin-sdk) - [本次更新 (2026-06-19) — MCP 結構化輸出](#本次更新-2026-06-19--mcp-結構化輸出) @@ -84,6 +85,12 @@ --- +## 本次更新 (2026-06-19) — 即時憑證租約 + +密鑰的零常駐權限。完整參考:[`docs/source/Zh/doc/new_features/v33_features_doc.rst`](../docs/source/Zh/doc/new_features/v33_features_doc.rst)。 + +- **`CredentialBroker`**(`AC_lease_secret` / `AC_lease_valid` / `AC_revoke_lease` / `AC_lease_active`、`ac_*`):使用者取得短效*租約*(綁定密鑰名稱 + 到期時間的 token);真正的值僅在 `redeem` 時、且僅在有效期間,透過可插拔解析器(已解鎖的 `SecretManager`、環境變數、vault)取得。密鑰值永不進入 executor/MCP 紀錄 —— executor/MCP/Builder 介面僅管理租約生命週期;`redeem` 是刻意設計的僅限 Python API 逃生門。時鐘與解析器皆可注入。 + ## 本次更新 (2026-06-19) — Maker-Checker 審批閘門 高風險步驟的職責分離。完整參考:[`docs/source/Zh/doc/new_features/v32_features_doc.rst`](../docs/source/Zh/doc/new_features/v32_features_doc.rst)。 diff --git a/docs/source/Eng/doc/new_features/v33_features_doc.rst b/docs/source/Eng/doc/new_features/v33_features_doc.rst new file mode 100644 index 00000000..5533cc64 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v33_features_doc.rst @@ -0,0 +1,53 @@ +Just-In-Time Credential Leases +============================== + +Long-lived secrets handed to automation are a standing liability. ``CredentialBroker`` +applies **zero standing privilege**: a consumer takes a short-lived *lease* — a +token bound to a secret name with an expiry — and the real value is fetched only +at :meth:`redeem` time, only while the lease is valid, through a pluggable +*resolver* (an unlocked ``SecretManager``'s ``get``, an environment lookup, a +vault client). Expired or revoked leases yield nothing. + +Secret values never enter executor/MCP records: the executor and MCP surfaces +manage the lease *lifecycle* only. :meth:`redeem`, which returns the real value, +is a deliberate **Python-API-only** escape hatch for code that must handle the +secret. The module is pure standard library and imports no ``PySide6``; the +clock and resolver are injectable, so expiry is deterministically testable. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import CredentialBroker + + broker = CredentialBroker(resolver=secret_manager.get) # resolver(name)->value + token = broker.lease("db_password", ttl=120) # token, not the value + + if broker.is_valid(token): + password = broker.redeem(token) # fetched just in time, Python-only + connect(password) + + broker.revoke(token) # or let it expire after ttl seconds + +``active()`` lists non-expired leases as ``{token, name, ttl_remaining}`` with no +values. A module-level :data:`default_broker` backs the executor/MCP commands; +configure its resolver once with ``set_secret_resolver(fn)``. + +Executor commands +----------------- + +================================ =================================================== +Command Effect +================================ =================================================== +``AC_lease_secret`` Issue a lease for ``name`` (``ttl`` s); ``{token, ttl}``. +``AC_lease_valid`` Report ``{valid}`` for a lease token. +``AC_revoke_lease`` Revoke a lease token; ``{revoked}``. +``AC_lease_active`` List active leases (no secret values). +================================ =================================================== + +There is intentionally **no** redeem command on the executor, MCP, or Script +Builder surfaces — exposing the value there would leak it into run records. +Redeeming is Python-only. The same lifecycle operations are exposed as MCP tools +(``ac_lease_secret`` / ``ac_lease_valid`` / ``ac_revoke_lease`` / +``ac_lease_active``) and as Script Builder commands under **Tools**. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index 8cb1d0f5..7ccbf839 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -55,6 +55,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v30_features_doc doc/new_features/v31_features_doc doc/new_features/v32_features_doc + doc/new_features/v33_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/v33_features_doc.rst b/docs/source/Zh/doc/new_features/v33_features_doc.rst new file mode 100644 index 00000000..cac43a56 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v33_features_doc.rst @@ -0,0 +1,50 @@ +即時(Just-In-Time)憑證租約 +============================ + +交給自動化的長效密鑰是一種長期負債。``CredentialBroker`` 實踐**零常駐權限(zero +standing privilege)**:使用者取得短效的*租約(lease)*—— 一個綁定密鑰名稱並帶有 +到期時間的 token —— 真正的值僅在 :meth:`redeem` 時、且僅在租約有效期間,透過可插拔 +的*解析器(resolver)*取得(已解鎖的 ``SecretManager`` 的 ``get``、環境變數查詢、 +vault 用戶端)。已過期或已撤銷的租約取不到任何值。 + +密鑰值永遠不會進入 executor/MCP 紀錄:executor 與 MCP 介面僅管理租約*生命週期*。 +回傳真正值的 :meth:`redeem` 是刻意設計的**僅限 Python API**逃生門,供必須處理密鑰 +的程式使用。本模組為純標準函式庫,不匯入 ``PySide6``;時鐘與解析器皆可注入,因此到 +期行為可被確定性地測試。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import CredentialBroker + + broker = CredentialBroker(resolver=secret_manager.get) # resolver(name)->value + token = broker.lease("db_password", ttl=120) # 取得 token,而非值 + + if broker.is_valid(token): + password = broker.redeem(token) # 即時取得,僅限 Python + connect(password) + + broker.revoke(token) # 或讓它在 ttl 秒後自然過期 + +``active()`` 以 ``{token, name, ttl_remaining}`` 列出未過期的租約,不含任何值。模組 +層級的 :data:`default_broker` 支撐 executor/MCP 指令;以 ``set_secret_resolver(fn)`` +設定一次其解析器即可。 + +執行器指令 +---------- + +================================ =================================================== +指令 效果 +================================ =================================================== +``AC_lease_secret`` 為 ``name`` 發出租約(``ttl`` 秒);``{token, ttl}``。 +``AC_lease_valid`` 回報租約 token 的 ``{valid}``。 +``AC_revoke_lease`` 撤銷租約 token;``{revoked}``。 +``AC_lease_active`` 列出有效租約(不含密鑰值)。 +================================ =================================================== + +executor、MCP 與 Script Builder 介面刻意**沒有** redeem 指令 —— 在那些介面暴露值會 +使其洩漏進執行紀錄。Redeem 僅限 Python。相同的生命週期操作亦提供為 MCP 工具 +(``ac_lease_secret`` / ``ac_lease_valid`` / ``ac_revoke_lease`` / +``ac_lease_active``),以及 Script Builder 中 **Tools** 分類下的指令。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index dfcac399..91007e50 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -55,6 +55,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v30_features_doc doc/new_features/v31_features_doc doc/new_features/v32_features_doc + doc/new_features/v33_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 b633ac61..9903f6e1 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -203,8 +203,11 @@ from je_auto_control.utils.plugin_sdk import ( COMMANDS_GROUP, discover_plugins, load_plugins, ) -# Maker-checker approval gate (segregation of duties for high-risk actions) -from je_auto_control.utils.governance import ApprovalGate +# Maker-checker approval gate + just-in-time credential leases (PAM/governance) +from je_auto_control.utils.governance import ( + ApprovalGate, CredentialBroker, CredentialBrokerError, default_broker, + set_secret_resolver, +) # Background popup/interrupt watchdog (unattended automation) from je_auto_control.utils.watchdog import ( PopupWatchdog, WatchdogRule, default_popup_watchdog, @@ -640,7 +643,8 @@ def start_autocontrol_gui(*args, **kwargs): "describe_step", "generate_sop", "write_sop", "easing_names", "tween_drag", "tween_points", "COMMANDS_GROUP", "discover_plugins", "load_plugins", - "ApprovalGate", + "ApprovalGate", "CredentialBroker", "CredentialBrokerError", + "default_broker", "set_secret_resolver", # 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 359fb34e..1af17db2 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -728,6 +728,29 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None: ), description="Report a request's status and approved flag.", )) + specs.append(CommandSpec( + "AC_lease_secret", "Tools", "Lease: Issue", + fields=( + FieldSpec("name", FieldType.STRING), + FieldSpec("ttl", FieldType.FLOAT, optional=True, default=300.0), + ), + description="Issue a short-lived JIT lease for a secret (no value).", + )) + specs.append(CommandSpec( + "AC_lease_valid", "Tools", "Lease: Valid?", + fields=(FieldSpec("token", FieldType.STRING),), + description="Report whether a lease token is still valid.", + )) + specs.append(CommandSpec( + "AC_revoke_lease", "Tools", "Lease: Revoke", + fields=(FieldSpec("token", FieldType.STRING),), + description="Revoke a lease token immediately.", + )) + specs.append(CommandSpec( + "AC_lease_active", "Tools", "Lease: List Active", + fields=(), + description="List active leases (token, name, ttl_remaining).", + )) specs.append(CommandSpec( "AC_generate_sop", "Report", "Generate SOP Document", fields=( diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index bcacf8da..ee53eaba 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -2922,6 +2922,30 @@ def _approval_status(token: str, db: Optional[str] = None) -> Dict[str, Any]: return {"status": gate.status(token), "approved": gate.is_approved(token)} +def _lease_secret(name: str, ttl: float = 300.0) -> Dict[str, Any]: + """Adapter: issue a JIT lease for a secret name (no value returned).""" + from je_auto_control.utils.governance import default_broker + return {"token": default_broker.lease(name, ttl), "ttl": float(ttl)} + + +def _lease_valid(token: str) -> Dict[str, Any]: + """Adapter: report whether a lease token is still valid.""" + from je_auto_control.utils.governance import default_broker + return {"valid": default_broker.is_valid(token)} + + +def _revoke_lease(token: str) -> Dict[str, Any]: + """Adapter: revoke a lease token immediately.""" + from je_auto_control.utils.governance import default_broker + return {"revoked": default_broker.revoke(token)} + + +def _lease_active() -> Dict[str, Any]: + """Adapter: list active (non-expired) leases without any secret values.""" + from je_auto_control.utils.governance import default_broker + return {"leases": default_broker.active()} + + class Executor: """ Executor @@ -3157,6 +3181,10 @@ def __init__(self): "AC_approval_approve": _approval_approve, "AC_approval_reject": _approval_reject, "AC_approval_status": _approval_status, + "AC_lease_secret": _lease_secret, + "AC_lease_valid": _lease_valid, + "AC_revoke_lease": _revoke_lease, + "AC_lease_active": _lease_active, "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/governance/__init__.py b/je_auto_control/utils/governance/__init__.py index a6eb4515..88b56e69 100644 --- a/je_auto_control/utils/governance/__init__.py +++ b/je_auto_control/utils/governance/__init__.py @@ -1,4 +1,10 @@ -"""Governance: maker-checker approval gate for high-risk actions.""" +"""Governance: maker-checker approval gate and just-in-time credential leases.""" +from je_auto_control.utils.governance.credential_broker import ( + CredentialBroker, CredentialBrokerError, default_broker, set_secret_resolver, +) from je_auto_control.utils.governance.governance import ApprovalGate -__all__ = ["ApprovalGate"] +__all__ = [ + "ApprovalGate", "CredentialBroker", "CredentialBrokerError", + "default_broker", "set_secret_resolver", +] diff --git a/je_auto_control/utils/governance/credential_broker.py b/je_auto_control/utils/governance/credential_broker.py new file mode 100644 index 00000000..ef4dab72 --- /dev/null +++ b/je_auto_control/utils/governance/credential_broker.py @@ -0,0 +1,104 @@ +"""Just-in-time credential leases — zero standing privilege for secrets. + +Instead of handing automation a long-lived secret, a consumer takes a +*lease*: a short-lived token bound to a secret name with an expiry. The real +value is fetched only at :meth:`CredentialBroker.redeem` time, only while the +lease is valid, through a pluggable *resolver* (e.g. an unlocked +:class:`~je_auto_control.utils.secrets.secret_store.SecretManager`'s ``get``, +or an environment lookup). Expired or revoked leases yield nothing. + +Secret values never enter executor/MCP records: the executor surface manages +the lease *lifecycle* only (``AC_lease_secret`` / ``AC_lease_valid`` / +``AC_revoke_lease`` / ``AC_lease_active``). :meth:`redeem`, which returns the +real value, is a deliberate Python-API-only escape hatch for code that must +handle the secret. + +Pure standard library; imports no ``PySide6``. Clock and resolver are +injectable, so expiry is deterministically testable without real time or a +real vault. +""" +import secrets +import time +from typing import Callable, Dict, List, Optional + + +class CredentialBrokerError(RuntimeError): + """Raised when a lease is unknown/expired or no resolver is configured.""" + + +class CredentialBroker: + """Issues short-lived leases that resolve to secrets just in time.""" + + def __init__(self, + resolver: Optional[Callable[[str], Optional[str]]] = None, + clock: Callable[[], float] = time.monotonic) -> None: + """``resolver(name)`` returns the secret value; ``clock`` returns now.""" + self._resolver = resolver + self._clock = clock + self._leases: Dict[str, Dict[str, object]] = {} + + def set_resolver(self, resolver: Callable[[str], Optional[str]]) -> None: + """Configure the function that maps a secret name to its value.""" + self._resolver = resolver + + def lease(self, name: str, ttl: float = 300.0) -> str: + """Issue a lease for secret ``name`` valid for ``ttl`` seconds.""" + token = secrets.token_hex(8) + self._leases[token] = {"name": name, + "expires_at": self._clock() + float(ttl)} + return token + + def _valid_lease(self, token: str) -> Optional[Dict[str, object]]: + lease = self._leases.get(token) + if lease is None: + return None + if self._clock() >= float(lease["expires_at"]): + self._leases.pop(token, None) # opportunistic expiry cleanup + return None + return lease + + def is_valid(self, token: str) -> bool: + """Return ``True`` while ``token``'s lease exists and has not expired.""" + return self._valid_lease(token) is not None + + def redeem(self, token: str) -> str: + """Return the secret value for a valid lease (Python-only by design). + + Raises :class:`CredentialBrokerError` if the lease is unknown/expired + or no resolver is configured. + """ + lease = self._valid_lease(token) + if lease is None: + raise CredentialBrokerError("lease is unknown or expired") + if self._resolver is None: + raise CredentialBrokerError("no secret resolver configured") + value = self._resolver(str(lease["name"])) + if value is None: + raise CredentialBrokerError( + f"resolver returned no value for {lease['name']!r}") + return value + + def revoke(self, token: str) -> bool: + """Revoke ``token`` immediately; return whether it existed.""" + return self._leases.pop(token, None) is not None + + def active(self) -> List[Dict[str, object]]: + """List non-expired leases as ``{token, name, ttl_remaining}`` (no values).""" + now = self._clock() + result: List[Dict[str, object]] = [] + for token, lease in list(self._leases.items()): + remaining = float(lease["expires_at"]) - now + if remaining > 0: + result.append({"token": token, "name": lease["name"], + "ttl_remaining": remaining}) + else: + self._leases.pop(token, None) + return result + + +default_broker = CredentialBroker() + + +def set_secret_resolver(resolver: Callable[[str], Optional[str]]) -> None: + """Configure the resolver used by the module-level :data:`default_broker`.""" + default_broker.set_resolver(resolver) diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 09f97d6f..cbc544d3 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -2619,6 +2619,46 @@ def governance_tools() -> List[MCPTool]: ] +def credential_lease_tools() -> List[MCPTool]: + _T = {"token": {"type": "string"}} + return [ + MCPTool( + name="ac_lease_secret", + description=("Issue a just-in-time lease for secret 'name', valid " + "for 'ttl' seconds (default 300). Returns {token, ttl} " + "only — never the value. Redeeming the value is a " + "Python-API-only operation, by design."), + input_schema=schema({"name": {"type": "string"}, + "ttl": {"type": "number"}}, ["name"]), + handler=h.lease_secret, + annotations=SIDE_EFFECT_ONLY, + ), + MCPTool( + name="ac_lease_valid", + description=("Report whether a lease token is still valid (exists " + "and not expired). Returns {valid}."), + input_schema=schema(dict(_T), ["token"]), + handler=h.lease_valid, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_revoke_lease", + description="Revoke a lease token immediately. Returns {revoked}.", + input_schema=schema(dict(_T), ["token"]), + handler=h.revoke_lease, + annotations=SIDE_EFFECT_ONLY, + ), + MCPTool( + name="ac_lease_active", + description=("List active (non-expired) leases as {token, name, " + "ttl_remaining} — no secret values are returned."), + input_schema=schema({}), + handler=h.lease_active, + annotations=READ_ONLY, + ), + ] + + def unattended_tools() -> List[MCPTool]: return [ MCPTool( @@ -3677,6 +3717,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, 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 785dd30c..b0a7e8ac 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -1268,6 +1268,26 @@ def approval_status(token: str, db: Optional[str] = None): return {"status": gate.status(token), "approved": gate.is_approved(token)} +def lease_secret(name: str, ttl: float = 300.0): + from je_auto_control.utils.governance import default_broker + return {"token": default_broker.lease(name, ttl), "ttl": float(ttl)} + + +def lease_valid(token: str): + from je_auto_control.utils.governance import default_broker + return {"valid": default_broker.is_valid(token)} + + +def revoke_lease(token: str): + from je_auto_control.utils.governance import default_broker + return {"revoked": default_broker.revoke(token)} + + +def lease_active(): + from je_auto_control.utils.governance import default_broker + return {"leases": default_broker.active()} + + 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_credential_lease_batch.py b/test/unit_test/headless/test_credential_lease_batch.py new file mode 100644 index 00000000..fab2f406 --- /dev/null +++ b/test/unit_test/headless/test_credential_lease_batch.py @@ -0,0 +1,111 @@ +"""Headless tests for just-in-time credential leases. Clock and resolver are +injected, so expiry is deterministic without real time or a real vault. Pure +stdlib, no Qt imports.""" +import time + +import pytest + +import je_auto_control as ac +from je_auto_control.utils.governance import ( + CredentialBroker, CredentialBrokerError) + + +class _Clock: + """A manually advanced monotonic clock for deterministic expiry tests.""" + + def __init__(self): + self.now = 1000.0 + + def __call__(self): + return self.now + + +def _broker(clock=time.monotonic): + return CredentialBroker(resolver={"db_pw": "s3cr3t"}.get, clock=clock) + + +def test_redeem_while_valid_returns_value(): + broker = _broker() + token = broker.lease("db_pw", ttl=60.0) + assert broker.is_valid(token) is True + assert broker.redeem(token) == "s3cr3t" + + +def test_redeem_after_expiry_raises(): + clock = _Clock() + broker = _broker(clock=clock) + token = broker.lease("db_pw", ttl=30.0) + clock.now += 31.0 # lease has expired + assert broker.is_valid(token) is False + with pytest.raises(CredentialBrokerError): + broker.redeem(token) + + +def test_revoke_invalidates_lease(): + broker = _broker() + token = broker.lease("db_pw", ttl=60.0) + assert broker.revoke(token) is True + assert broker.is_valid(token) is False + assert broker.revoke(token) is False # already gone + + +def test_redeem_without_resolver_raises(): + broker = CredentialBroker() # no resolver configured + token = broker.lease("db_pw", ttl=60.0) + with pytest.raises(CredentialBrokerError): + broker.redeem(token) + + +def test_unknown_secret_raises(): + broker = _broker() + token = broker.lease("missing", ttl=60.0) + with pytest.raises(CredentialBrokerError): + broker.redeem(token) + + +def test_active_excludes_expired_and_hides_values(): + clock = _Clock() + broker = _broker(clock=clock) + live = broker.lease("db_pw", ttl=100.0) + broker.lease("db_pw", ttl=10.0) # will expire + clock.now += 20.0 + active = broker.active() + assert [a["token"] for a in active] == [live] + assert "value" not in active[0] and "s3cr3t" not in str(active[0]) + + +# --- wiring --------------------------------------------------------------- + +def _valid(token): + rec = ac.execute_action([["AC_lease_valid", {"token": token}]]) + return next(v for v in rec.values() if isinstance(v, dict))["valid"] + + +def test_executor_lifecycle_round_trip(): + rec = ac.execute_action([["AC_lease_secret", {"name": "x", "ttl": 60}]]) + token = next(v for v in rec.values() if isinstance(v, dict))["token"] + assert _valid(token) is True # leased: valid (default_broker) + ac.execute_action([["AC_revoke_lease", {"token": token}]]) + assert _valid(token) is False # revoked: no longer valid + + +def test_wiring(): + known = ac.executor.known_commands() + assert {"AC_lease_secret", "AC_lease_valid", "AC_revoke_lease", + "AC_lease_active"} <= 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_lease_secret", "ac_lease_valid", "ac_revoke_lease", + "ac_lease_active"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + cmds = {s.command for s in _build_specs()} + assert {"AC_lease_secret", "AC_lease_valid", "AC_revoke_lease", + "AC_lease_active"} <= cmds + + +def test_facade_exports(): + for attr in ("CredentialBroker", "CredentialBrokerError", + "default_broker", "set_secret_resolver"): + assert hasattr(ac, attr) + assert attr in ac.__all__