diff --git a/README.md b/README.md index fb340293..caa73f07 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ## Table of Contents +- [What's new (2026-06-21) — SLSA Build Provenance](#whats-new-2026-06-21--slsa-build-provenance) - [What's new (2026-06-21) — Feature Flags](#whats-new-2026-06-21--feature-flags) - [What's new (2026-06-21) — Text Diff, Patch & Three-Way Merge](#whats-new-2026-06-21--text-diff-patch--three-way-merge) - [What's new (2026-06-21) — Calendar Recurrence Rules (RRULE)](#whats-new-2026-06-21--calendar-recurrence-rules-rrule) @@ -121,6 +122,12 @@ --- +## What's new (2026-06-21) — SLSA Build Provenance + +Attest what was built. Full reference: [`docs/source/Eng/doc/new_features/v69_features_doc.rst`](docs/source/Eng/doc/new_features/v69_features_doc.rst). + +- **`build_provenance` / `subject_for` / `verify_provenance` / `write_provenance`** (`AC_build_provenance`, `AC_verify_provenance`): the framework signs action files and inventories deps (SBOM) but couldn't attest *what was produced by which build*. This adds an in-toto v1 Statement with a SLSA v1 provenance predicate over file `sha256` digests, and a verifier that re-hashes the artifacts (tamper → mismatch). Complements `action_signing` + `sbom`; pure-stdlib `hashlib`+`json`, fully offline. + ## What's new (2026-06-21) — Feature Flags Toggle behavior with targeting & rollout. Full reference: [`docs/source/Eng/doc/new_features/v68_features_doc.rst`](docs/source/Eng/doc/new_features/v68_features_doc.rst). diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 6e7edef5..6f7484a3 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -12,6 +12,7 @@ ## 目录 +- [本次更新 (2026-06-21) — SLSA 构建来源证明](#本次更新-2026-06-21--slsa-构建来源证明) - [本次更新 (2026-06-21) — 功能旗标](#本次更新-2026-06-21--功能旗标) - [本次更新 (2026-06-21) — 文本 Diff、应用与三方合并](#本次更新-2026-06-21--文本-diff应用与三方合并) - [本次更新 (2026-06-21) — 日历周期规则(RRULE)](#本次更新-2026-06-21--日历周期规则rrule) @@ -120,6 +121,12 @@ --- +## 本次更新 (2026-06-21) — SLSA 构建来源证明 + +证明构建产生了什么。完整参考:[`docs/source/Zh/doc/new_features/v69_features_doc.rst`](../docs/source/Zh/doc/new_features/v69_features_doc.rst)。 + +- **`build_provenance` / `subject_for` / `verify_provenance` / `write_provenance`**(`AC_build_provenance`、`AC_verify_provenance`):框架能签署动作文件并盘点依赖项(SBOM),却无法证明*哪个构建产生了什么*。本功能补上 in-toto v1 Statement,携带覆盖文件 `sha256` 摘要的 SLSA v1 provenance predicate,并附上会重新哈希产物的验证器(篡改 → 不符)。与 `action_signing` + `sbom` 互补;纯标准库 `hashlib`+`json`,完全离线。 + ## 本次更新 (2026-06-21) — 功能旗标 以目标规则与推出切换行为。完整参考:[`docs/source/Zh/doc/new_features/v68_features_doc.rst`](../docs/source/Zh/doc/new_features/v68_features_doc.rst)。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index d266a708..48d48299 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -12,6 +12,7 @@ ## 目錄 +- [本次更新 (2026-06-21) — SLSA 建置來源證明](#本次更新-2026-06-21--slsa-建置來源證明) - [本次更新 (2026-06-21) — 功能旗標](#本次更新-2026-06-21--功能旗標) - [本次更新 (2026-06-21) — 文字 Diff、套用與三方合併](#本次更新-2026-06-21--文字-diff套用與三方合併) - [本次更新 (2026-06-21) — 行事曆週期規則(RRULE)](#本次更新-2026-06-21--行事曆週期規則rrule) @@ -120,6 +121,12 @@ --- +## 本次更新 (2026-06-21) — SLSA 建置來源證明 + +證明建置產生了什麼。完整參考:[`docs/source/Zh/doc/new_features/v69_features_doc.rst`](../docs/source/Zh/doc/new_features/v69_features_doc.rst)。 + +- **`build_provenance` / `subject_for` / `verify_provenance` / `write_provenance`**(`AC_build_provenance`、`AC_verify_provenance`):框架能簽署動作檔並盤點相依套件(SBOM),卻無法證明*哪個建置產生了什麼*。本功能補上 in-toto v1 Statement,攜帶覆蓋檔案 `sha256` 摘要的 SLSA v1 provenance predicate,並附上會重新雜湊產物的驗證器(竄改 → 不符)。與 `action_signing` + `sbom` 互補;純標準函式庫 `hashlib`+`json`,完全離線。 + ## 本次更新 (2026-06-21) — 功能旗標 以目標規則與推出切換行為。完整參考:[`docs/source/Zh/doc/new_features/v68_features_doc.rst`](../docs/source/Zh/doc/new_features/v68_features_doc.rst)。 diff --git a/docs/source/Eng/doc/new_features/v69_features_doc.rst b/docs/source/Eng/doc/new_features/v69_features_doc.rst new file mode 100644 index 00000000..2aa35b50 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v69_features_doc.rst @@ -0,0 +1,48 @@ +SLSA Build Provenance +===================== + +The framework can sign action files (HMAC) and inventory dependencies (SBOM), +but it could not attest *what was produced by which build* — the SLSA +provenance attestation that binds artifact digests to build metadata. This adds +an in-toto v1 Statement carrying a SLSA v1 provenance predicate over file +``sha256`` digests, plus a verifier that re-hashes the artifacts. + +Pure standard library (``hashlib`` + ``json``); fully offline; imports no +``PySide6``. DSSE signing of the statement is left as an optional later layer. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import ( + subject_for, build_provenance, write_provenance, verify_provenance) + + subjects = [subject_for("dist/app.whl"), subject_for("sbom.cdx.json")] + statement = build_provenance( + subjects, builder_id="github-actions", + metadata={"invocation_id": "run-42", "started_on": "2026-06-21T00:00:00Z"}) + write_provenance(statement, "app.intoto.jsonl") + + # later, on the consumer side + mismatches = verify_provenance(statement, {"app.whl": "dist/app.whl"}) + if not mismatches: + print("artifact digests verified") + +``subject_for`` hashes a file into an in-toto subject (``subject_for_bytes`` +does the same for in-memory data); ``build_provenance`` wraps the subjects in +the in-toto v1 / SLSA v1 envelope (``buildDefinition`` + ``runDetails``); +``verify_provenance`` re-hashes each named file and returns any digest +mismatch. It complements ``action_signing`` (which signs action JSON) and +``sbom`` (which inventories dependencies) — attest the SBOM and signed +artifacts together. + +Executor commands +----------------- + +``AC_build_provenance`` takes ``paths`` (a list, or JSON string) plus optional +``builder_id`` / ``build_type`` and returns ``{statement}``. +``AC_verify_provenance`` takes a ``statement`` and ``files`` (name->path) and +returns ``{ok, mismatches}``. Both are exposed as MCP tools +(``ac_build_provenance`` / ``ac_verify_provenance``) and as Script Builder +commands under **Security**. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index 612602c6..1a5d8d49 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -91,6 +91,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v66_features_doc doc/new_features/v67_features_doc doc/new_features/v68_features_doc + doc/new_features/v69_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/v69_features_doc.rst b/docs/source/Zh/doc/new_features/v69_features_doc.rst new file mode 100644 index 00000000..9123344f --- /dev/null +++ b/docs/source/Zh/doc/new_features/v69_features_doc.rst @@ -0,0 +1,42 @@ +SLSA 建置來源證明(Provenance) +============================== + +框架能簽署動作檔(HMAC)並盤點相依套件(SBOM),但無法證明*哪個建置產生了什麼* —— 也就是把產物 +摘要綁定到建置中繼資料的 SLSA 來源證明。本功能補上一個 in-toto v1 Statement,攜帶覆蓋檔案 +``sha256`` 摘要的 SLSA v1 provenance predicate,並附上會重新雜湊產物的驗證器。 + +純標準函式庫(``hashlib`` + ``json``);完全離線;不匯入 ``PySide6``。Statement 的 DSSE 簽署留作 +選用的後續層。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import ( + subject_for, build_provenance, write_provenance, verify_provenance) + + subjects = [subject_for("dist/app.whl"), subject_for("sbom.cdx.json")] + statement = build_provenance( + subjects, builder_id="github-actions", + metadata={"invocation_id": "run-42", "started_on": "2026-06-21T00:00:00Z"}) + write_provenance(statement, "app.intoto.jsonl") + + # 之後,在消費端 + mismatches = verify_provenance(statement, {"app.whl": "dist/app.whl"}) + if not mismatches: + print("產物摘要已驗證") + +``subject_for`` 把檔案雜湊成 in-toto subject(``subject_for_bytes`` 對記憶體資料做同樣的事); +``build_provenance`` 把 subjects 包進 in-toto v1 / SLSA v1 信封(``buildDefinition`` + +``runDetails``);``verify_provenance`` 重新雜湊每個具名檔案並回傳任何摘要不符。它與 +``action_signing``(簽署動作 JSON)及 ``sbom``(盤點相依套件)互補 —— 可一併證明 SBOM 與已簽署的 +產物。 + +執行器命令 +---------- + +``AC_build_provenance`` 接受 ``paths``(清單或 JSON 字串)及選用的 ``builder_id`` / +``build_type``,回傳 ``{statement}``。``AC_verify_provenance`` 接受 ``statement`` 與 ``files`` +(name->path),回傳 ``{ok, mismatches}``。兩者皆以 MCP 工具(``ac_build_provenance`` / +``ac_verify_provenance``)以及 Script Builder 中 **Security** 分類下的命令提供。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index 201bc048..2ca8de11 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -91,6 +91,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v66_features_doc doc/new_features/v67_features_doc doc/new_features/v68_features_doc + doc/new_features/v69_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 38772c88..d4f6daa4 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -351,6 +351,11 @@ Flag, FlagStore, assign_variant, evaluate_flag, is_enabled, percentage_bucket, ) +# SLSA build provenance (in-toto v1 statements over file digests) +from je_auto_control.utils.provenance import ( + build_provenance, subject_for, subject_for_bytes, verify_provenance, + write_provenance, +) # Background popup/interrupt watchdog (unattended automation) from je_auto_control.utils.watchdog import ( PopupWatchdog, WatchdogRule, default_popup_watchdog, @@ -841,6 +846,8 @@ def start_autocontrol_gui(*args, **kwargs): "unified_diff", "Flag", "FlagStore", "assign_variant", "evaluate_flag", "is_enabled", "percentage_bucket", + "build_provenance", "subject_for", "subject_for_bytes", + "verify_provenance", "write_provenance", # 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 260eb51b..afe512e2 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -1324,6 +1324,26 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None: ), description="Evaluate SBOM licenses against allow/deny SPDX lists.", )) + specs.append(CommandSpec( + "AC_build_provenance", "Security", "Provenance: Build (SLSA)", + fields=( + FieldSpec("paths", FieldType.STRING, + placeholder='["dist/app.whl", "sbom.cdx.json"]'), + FieldSpec("builder_id", FieldType.STRING, optional=True, + placeholder="je_auto_control"), + ), + description="Build a SLSA in-toto provenance statement over files.", + )) + specs.append(CommandSpec( + "AC_verify_provenance", "Security", "Provenance: Verify", + fields=( + FieldSpec("statement", FieldType.STRING, + placeholder='{"subject": [...], "predicate": {...}}'), + FieldSpec("files", FieldType.STRING, + placeholder='{"app.whl": "dist/app.whl"}'), + ), + description="Re-hash files against a provenance statement; {ok, mismatches}.", + )) specs.append(CommandSpec( "AC_jwt_encode", "Security", "JWT: Sign Token", fields=( diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 246b09c9..7e7d1c56 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -2928,6 +2928,31 @@ def _rate_limit(name: str, rate: float = 1.0, capacity: float = 1.0, "wait": round(bucket.time_until_available(float(n)), 4)} +def _build_provenance(paths: Any, builder_id: str = "je_auto_control", + build_type: str = "https://je-auto-control/buildtype/v1" + ) -> Dict[str, Any]: + """Adapter: build a SLSA provenance statement over a list of file paths.""" + import json + from je_auto_control.utils.provenance import build_provenance, subject_for + if isinstance(paths, str): + paths = json.loads(paths) + subjects = [subject_for(path) for path in paths] + return {"statement": build_provenance( + subjects, builder_id=builder_id, build_type=build_type)} + + +def _verify_provenance(statement: Any, files: Any) -> Dict[str, Any]: + """Adapter: re-hash files (name->path) against a provenance statement.""" + import json + from je_auto_control.utils.provenance import verify_provenance + if isinstance(statement, str): + statement = json.loads(statement) + if isinstance(files, str): + files = json.loads(files) + mismatches = verify_provenance(statement, files) + return {"ok": not mismatches, "mismatches": mismatches} + + def _evaluate_flag(flags: Any, key: str, context: Any = None) -> Dict[str, Any]: """Adapter: evaluate a feature flag (flags/context dict or JSON string).""" import json @@ -3880,6 +3905,8 @@ def __init__(self): "AC_rrule_next": _rrule_next, "AC_evaluate_flag": _evaluate_flag, "AC_flag_enabled": _flag_enabled, + "AC_build_provenance": _build_provenance, + "AC_verify_provenance": _verify_provenance, "AC_unified_diff": _unified_diff, "AC_apply_unified": _apply_unified, "AC_three_way_merge": _three_way_merge, diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 4ba6ab9e..93d00cf5 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -3343,6 +3343,32 @@ def rate_limit_tools() -> List[MCPTool]: ] +def provenance_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_build_provenance", + description=("Build a SLSA in-toto v1 provenance statement over a " + "list of file 'paths' (sha256 subjects). Returns " + "{statement}."), + input_schema=schema( + {"paths": {"type": "array"}, "builder_id": {"type": "string"}}, + ["paths"]), + handler=h.build_provenance, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_verify_provenance", + description=("Re-hash 'files' (name->path) against a provenance " + "'statement'. Returns {ok, mismatches}."), + input_schema=schema( + {"statement": {"type": "object"}, "files": {"type": "object"}}, + ["statement", "files"]), + handler=h.verify_provenance, + annotations=READ_ONLY, + ), + ] + + def feature_flag_tools() -> List[MCPTool]: return [ MCPTool( @@ -4719,7 +4745,7 @@ def media_assert_tools() -> List[MCPTool]: jsonpath_tools, json_schema_tools, vuln_scan_tools, vex_tools, license_policy_tools, jwt_tools, rate_limit_tools, json_patch_tools, search_index_tools, stats_tools, recurrence_tools, text_diff_tools, - feature_flag_tools, + feature_flag_tools, provenance_tools, saga_tools, decision_table_tools, locator_repair_tools, pii_text_tools, sarif_tools, screen_record_tools, diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 411d2df2..2c5509e5 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -1664,6 +1664,18 @@ def rrule_next(rule, dtstart, now=None): return {"next": moment.isoformat() if moment else None} +def build_provenance(paths, builder_id="je_auto_control"): + from je_auto_control.utils.provenance import build_provenance, subject_for + subjects = [subject_for(path) for path in paths] + return {"statement": build_provenance(subjects, builder_id=builder_id)} + + +def verify_provenance(statement, files): + from je_auto_control.utils.provenance import verify_provenance as _verify + mismatches = _verify(statement, files) + return {"ok": not mismatches, "mismatches": mismatches} + + def evaluate_flag(flags, key, context=None): from je_auto_control.utils.feature_flags import ( FlagStore, evaluate_flag as _ev) diff --git a/je_auto_control/utils/provenance/__init__.py b/je_auto_control/utils/provenance/__init__.py new file mode 100644 index 00000000..0695bee0 --- /dev/null +++ b/je_auto_control/utils/provenance/__init__.py @@ -0,0 +1,10 @@ +"""SLSA build provenance (in-toto v1 statements) over file digests.""" +from je_auto_control.utils.provenance.provenance import ( + build_provenance, subject_for, subject_for_bytes, verify_provenance, + write_provenance, +) + +__all__ = [ + "build_provenance", "subject_for", "subject_for_bytes", + "verify_provenance", "write_provenance", +] diff --git a/je_auto_control/utils/provenance/provenance.py b/je_auto_control/utils/provenance/provenance.py new file mode 100644 index 00000000..1f3c8f0e --- /dev/null +++ b/je_auto_control/utils/provenance/provenance.py @@ -0,0 +1,94 @@ +"""Build and verify SLSA build provenance (in-toto v1 statements). + +The framework can sign action files (HMAC) and inventory dependencies (SBOM), +but it could not attest *what was produced by which build* — the SLSA +provenance attestation that binds artifact digests to build metadata. This adds +an in-toto v1 Statement carrying a SLSA v1 provenance predicate over file +sha256 digests, plus a verifier that re-hashes the artifacts. + +Pure standard library (``hashlib`` + ``json`` + ``os``); fully offline; imports +no ``PySide6``. DSSE signing of the statement is intentionally left as an +optional later layer. +""" +import hashlib +import json +import os +from pathlib import Path +from typing import Any, Dict, List, Mapping, Optional, Sequence + +_STATEMENT_TYPE = "https://in-toto.io/Statement/v1" +_PREDICATE_TYPE = "https://slsa.dev/provenance/v1" +_CHUNK = 65536 + + +def _sha256_file(path: str) -> str: + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(_CHUNK), b""): + digest.update(chunk) + return digest.hexdigest() + + +def subject_for(path: str, *, name: Optional[str] = None) -> Dict[str, Any]: + """Return an in-toto subject (name + sha256 digest) for a file.""" + return {"name": name or os.path.basename(path), + "digest": {"sha256": _sha256_file(path)}} + + +def subject_for_bytes(name: str, data: bytes) -> Dict[str, Any]: + """Return an in-toto subject for in-memory ``data``.""" + return {"name": name, "digest": {"sha256": hashlib.sha256(data).hexdigest()}} + + +def build_provenance(subjects: Sequence[Mapping[str, Any]], *, + build_type: str = "https://je-auto-control/buildtype/v1", + builder_id: str = "je_auto_control", + external_parameters: Optional[Mapping[str, Any]] = None, + metadata: Optional[Mapping[str, Any]] = None + ) -> Dict[str, Any]: + """Build an in-toto v1 statement with a SLSA v1 provenance predicate.""" + meta = metadata or {} + return { + "_type": _STATEMENT_TYPE, + "subject": [dict(subject) for subject in subjects], + "predicateType": _PREDICATE_TYPE, + "predicate": { + "buildDefinition": { + "buildType": build_type, + "externalParameters": dict(external_parameters or {}), + "internalParameters": {}, + "resolvedDependencies": [], + }, + "runDetails": { + "builder": {"id": builder_id}, + "metadata": { + "invocationId": meta.get("invocation_id", ""), + "startedOn": meta.get("started_on", ""), + "finishedOn": meta.get("finished_on", ""), + }, + "byproducts": [], + }, + }, + } + + +def write_provenance(statement: Mapping[str, Any], path: str) -> str: + """Write a provenance statement to ``path``; return the resolved path.""" + out = Path(path) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(statement, indent=2), encoding="utf-8") + return str(out.resolve()) + + +def verify_provenance(statement: Mapping[str, Any], + files: Mapping[str, str]) -> List[Dict[str, Any]]: + """Re-hash ``files`` (name->path) and return digest mismatches.""" + expected = {subject["name"]: subject.get("digest", {}).get("sha256") + for subject in statement.get("subject", [])} + mismatches: List[Dict[str, Any]] = [] + for name, path in files.items(): + actual = _sha256_file(path) + if expected.get(name) != actual: + mismatches.append({"name": name, "expected": expected.get(name), + "actual": actual}) + return mismatches diff --git a/test/unit_test/headless/test_provenance_batch.py b/test/unit_test/headless/test_provenance_batch.py new file mode 100644 index 00000000..7e393cf0 --- /dev/null +++ b/test/unit_test/headless/test_provenance_batch.py @@ -0,0 +1,86 @@ +"""Headless tests for SLSA provenance. Pure stdlib, no Qt imports.""" +import hashlib +import json + +import je_auto_control as ac +from je_auto_control.utils.provenance import ( + build_provenance, subject_for, subject_for_bytes, verify_provenance, + write_provenance) + + +def _write(tmp_path, name, data): + path = tmp_path / name + path.write_bytes(data) + return str(path) + + +def test_subject_for_bytes_digest(): + subject = subject_for_bytes("inline", b"hello") + assert subject["name"] == "inline" + assert subject["digest"]["sha256"] == hashlib.sha256(b"hello").hexdigest() + + +def test_subject_for_file(tmp_path): + path = _write(tmp_path, "a.txt", b"hello") + subject = subject_for(path) + assert subject["name"] == "a.txt" + assert subject["digest"]["sha256"] == hashlib.sha256(b"hello").hexdigest() + + +def test_build_provenance_structure(): + stmt = build_provenance([subject_for_bytes("a", b"x")], builder_id="ci", + metadata={"invocation_id": "run-1"}) + assert stmt["_type"] == "https://in-toto.io/Statement/v1" + assert stmt["predicateType"] == "https://slsa.dev/provenance/v1" + assert stmt["predicate"]["runDetails"]["builder"]["id"] == "ci" + assert stmt["predicate"]["runDetails"]["metadata"]["invocationId"] == "run-1" + + +def test_verify_clean_and_tamper(tmp_path): + path = _write(tmp_path, "a.txt", b"hello") + stmt = build_provenance([subject_for(path)]) + assert verify_provenance(stmt, {"a.txt": path}) == [] + _write(tmp_path, "a.txt", b"TAMPERED") + mismatches = verify_provenance(stmt, {"a.txt": path}) + assert len(mismatches) == 1 and mismatches[0]["name"] == "a.txt" + + +def test_write_provenance_round_trip(tmp_path): + stmt = build_provenance([subject_for_bytes("a", b"x")]) + out = write_provenance(stmt, str(tmp_path / "prov.json")) + with open(out, encoding="utf-8") as handle: + assert json.load(handle)["predicateType"] == stmt["predicateType"] + + +# --- wiring --------------------------------------------------------------- + +def test_executor_round_trip(tmp_path): + path = _write(tmp_path, "art.bin", b"payload") + rec = ac.execute_action([[ + "AC_build_provenance", {"paths": json.dumps([path])}, + ]]) + stmt = next(v for v in rec.values() if isinstance(v, dict))["statement"] + rec2 = ac.execute_action([[ + "AC_verify_provenance", + {"statement": json.dumps(stmt), + "files": json.dumps({"art.bin": path})}, + ]]) + payload = next(v for v in rec2.values() if isinstance(v, dict)) + assert payload["ok"] is True + + +def test_wiring(): + assert {"AC_build_provenance", "AC_verify_provenance"} <= ac.executor.known_commands() + 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_build_provenance", "ac_verify_provenance"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + cmds = {s.command for s in _build_specs()} + assert {"AC_build_provenance", "AC_verify_provenance"} <= cmds + + +def test_facade_exports(): + for attr in ("build_provenance", "verify_provenance", "subject_for", + "subject_for_bytes", "write_provenance"): + assert hasattr(ac, attr) + assert attr in ac.__all__