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) — 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)
Expand Down Expand Up @@ -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).
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) — 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-结构化输出)
Expand Down Expand Up @@ -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)。
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) — 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-結構化輸出)
Expand Down Expand Up @@ -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)。
Expand Down
53 changes: 53 additions & 0 deletions docs/source/Eng/doc/new_features/v33_features_doc.rst
Original file line number Diff line number Diff line change
@@ -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**.
1 change: 1 addition & 0 deletions docs/source/Eng/eng_index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions docs/source/Zh/doc/new_features/v33_features_doc.rst
Original file line number Diff line number Diff line change
@@ -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** 分類下的指令。
1 change: 1 addition & 0 deletions docs/source/Zh/zh_index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions je_auto_control/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
23 changes: 23 additions & 0 deletions je_auto_control/gui/script_builder/command_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=(
Expand Down
28 changes: 28 additions & 0 deletions je_auto_control/utils/executor/action_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 8 additions & 2 deletions je_auto_control/utils/governance/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
104 changes: 104 additions & 0 deletions je_auto_control/utils/governance/credential_broker.py
Original file line number Diff line number Diff line change
@@ -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()):

Check warning on line 89 in je_auto_control/utils/governance/credential_broker.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this unnecessary `list()` call on an already iterable object.

See more on https://sonarcloud.io/project/issues?id=Integration-Automation_AutoControlGUI&issues=AZ7gVHgWoEU4j9d2SLxA&open=AZ7gVHgWoEU4j9d2SLxA&pullRequest=241
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)
Loading
Loading