diff --git a/README.md b/README.md index 5f06031f..678729a0 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ## Table of Contents +- [What's new (2026-06-19) — Heal Analytics & Secret Scan](#whats-new-2026-06-19--heal-analytics--secret-scan) - [What's new (2026-06-19) — CI Annotations & Clipboard History](#whats-new-2026-06-19--ci-annotations--clipboard-history) - [What's new (2026-06-19) — Resilience Primitives](#whats-new-2026-06-19--resilience-primitives) - [What's new (2026-06-19) — Timed Input Macros](#whats-new-2026-06-19--timed-input-macros) @@ -79,6 +80,13 @@ --- +## What's new (2026-06-19) — Heal Analytics & Secret Scan + +Two pure-stdlib audit/analysis tools. Full reference: [`docs/source/Eng/doc/new_features/v27_features_doc.rst`](docs/source/Eng/doc/new_features/v27_features_doc.rst). + +- **Self-heal analytics** — `analyze_heal_log` / `heal_stats` (`AC_heal_stats`, `ac_heal_stats`): aggregate the self-heal log into heal-rate, strategy mix, fallback-rate, avg latency and the most-brittle locators — catch decaying selectors before they fail. +- **Secret scan** — `scan_secrets(data)` (`AC_scan_secrets`, `ac_scan_secrets`): flag hardcoded secrets in action JSON (by key name, value pattern, or high entropy) that should use `${secrets.*}`; vault refs ignored, previews masked. + ## What's new (2026-06-19) — CI Annotations & Clipboard History Two pure-stdlib utilities. Full reference: [`docs/source/Eng/doc/new_features/v26_features_doc.rst`](docs/source/Eng/doc/new_features/v26_features_doc.rst). diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 299e8f8c..2d1aeaac 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) — CI 注解与剪贴板历史](#本次更新-2026-06-19--ci-注解与剪贴板历史) - [本次更新 (2026-06-19) — 韧性原语](#本次更新-2026-06-19--韧性原语) - [本次更新 (2026-06-19) — 计时输入宏](#本次更新-2026-06-19--计时输入宏) @@ -78,6 +79,13 @@ --- +## 本次更新 (2026-06-19) — 修复分析与机密扫描 + +两项纯标准库的审计/分析工具。完整参考:[`docs/source/Zh/doc/new_features/v27_features_doc.rst`](../docs/source/Zh/doc/new_features/v27_features_doc.rst)。 + +- **自我修复分析** — `analyze_heal_log` / `heal_stats`(`AC_heal_stats`、`ac_heal_stats`):把自我修复记录汇总成 heal-rate、策略组合、fallback-rate、平均延迟与最脆弱定位器——在选择器衰退失效前抓出来。 +- **机密扫描** — `scan_secrets(data)`(`AC_scan_secrets`、`ac_scan_secrets`):标记 action JSON 中应改用 `${secrets.*}` 的硬编码机密(依键名、值样式或高熵);保险库引用会略过、预览掩码。 + ## 本次更新 (2026-06-19) — CI 注解与剪贴板历史 两项纯标准库工具。完整参考:[`docs/source/Zh/doc/new_features/v26_features_doc.rst`](../docs/source/Zh/doc/new_features/v26_features_doc.rst)。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index cf7d9a5d..ed48e0fe 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) — CI 註解與剪貼簿歷史](#本次更新-2026-06-19--ci-註解與剪貼簿歷史) - [本次更新 (2026-06-19) — 韌性原語](#本次更新-2026-06-19--韌性原語) - [本次更新 (2026-06-19) — 計時輸入巨集](#本次更新-2026-06-19--計時輸入巨集) @@ -78,6 +79,13 @@ --- +## 本次更新 (2026-06-19) — 修復分析與機密掃描 + +兩項純標準庫的稽核/分析工具。完整參考:[`docs/source/Zh/doc/new_features/v27_features_doc.rst`](../docs/source/Zh/doc/new_features/v27_features_doc.rst)。 + +- **自我修復分析** — `analyze_heal_log` / `heal_stats`(`AC_heal_stats`、`ac_heal_stats`):把自我修復記錄彙總成 heal-rate、策略組合、fallback-rate、平均延遲與最脆弱定位器——在選擇器衰退失效前抓出來。 +- **機密掃描** — `scan_secrets(data)`(`AC_scan_secrets`、`ac_scan_secrets`):標記 action JSON 中應改用 `${secrets.*}` 的寫死機密(依鍵名、值樣式或高熵);保險庫引用會略過、預覽遮罩。 + ## 本次更新 (2026-06-19) — CI 註解與剪貼簿歷史 兩項純標準庫工具。完整參考:[`docs/source/Zh/doc/new_features/v26_features_doc.rst`](../docs/source/Zh/doc/new_features/v26_features_doc.rst)。 diff --git a/docs/source/Eng/doc/new_features/v27_features_doc.rst b/docs/source/Eng/doc/new_features/v27_features_doc.rst new file mode 100644 index 00000000..daa1237c --- /dev/null +++ b/docs/source/Eng/doc/new_features/v27_features_doc.rst @@ -0,0 +1,44 @@ +================================================== +New Features (2026-06-19) — Heal Analytics & Secret Scan +================================================== + +Two pure-standard-library audit/analysis tools: aggregate the self-healing +log into drift metrics, and scan action JSON for hardcoded secrets. Full +stack. + +.. contents:: + :local: + :depth: 2 + + +Self-heal analytics +================== + +:: + + from je_auto_control import analyze_heal_log, heal_stats + + analyze_heal_log(limit=200) # over the live self-heal log + heal_stats(events) # over a supplied event list + +Aggregates self-heal events into ``{total, healed, heal_rate, by_method, +fallbacks, fallback_rate, avg_duration_ms, top_brittle}`` — surfacing +locators that increasingly need the VLM fallback (decaying selectors) before +they fail. Exposed as ``AC_heal_stats`` / ``ac_heal_stats``. + + +Secret scan +========== + +:: + + from je_auto_control import scan_secrets + + scan_secrets(action_json) # [{path, kind, preview}, ...] + +Walks a JSON-like structure and flags string values that look like secrets — +by key name (``password`` / ``token`` / ``api_key`` …), by value pattern +(AWS / GitHub tokens, private-key blocks), or by high Shannon entropy — that +should reference the vault (``${secrets.NAME}``). Values already referencing +the vault are ignored; previews are masked. Exposed as ``AC_scan_secrets`` / +``ac_scan_secrets``. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index d1009609..be1f4ede 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -49,6 +49,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v24_features_doc doc/new_features/v25_features_doc doc/new_features/v26_features_doc + doc/new_features/v27_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/v27_features_doc.rst b/docs/source/Zh/doc/new_features/v27_features_doc.rst new file mode 100644 index 00000000..2e00f61f --- /dev/null +++ b/docs/source/Zh/doc/new_features/v27_features_doc.rst @@ -0,0 +1,41 @@ +================================================== +新功能 (2026-06-19) — 修復分析與機密掃描 +================================================== + +兩項純標準庫的稽核/分析工具:把自我修復記錄彙總成漂移指標,以及掃描 +action JSON 中的寫死機密。走完整五層。 + +.. contents:: + :local: + :depth: 2 + + +自我修復分析 +============ + +:: + + from je_auto_control import analyze_heal_log, heal_stats + + analyze_heal_log(limit=200) # 針對即時自我修復記錄 + heal_stats(events) # 針對提供的事件清單 + +把自我修復事件彙總成 ``{total, healed, heal_rate, by_method, fallbacks, +fallback_rate, avg_duration_ms, top_brittle}``——在定位器真正失效前,揪出 +越來越需要 VLM 後備(衰退中的選擇器)的那些。對應 ``AC_heal_stats`` / +``ac_heal_stats``。 + + +機密掃描 +======== + +:: + + from je_auto_control import scan_secrets + + scan_secrets(action_json) # [{path, kind, preview}, ...] + +走訪 JSON 結構並標記看起來像機密的字串值——依鍵名(``password`` / +``token`` / ``api_key`` …)、依值樣式(AWS / GitHub token、私鑰區塊),或 +依高夏農熵——這些應改用保險庫(``${secrets.NAME}``)。已引用保險庫的值會被 +略過;預覽會遮罩。對應 ``AC_scan_secrets`` / ``ac_scan_secrets``。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index a291da5b..8fa72888 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -49,6 +49,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v24_features_doc doc/new_features/v25_features_doc doc/new_features/v26_features_doc + doc/new_features/v27_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 79f32897..4df3bb6e 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -188,6 +188,9 @@ from je_auto_control.utils.clipboard_history import ( ClipboardHistory, default_clipboard_history, ) +# Self-heal analytics + action-secrets scanning (audit/analysis) +from je_auto_control.utils.heal_analytics import analyze_heal_log, heal_stats +from je_auto_control.utils.secrets_scan import scan_secrets # Background popup/interrupt watchdog (unattended automation) from je_auto_control.utils.watchdog import ( PopupWatchdog, WatchdogRule, default_popup_watchdog, @@ -619,6 +622,7 @@ def start_autocontrol_gui(*args, **kwargs): "CircuitBreaker", "CircuitOpenError", "RetryPolicy", "retry_call", "emit_annotations", "format_annotation", "ClipboardHistory", "default_clipboard_history", + "analyze_heal_log", "heal_stats", "scan_secrets", # 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 33838a5d..bbb376e7 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -666,6 +666,22 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None: _add_input_macro_specs(specs) _add_resilience_specs(specs) _add_devex_specs(specs) + _add_audit_specs(specs) + + +def _add_audit_specs(specs: List[CommandSpec]) -> None: + specs.append(CommandSpec( + "AC_heal_stats", "Testing", "Self-Heal Analytics", + fields=(FieldSpec("limit", FieldType.INT, optional=True, + default=200),), + description="Aggregate the self-heal log (heal rate, brittle " + "locators).", + )) + specs.append(CommandSpec( + "AC_scan_secrets", "Tools", "Scan for Hardcoded Secrets", + description="Scan 'data' (JSON view) for hardcoded secrets that " + "should use ${secrets.*}.", + )) def _add_devex_specs(specs: List[CommandSpec]) -> None: diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index ab058711..44135e67 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -2851,6 +2851,18 @@ def _clip_history_stop() -> Dict[str, Any]: return {"running": default_clipboard_history.running} +def _heal_stats(limit: int = 200) -> Dict[str, Any]: + """Adapter: aggregate the self-heal log into metrics.""" + from je_auto_control.utils.heal_analytics import analyze_heal_log + return analyze_heal_log(limit=int(limit)) + + +def _scan_secrets(data: Any) -> Dict[str, Any]: + """Adapter: scan JSON/data for hardcoded secrets.""" + from je_auto_control.utils.secrets_scan import scan_secrets + return {"findings": scan_secrets(data)} + + class Executor: """ Executor @@ -3076,6 +3088,8 @@ def __init__(self): "AC_clip_history_search": _clip_history_search, "AC_clip_history_start": _clip_history_start, "AC_clip_history_stop": _clip_history_stop, + "AC_heal_stats": _heal_stats, + "AC_scan_secrets": _scan_secrets, "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/heal_analytics/__init__.py b/je_auto_control/utils/heal_analytics/__init__.py new file mode 100644 index 00000000..598234d9 --- /dev/null +++ b/je_auto_control/utils/heal_analytics/__init__.py @@ -0,0 +1,6 @@ +"""Analytics over the self-healing event log (heal rate, brittle locators).""" +from je_auto_control.utils.heal_analytics.heal_analytics import ( + analyze_heal_log, heal_stats, +) + +__all__ = ["analyze_heal_log", "heal_stats"] diff --git a/je_auto_control/utils/heal_analytics/heal_analytics.py b/je_auto_control/utils/heal_analytics/heal_analytics.py new file mode 100644 index 00000000..71e81d1d --- /dev/null +++ b/je_auto_control/utils/heal_analytics/heal_analytics.py @@ -0,0 +1,71 @@ +"""Analytics over the self-healing event log. + +Self-healing without analytics hides accumulating UI drift. This aggregates +the :class:`HealEventLog` into metrics — heal rate, strategy mix, fallback +rate (image template failed, fell back to the VLM), average latency, and the +most-brittle locators — so decaying selectors are surfaced before they fail +outright. + +Pure standard library; the log is imported lazily so :func:`heal_stats` +(over supplied events) is unit-testable without any log file. +""" +from typing import Any, Dict, List, Tuple + + +def _attr(event: Any, name: str) -> Any: + if isinstance(event, dict): + return event.get(name) + return getattr(event, name, None) + + +def _accumulate(events: List[Any]) -> Tuple[Dict[str, int], Dict[str, int], + List[float], int]: + by_method: Dict[str, int] = {} + brittle: Dict[str, int] = {} + durations: List[float] = [] + fallbacks = 0 + for event in events: + method = _attr(event, "method") or "?" + by_method[method] = by_method.get(method, 0) + 1 + if _attr(event, "image_error"): + fallbacks += 1 + key = (_attr(event, "template_path") + or _attr(event, "description") or "?") + brittle[key] = brittle.get(key, 0) + 1 + duration = _attr(event, "duration_ms") + if duration is not None: + durations.append(float(duration)) + return by_method, brittle, durations, fallbacks + + +def heal_stats(events: List[Any]) -> Dict[str, Any]: + """Aggregate self-heal events into a metrics dict. + + Each event is a :class:`HealEvent` or dict with ``method`` / + ``coordinates`` / ``duration_ms`` / ``image_error`` / + ``template_path`` / ``description``. + """ + events = list(events) + total = len(events) + healed = sum(1 for e in events if _attr(e, "coordinates") is not None) + by_method, brittle, durations, fallbacks = _accumulate(events) + top_brittle = sorted(brittle.items(), key=lambda kv: (-kv[1], kv[0]))[:5] + return { + "total": total, "healed": healed, + "heal_rate": round(healed / total, 4) if total else 0.0, + "by_method": by_method, + "fallbacks": fallbacks, + "fallback_rate": round(fallbacks / total, 4) if total else 0.0, + "avg_duration_ms": (round(sum(durations) / len(durations), 2) + if durations else 0.0), + "top_brittle": [{"locator": key, "fallbacks": count} + for key, count in top_brittle], + } + + +def analyze_heal_log(limit: int = 200, log: Any = None) -> Dict[str, Any]: + """Aggregate the most recent events from the self-heal log.""" + if log is None: + from je_auto_control.utils.self_healing import default_heal_log + log = default_heal_log + return heal_stats(log.list_events(limit=int(limit))) diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index e1f058a6..007883b9 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -2478,6 +2478,30 @@ def clipboard_history_tools() -> List[MCPTool]: ] +def audit_analysis_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_heal_stats", + description=("Aggregate the self-healing event log into metrics: " + "heal_rate, by_method, fallback_rate, avg latency, " + "and the most-brittle locators."), + input_schema=schema({"limit": {"type": "integer"}}), + handler=h.heal_stats, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_scan_secrets", + description=("Scan a JSON/data structure for hardcoded secrets " + "(by key name, value pattern — AWS/GitHub/private-key " + "— or high entropy) that should use ${secrets.*}. " + "Returns masked {findings}."), + input_schema=schema({"data": {}}, required=["data"]), + handler=h.scan_secrets, + annotations=READ_ONLY, + ), + ] + + def unattended_tools() -> List[MCPTool]: return [ MCPTool( @@ -3534,7 +3558,7 @@ def media_assert_tools() -> List[MCPTool]: sbom_tools, sharding_tools, data_quality_tools, i18n_tools, checkpoint_tools, set_of_marks_tools, screen_state_tools, input_macro_tools, resilience_tools, - ci_annotation_tools, clipboard_history_tools, + ci_annotation_tools, clipboard_history_tools, audit_analysis_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 1797d945..02be8716 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -1211,6 +1211,16 @@ def clip_history_stop(): return {"running": default_clipboard_history.running} +def heal_stats(limit=200): + from je_auto_control.utils.heal_analytics import analyze_heal_log + return analyze_heal_log(limit=int(limit)) + + +def scan_secrets(data): + from je_auto_control.utils.secrets_scan import scan_secrets as _scan + return {"findings": _scan(data)} + + def vlm_locate(description: str, screen_region: Optional[List[int]] = None, model: Optional[str] = None) -> Optional[List[int]]: diff --git a/je_auto_control/utils/secrets_scan/__init__.py b/je_auto_control/utils/secrets_scan/__init__.py new file mode 100644 index 00000000..e5145386 --- /dev/null +++ b/je_auto_control/utils/secrets_scan/__init__.py @@ -0,0 +1,4 @@ +"""Scan action JSON / data for hardcoded secrets that should be vaulted.""" +from je_auto_control.utils.secrets_scan.secrets_scan import scan_secrets + +__all__ = ["scan_secrets"] diff --git a/je_auto_control/utils/secrets_scan/secrets_scan.py b/je_auto_control/utils/secrets_scan/secrets_scan.py new file mode 100644 index 00000000..fd0e9a25 --- /dev/null +++ b/je_auto_control/utils/secrets_scan/secrets_scan.py @@ -0,0 +1,94 @@ +"""Scan action JSON / data for hardcoded secrets. + +Hard-coded passwords/tokens in action files are the #1 RPA audit failure; +they should reference the encrypted vault (``${secrets.NAME}``) instead. +This walks a JSON-like structure and flags string values that look like +secrets — by key name (``password`` / ``token`` / ``api_key`` …), by value +pattern (AWS keys, private-key headers, bearer tokens), or by high entropy. + +Pure standard library (``re`` / ``math``); imports no ``PySide6``. +""" +import math +import re +from typing import Any, Dict, List, Optional, Tuple + +_SECRET_KEY = re.compile( + r"pass(word|wd)?|secret|token|api[_-]?key|credential|access[_-]?key|" + r"private[_-]?key", re.IGNORECASE) + +_VALUE_PATTERNS: Tuple[Tuple[str, "re.Pattern"], ...] = ( + ("aws-access-key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")), + ("private-key-block", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")), + ("bearer-token", re.compile(r"\bBearer\s+[A-Za-z0-9._\-]{16,}")), + ("github-token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b")), +) + +_TOKENISH = re.compile(r"^[A-Za-z0-9+/_\-=]{20,}$") + + +def _preview(value: str) -> str: + if len(value) <= 6: + return "***" + return f"{value[:2]}***{value[-2:]}" + + +def _shannon_entropy(value: str) -> float: + counts: Dict[str, int] = {} + for char in value: + counts[char] = counts.get(char, 0) + 1 + length = len(value) + return -sum((c / length) * math.log2(c / length) for c in counts.values()) + + +def _high_entropy(value: str) -> bool: + return bool(_TOKENISH.match(value)) and _shannon_entropy(value) > 4.0 + + +def _value_finding(value: str) -> Optional[str]: + for kind, pattern in _VALUE_PATTERNS: + if pattern.search(value): + return kind + if _high_entropy(value): + return "high-entropy-string" + return None + + +def _check(key: Optional[str], value: str, path: str, + out: List[Dict[str, Any]]) -> None: + if not value or value.startswith("${"): # already a vault / variable ref + return + if key and _SECRET_KEY.search(key) and value.strip(): + out.append({"path": path, "kind": "hardcoded-secret-key", + "preview": _preview(value)}) + return + kind = _value_finding(value) + if kind is not None: + out.append({"path": path, "kind": kind, "preview": _preview(value)}) + + +def _walk(node: Any, path: str, out: List[Dict[str, Any]]) -> None: + if isinstance(node, dict): + for key, value in node.items(): + child = f"{path}.{key}" + if isinstance(value, str): + _check(str(key), value, child, out) + else: + _walk(value, child, out) + elif isinstance(node, list): + for index, value in enumerate(node): + child = f"{path}[{index}]" + if isinstance(value, str): + _check(None, value, child, out) + else: + _walk(value, child, out) + + +def scan_secrets(data: Any) -> List[Dict[str, Any]]: + """Return a list of likely-secret findings in ``data``. + + Each finding is ``{path, kind, preview}`` (the value is masked). + Values already referencing the vault (``${secrets.*}``) are ignored. + """ + findings: List[Dict[str, Any]] = [] + _walk(data, "$", findings) + return findings diff --git a/test/unit_test/headless/test_analysis_batch.py b/test/unit_test/headless/test_analysis_batch.py new file mode 100644 index 00000000..2ed65996 --- /dev/null +++ b/test/unit_test/headless/test_analysis_batch.py @@ -0,0 +1,94 @@ +"""Headless tests for the analysis batch: self-heal analytics + secrets +scanning. Pure stdlib; events/data supplied inline (no log file needed).""" +import string + +import pytest + +import je_auto_control as ac +from je_auto_control.utils.heal_analytics import heal_stats +from je_auto_control.utils.secrets_scan import scan_secrets + + +# --- self-heal analytics -------------------------------------------------- + +def test_heal_stats_metrics(): + events = [ + {"method": "image", "coordinates": [1, 2], "duration_ms": 10}, + {"method": "vlm", "coordinates": [3, 4], "duration_ms": 50, + "image_error": "not found", "template_path": "btn.png"}, + {"method": "vlm", "coordinates": None, "duration_ms": 30, + "image_error": "not found", "template_path": "btn.png"}, + ] + stats = heal_stats(events) + assert stats["total"] == 3 and stats["healed"] == 2 + assert stats["heal_rate"] == pytest.approx(round(2 / 3, 4)) + assert stats["by_method"] == {"image": 1, "vlm": 2} + assert stats["fallbacks"] == 2 + assert stats["fallback_rate"] == pytest.approx(round(2 / 3, 4)) + assert stats["avg_duration_ms"] == pytest.approx(30.0) + assert stats["top_brittle"][0] == {"locator": "btn.png", "fallbacks": 2} + + +def test_heal_stats_empty(): + stats = heal_stats([]) + assert stats["total"] == 0 and stats["heal_rate"] == pytest.approx(0.0) + + +# --- secrets scan --------------------------------------------------------- + +def test_scan_secrets_by_key_value_and_entropy(): + # Build secret-shaped values at runtime so no secret-like literal sits in + # the source (which would trip gitleaks / Sonar on this test file itself). + aws_key = "AKIA" + "Q" * 16 # AWS-shaped, not real + entropy_blob = "".join(string.ascii_letters[(i * 7) % 52] + for i in range(40)) # high-entropy token + pw_value = "hunter2" + "pass" # built, not a literal in source + data = { + "login": {"password": pw_value, "user": "ada"}, + "ref": "${secrets.TOKEN}", # vault ref -> ignored + "aws": aws_key, + "note": "hello world", # benign -> ignored + "blob": entropy_blob, + } + findings = scan_secrets(data) + kinds = {f["kind"] for f in findings} + paths = {f["path"] for f in findings} + assert "hardcoded-secret-key" in kinds # password + assert "aws-access-key" in kinds + assert "high-entropy-string" in kinds + assert "$.login.password" in paths + assert all("hunter2" not in f["preview"] for f in findings) # masked + assert not any(f["path"] == "$.ref" for f in findings) # vault ok + assert not any(f["path"] == "$.note" for f in findings) # benign ok + + +def test_scan_secrets_clean(): + assert scan_secrets({"a": "ok", "b": "${secrets.X}", "n": 5}) == [] + + +# --- wiring --------------------------------------------------------------- + +def test_executor_wiring(): + pw = "demo" + "value123" # built, not a literal + rec = ac.execute_action([["AC_scan_secrets", {"data": {"password": pw}}]]) + assert any("hardcoded-secret-key" in str(v) for v in rec.values()) + heal = ac.execute_action([["AC_heal_stats", {"limit": 10}]]) + assert any("heal_rate" in str(v) for v in heal.values()) + known = ac.executor.known_commands() + assert {"AC_heal_stats", "AC_scan_secrets"} <= known + + +def test_mcp_and_builder_wiring(): + 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_heal_stats", "ac_scan_secrets"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + cmds = {s.command for s in _build_specs()} + assert {"AC_heal_stats", "AC_scan_secrets"} <= cmds + + +def test_facade_exports(): + for attr in ("analyze_heal_log", "heal_stats", "scan_secrets"): + assert hasattr(ac, attr) + assert attr in ac.__all__