From fd20f2df9524654d9fa176d2e1f93d1d8ff1f3d4 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:57:19 +0800 Subject: [PATCH 1/8] Append optional readonly account facts to production runtime (#323) * feat: append optional readonly account facts to production runtime Co-Authored-By: Codex * fix: pin companion workflow to reviewed public revision Co-Authored-By: Codex --------- Co-authored-by: Codex --- .github/workflows/binance-account-facts.yml | 169 ++++++++++++++++++++ .github/workflows/main.yml | 19 +++ 2 files changed, 188 insertions(+) create mode 100644 .github/workflows/binance-account-facts.yml diff --git a/.github/workflows/binance-account-facts.yml b/.github/workflows/binance-account-facts.yml new file mode 100644 index 00000000..5b03e650 --- /dev/null +++ b/.github/workflows/binance-account-facts.yml @@ -0,0 +1,169 @@ +name: Binance Account Facts + +"on": + workflow_call: + inputs: + enabled: + description: "Run the isolated read-only account-facts companion job." + type: boolean + required: false + default: false + source_mode: + description: "The reusable caller uses same_parent; legacy_terminal is reserved for the existing report read." + type: string + required: false + default: same_parent + workflow_dispatch: + inputs: + enabled: + description: "Run the existing terminal-report read; repository gate must also be true." + required: false + type: boolean + default: false + source_run_id: + description: "Exact existing Runtime run ID to validate and read." + required: false + type: string + source_mode: + description: "Keep this workflow_dispatch on the single legacy terminal-report path." + required: true + type: choice + options: [legacy_terminal] + +permissions: + actions: read + contents: read + +concurrency: + # Do not share Runtime's concurrency group; fail-closed source-window checks + # reject intervening Runtime attempts without changing the live workflow. + group: binance-account-facts-readonly + cancel-in-progress: false + +jobs: + read-and-publish: + if: >- + vars.BINANCE_ACCOUNT_FACTS_ENABLED == 'true' && + github.ref == 'refs/heads/runtime-production' && + ((inputs.enabled == true && inputs.source_mode == 'same_parent') || + (github.workflow == 'Binance Account Facts' && github.event_name == 'workflow_dispatch' && + inputs.enabled == true && inputs.source_mode == 'legacy_terminal')) + runs-on: self-hosted + timeout-minutes: 5 + continue-on-error: true + environment: binance-runtime + steps: + - name: Checkout trusted default-branch reader source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ vars.BINANCE_ACCOUNT_FACTS_READER_REVISION }} + persist-credentials: false + + - name: Verify trusted checkout and exact Runtime source + id: preflight + shell: bash + env: + BINANCE_ACCOUNT_FACTS_ENABLED: ${{ vars.BINANCE_ACCOUNT_FACTS_ENABLED || 'false' }} + BINANCE_ACCOUNT_FACTS_READER_REVISION: ${{ vars.BINANCE_ACCOUNT_FACTS_READER_REVISION || '' }} + BINANCE_RUNTIME_WORKFLOW_SHA: ${{ vars.BINANCE_RUNTIME_WORKFLOW_SHA || '' }} + SOURCE_MODE: ${{ inputs.source_mode || 'legacy_terminal' }} + SOURCE_RUN_ID: ${{ inputs.source_run_id || '' }} + GITHUB_TOKEN: ${{ github.token }} + GITHUB_API_URL: ${{ github.api_url }} + run: | + set -euo pipefail + if [[ ! "$BINANCE_ACCOUNT_FACTS_READER_REVISION" =~ ^[0-9a-f]{40}$ ]] || \ + [ "$(git rev-parse HEAD)" != "$BINANCE_ACCOUNT_FACTS_READER_REVISION" ] || \ + [ "$GITHUB_REF" != "refs/heads/runtime-production" ]; then + echo "account_facts_trusted_checkout_invalid" >&2 + exit 1 + fi + uv sync --frozen --no-dev + uv run --no-sync python scripts/read_binance_account_facts.py --preflight + + - name: Create a private same-runner handoff directory + id: private-dir + shell: bash + run: | + set -euo pipefail + private_dir="$(mktemp -d "$RUNNER_TEMP/binance-account-facts.XXXXXX")" + printf 'facts_dir=%s\n' "$private_dir" >> "$GITHUB_OUTPUT" + chmod 700 "$private_dir" + + - name: Download only the exact successful Runtime report artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: binance-execution-report-${{ steps.preflight.outputs.source_run_id }} + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ steps.preflight.outputs.source_run_id }} + path: ${{ steps.private-dir.outputs.facts_dir }}/source + + - name: Read the bound Spot and Flexible Earn account quantities + id: read-account-facts + shell: bash + env: + FACTS_DIR: ${{ steps.private-dir.outputs.facts_dir }} + SOURCE_RUN_ID: ${{ steps.preflight.outputs.source_run_id }} + GITHUB_TOKEN: ${{ github.token }} + GITHUB_API_URL: ${{ github.api_url }} + SOURCE_MODE: ${{ inputs.source_mode || 'legacy_terminal' }} + BINANCE_RUNTIME_WORKFLOW_SHA: ${{ vars.BINANCE_RUNTIME_WORKFLOW_SHA || '' }} + BINANCE_ACCOUNT_FACTS_ENABLED: ${{ vars.BINANCE_ACCOUNT_FACTS_ENABLED || 'false' }} + BINANCE_API_KEY: ${{ secrets.BINANCE_API_KEY }} + BINANCE_API_SECRET: ${{ secrets.BINANCE_API_SECRET }} + BINANCE_RISK_AUTHORITY_JSON: ${{ secrets.BINANCE_RISK_AUTHORITY_JSON }} + BINANCE_RISK_AUTHORITY_FILE: ${{ vars.BINANCE_RISK_AUTHORITY_FILE }} + BINANCE_RISK_AUTHORITY_SHA256: ${{ vars.BINANCE_RISK_AUTHORITY_SHA256 }} + BINANCE_RISK_AUTHORITY_SOURCE_REVISION: ${{ vars.BINANCE_RISK_AUTHORITY_SOURCE_REVISION }} + BINANCE_RECONCILIATION_EXPECTED_DIGESTS_JSON: ${{ vars.BINANCE_RECONCILIATION_EXPECTED_DIGESTS_JSON }} + BINANCE_RUNTIME_RELEASE_SHA: ${{ vars.BINANCE_RUNTIME_RELEASE_SHA }} + BINANCE_ACCOUNT_FACTS_BINDING_JSON: ${{ secrets.BINANCE_ACCOUNT_FACTS_BINDING_JSON }} + BINANCE_DRY_RUN: ${{ vars.BINANCE_DRY_RUN || 'true' }} + RUNTIME_TARGET_ENABLED: ${{ vars.RUNTIME_TARGET_ENABLED || 'false' }} + RUNTIME_TARGET_JSON: ${{ vars.RUNTIME_TARGET_JSON }} + BINANCE_ACCOUNT_FACTS_READER_REVISION: ${{ vars.BINANCE_ACCOUNT_FACTS_READER_REVISION || '' }} + READER_PUBLIC_REVISION: ${{ vars.BINANCE_ACCOUNT_FACTS_READER_REVISION || '' }} + run: | + set -euo pipefail + uv run --no-sync python scripts/read_binance_account_facts.py \ + --report "$FACTS_DIR/source/execution_report.json" \ + --output "$FACTS_DIR/account_facts.json" + + - name: Recheck Runtime source before publishing + shell: bash + env: + SOURCE_RUN_ID: ${{ steps.preflight.outputs.source_run_id }} + GITHUB_TOKEN: ${{ github.token }} + GITHUB_API_URL: ${{ github.api_url }} + SOURCE_MODE: ${{ inputs.source_mode || 'legacy_terminal' }} + BINANCE_RUNTIME_WORKFLOW_SHA: ${{ vars.BINANCE_RUNTIME_WORKFLOW_SHA || '' }} + run: | + set -euo pipefail + uv run --no-sync python scripts/read_binance_account_facts.py --verify-current-source + + - name: Publish with the isolated account-facts token + shell: bash + env: + FACTS_DIR: ${{ steps.private-dir.outputs.facts_dir }} + BINANCE_ACCOUNT_FACTS_ENABLED: ${{ vars.BINANCE_ACCOUNT_FACTS_ENABLED || 'false' }} + BINANCE_ACCOUNT_FACTS_SYNC_TOKEN: ${{ secrets.BINANCE_ACCOUNT_FACTS_SYNC_TOKEN }} + run: | + set -euo pipefail + uv run --no-sync python scripts/publish_binance_account_facts.py \ + "$FACTS_DIR/account_facts.json" + + - name: Remove this run's private report and account-facts files + if: always() + shell: bash + env: + FACTS_DIR: ${{ steps.private-dir.outputs.facts_dir }} + run: | + set -euo pipefail + if [ -n "${FACTS_DIR:-}" ] && [ -d "$FACTS_DIR" ] && [ ! -L "$FACTS_DIR" ]; then + rm -f -- "$FACTS_DIR/account_facts.json" "$FACTS_DIR/source/execution_report.json" + if [ -d "$FACTS_DIR/source" ]; then + rmdir -- "$FACTS_DIR/source" + fi + rmdir -- "$FACTS_DIR" + fi diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index adb6c553..f3d79c2a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -769,3 +769,22 @@ jobs: git add "hourly/${MONTH}/${TS}.json" git commit -m "execution: ${TS}" git push origin "HEAD:${LOGS_BRANCH}" + + account-facts-readonly: + name: Read-only native account quantities + needs: deploy + if: >- + needs.deploy.result == 'success' && + needs.deploy.outputs.runtime_target_enabled == 'true' && + github.ref == 'refs/heads/runtime-production' && + vars.BINANCE_ACCOUNT_FACTS_ENABLED == 'true' && + github.event.inputs.validate_only != 'true' && + github.event.inputs.reconcile_only != 'true' + permissions: + actions: read + contents: read + uses: QuantStrategyLab/BinancePlatform/.github/workflows/binance-account-facts.yml@a7bf700c6b85bd20dbc6fb0b2962adc51006c859 + with: + enabled: true + source_mode: same_parent + secrets: inherit From fd579711a605b17ea65eca82ec6b0a447c6c8d3d Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:14:33 +0800 Subject: [PATCH 2/8] fix: adopt isolated account facts uv setup Co-Authored-By: Codex --- .github/workflows/binance-account-facts.yml | 3 +++ .github/workflows/main.yml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/binance-account-facts.yml b/.github/workflows/binance-account-facts.yml index 5b03e650..f6ad64d5 100644 --- a/.github/workflows/binance-account-facts.yml +++ b/.github/workflows/binance-account-facts.yml @@ -59,6 +59,9 @@ jobs: ref: ${{ vars.BINANCE_ACCOUNT_FACTS_READER_REVISION }} persist-credentials: false + - name: Set up uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9 + - name: Verify trusted checkout and exact Runtime source id: preflight shell: bash diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f3d79c2a..8d13f7f7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -783,7 +783,7 @@ jobs: permissions: actions: read contents: read - uses: QuantStrategyLab/BinancePlatform/.github/workflows/binance-account-facts.yml@a7bf700c6b85bd20dbc6fb0b2962adc51006c859 + uses: QuantStrategyLab/BinancePlatform/.github/workflows/binance-account-facts.yml@e46bda23661a4851df7ad84edd010546ab104b3c with: enabled: true source_mode: same_parent From ddc1ebbce7d82066ff6945ade6c1d79d6ff0dfb5 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 7 Oct 2026 04:44:05 +0800 Subject: [PATCH 3/8] fix: detach optional native account facts from Runtime Keep workflow-wide strategy serialization and the protected standalone manual reader unchanged. Native account facts remain a separate maintenance-only capability; the automatic data path is not complete. --- .github/workflows/main.yml | 18 --------- docs/operator_runbook.md | 35 ++++++++++++++++++ tests/test_runtime_workflow_security.py | 49 +++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 18 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8d13f7f7..55a4e8d6 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -770,21 +770,3 @@ jobs: git commit -m "execution: ${TS}" git push origin "HEAD:${LOGS_BRANCH}" - account-facts-readonly: - name: Read-only native account quantities - needs: deploy - if: >- - needs.deploy.result == 'success' && - needs.deploy.outputs.runtime_target_enabled == 'true' && - github.ref == 'refs/heads/runtime-production' && - vars.BINANCE_ACCOUNT_FACTS_ENABLED == 'true' && - github.event.inputs.validate_only != 'true' && - github.event.inputs.reconcile_only != 'true' - permissions: - actions: read - contents: read - uses: QuantStrategyLab/BinancePlatform/.github/workflows/binance-account-facts.yml@e46bda23661a4851df7ad84edd010546ab104b3c - with: - enabled: true - source_mode: same_parent - secrets: inherit diff --git a/docs/operator_runbook.md b/docs/operator_runbook.md index 79c94912..d7e93374 100644 --- a/docs/operator_runbook.md +++ b/docs/operator_runbook.md @@ -169,6 +169,41 @@ The monthly execution pool is locked to the accepted upstream `version` / `as_of - Repository variable changes are consumed by the next externally scheduled dispatch; they do not reconfigure the VPS scheduler cadence. - Avoid overlapping dispatches from multiple schedulers or from a second manual run while the current runtime job is still in progress. +### Optional native account facts: separate manual capability + +The automatic `Runtime` workflow does not attach the optional native +account-facts collector. Its original workflow-wide concurrency lock, strategy +job, and isolated execution-log publisher remain unchanged. Waiting for optional +account-data approval must not become part of an automatic trading run. + +`Binance Account Facts` remains a separate protected manual workflow. Its existing +manual inputs are `enabled` (default `false`), an exact `source_run_id`, and +`source_mode=legacy_terminal`. The repository gate, `runtime-production` ref, +`binance-runtime` environment approval, trusted reader revision, source checks, +and isolated publication token still apply. Retaining this entry does not grant +permission to invoke it or relax its checks. + +Use it only during an explicitly established quiet maintenance window: Runtime +workflow admission remains disabled, no active or runnable Runtime attempt exists, +and no other operator or controller can re-enable, rerun, or dispatch trading. +Its separate concurrency group and point-in-time source checks do not provide an +atomic read-to-publication fence against a later Runtime. Actions-level inactivity +does not establish host-process or broker-order inactivity. + +The reader also requires the latest qualifying Runtime source to be terminal +success with its exact approved revisions and report. At the 2026-10-06 +maintenance checkpoint, the latest queued run and old waiting parent were +cancelled, so no eligible latest successful source was established. Do not create +one by enabling or dispatching a strategy merely to fill the account-data gap; +do not make the reader accept cancellation or stale sources. + +Automatic native account facts are not yet a completed data path. Preserve +historical observation timestamps and unavailable/stale states rather than +presenting old data as fresh or missing data as zero. Reliable asynchronous +collection and receiver-side run/session fencing require separate review. +Source publication, workflow-pin adoption, and resuming trading are separate +decisions; this detachment itself authorizes none of them. + ### Approved runtime release pin `BINANCE_RUNTIME_RELEASE_SHA` is the single protected repository variable that diff --git a/tests/test_runtime_workflow_security.py b/tests/test_runtime_workflow_security.py index e931aa8f..77e32bf8 100644 --- a/tests/test_runtime_workflow_security.py +++ b/tests/test_runtime_workflow_security.py @@ -19,6 +19,55 @@ def _job_block(workflow: str, job: str, next_job: str | None = None) -> str: return workflow[start:end] +def test_runtime_does_not_automatically_run_optional_account_facts() -> None: + workflow = WORKFLOW.read_text(encoding="utf-8") + jobs = workflow.split("\njobs:\n", 1)[1] + assert re.findall(r"^ ([a-z][a-z0-9-]*):$", jobs, re.MULTILINE) == [ + "deploy", "publish-execution-log", + ] + assert "account-facts-readonly:" not in jobs + assert "/.github/workflows/binance-account-facts.yml@" not in jobs + + +def test_runtime_keeps_workflow_lock_after_optional_reader_detachment() -> None: + workflow = WORKFLOW.read_text(encoding="utf-8") + before_jobs = workflow.split("\njobs:\n", 1)[0] + assert workflow.startswith("name: Runtime\n") + assert ( + "\nconcurrency:\n" + " group: ${{ github.workflow }}-${{ github.ref_name }}\n" + " cancel-in-progress: false\n" + ) in before_jobs + assert "\n concurrency:\n" not in workflow + + +def test_manual_native_facts_entry_remains_opt_in_and_source_bound() -> None: + workflow = WORKFLOW.with_name("binance-account-facts.yml").read_text(encoding="utf-8") + dispatch = workflow.split(" workflow_dispatch:\n", 1)[1].split("\npermissions:\n", 1)[0] + enabled = dispatch.split(" enabled:\n", 1)[1].split(" source_run_id:\n", 1)[0] + assert " type: boolean\n" in enabled + assert " default: false\n" in enabled + assert " source_run_id:\n" in dispatch + assert " options: [legacy_terminal]\n" in dispatch + assert " schedule:" not in workflow + assert "vars.BINANCE_ACCOUNT_FACTS_ENABLED == 'true'" in workflow + assert "github.ref == 'refs/heads/runtime-production'" in workflow + assert "github.workflow == 'Binance Account Facts' && github.event_name == 'workflow_dispatch'" in workflow + assert "inputs.enabled == true && inputs.source_mode == 'legacy_terminal'" in workflow + assert " environment: binance-runtime\n" in workflow + assert " group: binance-account-facts-readonly\n cancel-in-progress: false\n" in workflow + assert " ref: ${{ vars.BINANCE_ACCOUNT_FACTS_READER_REVISION }}\n" in workflow + assert "scripts/read_binance_account_facts.py --preflight" in workflow + assert "scripts/read_binance_account_facts.py --verify-current-source" in workflow + assert "SOURCE_RUN_ID: ${{ steps.preflight.outputs.source_run_id }}" in workflow + assert "BINANCE_ACCOUNT_FACTS_SYNC_TOKEN: ${{ secrets.BINANCE_ACCOUNT_FACTS_SYNC_TOKEN }}" in workflow + assert workflow.index("Verify trusted checkout and exact Runtime source") < ( + workflow.index("Read the bound Spot and Flexible Earn account quantities") + ) < workflow.index("Recheck Runtime source before publishing") < ( + workflow.index("Publish with the isolated account-facts token") + ) + + def test_runtime_workflow_shared_config_shell_contract() -> None: script = ( Path(__file__).resolve().parents[1] From e5079b239458fc856bfa857c1e63fea44eb7af7b Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 7 Oct 2026 05:31:25 +0800 Subject: [PATCH 4/8] fix: keep runtime financial reports out of public diagnostics Use metadata-only public outputs, retain explicit unknown cash flow, and remove full-report artifact/Git publication. Preserve private report persistence, recorder behavior, Runtime locking and protected reader gates. --- .github/workflows/main.yml | 66 ------ application/cycle_service.py | 66 +++--- docs/operator_runbook.md | 38 ++++ tests/test_cycle_service.py | 215 ++++++++++++++++++- tests/test_runtime_workflow_security.py | 69 +++--- tests/test_runtime_workflow_shared_config.sh | 5 +- 6 files changed, 338 insertions(+), 121 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 55a4e8d6..0f33f3e9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -622,16 +622,6 @@ jobs: if: ${{ env.RUNTIME_TARGET_ENABLED == 'false' && github.event.inputs.full_cycle == 'true' }} run: echo "Runtime target is disabled; full-cycle validation used read-only broker calls and suppressed all writes." - - name: 5. Stage execution report for isolated log publisher - continue-on-error: true - if: ${{ github.event.inputs.validate_only != 'true' && github.event.inputs.reconcile_only != 'true' && env.RUNTIME_TARGET_ENABLED == 'true' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: binance-execution-report-${{ github.run_id }} - path: reports/execution_report.json - if-no-files-found: warn - retention-days: 1 - - name: 5a. Stage redacted lifecycle recorder output for monitor continue-on-error: true if: ${{ always() && github.event.inputs.validate_only != 'true' && github.event.inputs.reconcile_only != 'true' && env.RUNTIME_TARGET_ENABLED == 'true' }} @@ -714,59 +704,3 @@ jobs: TG_TOKEN: ${{ secrets.TG_TOKEN }} GLOBAL_TELEGRAM_CHAT_ID: ${{ vars.GLOBAL_TELEGRAM_CHAT_ID }} - publish-execution-log: - name: Publish execution log without broker credentials - needs: deploy - if: ${{ needs.deploy.result == 'success' && needs.deploy.outputs.runtime_target_enabled == 'true' && github.event.inputs.validate_only != 'true' && github.event.inputs.reconcile_only != 'true' }} - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - actions: read - contents: write - steps: - - name: Download staged execution report - continue-on-error: true - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - name: binance-execution-report-${{ github.run_id }} - path: reports - - - name: Push execution log to logs branch - continue-on-error: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - LOGS_BRANCH="logs" - TS="$(date -u +%Y-%m-%dT%H%M)" - MONTH="${TS:0:7}" - REPORT_FILE="${GITHUB_WORKSPACE}/reports/execution_report.json" - - if [ ! -f "$REPORT_FILE" ]; then - echo "No execution report found, skipping log push." - exit 0 - fi - - TMP_DIR=$(mktemp -d) - trap 'rm -rf "$TMP_DIR"' EXIT - git clone --no-checkout "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "${TMP_DIR}/logs-repo" - cd "${TMP_DIR}/logs-repo" - - if git ls-remote --exit-code --heads origin "${LOGS_BRANCH}" >/dev/null 2>&1; then - git fetch origin "${LOGS_BRANCH}:${LOGS_BRANCH}" - git checkout "${LOGS_BRANCH}" - else - git checkout --orphan "${LOGS_BRANCH}" - git rm -rf . >/dev/null 2>&1 || true - find . -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} + - fi - - mkdir -p "hourly/${MONTH}" - cp "$REPORT_FILE" "hourly/${MONTH}/${TS}.json" - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add "hourly/${MONTH}/${TS}.json" - git commit -m "execution: ${TS}" - git push origin "HEAD:${LOGS_BRANCH}" - diff --git a/application/cycle_service.py b/application/cycle_service.py index 05746ca2..c2b55d07 100644 --- a/application/cycle_service.py +++ b/application/cycle_service.py @@ -11,8 +11,10 @@ from quant_platform_kit.common.runtime_reports import persist_runtime_report from quant_platform_kit.strategy_lifecycle.performance_monitor import ( + PerformanceMonitor, + infer_strategy_domain, + logger as _performance_monitor_logger, resolve_lifecycle_stream_id, - try_record_platform_execution, ) from application.execution_receipt_adapter import attach_execution_receipt_from_report from application.portfolio_service import EARN_FORWARD_REASON_CODES @@ -25,6 +27,25 @@ ) +def try_record_platform_execution( + profile_id, execution_result, *, domain="", decision=None, stream_id="", +): + """Preserve the pinned recorder's best-effort behavior without raw errors.""" + try: + if not execution_result: + return + monitor = PerformanceMonitor() + monitor.record_execution( + profile_id, + execution_result, + domain=infer_strategy_domain(profile_id, explicit_domain=domain), + decision=decision, + stream_id=stream_id, + ) + except Exception: # noqa: BLE001 - preserve the recorder's best-effort boundary + _performance_monitor_logger.warning("platform_execution_record_failed") + + def _record_risk_diagnostics(report, allocation): assessment = allocation.get("risk_assessment") if not isinstance(assessment, Mapping): @@ -68,8 +89,12 @@ def _build_platform_execution_result(report, *, state_healthy, state_owner_relea } +def _public_cycle_status(status): + return status if isinstance(status, str) and status in {"ok", "aborted", "error"} else "unknown" + + def _write_lifecycle_export(profile_id, execution_result): - """Best-effort, redacted export of the current recorder payload. + """Best-effort public health metadata, without financial recorder fields. The normal PerformanceStore path remains authoritative. This optional file is a short-lived transport handoff for the monitor and must never @@ -90,18 +115,13 @@ def _write_lifecycle_export(profile_id, execution_result): profile = str(profile_id or "").strip() if not profile: return - stream_id = resolve_lifecycle_stream_id(execution_result=execution_result) + stream_id = resolve_lifecycle_stream_id(execution_result={"platform": "binance"}) safe_result = { - key: execution_result.get(key) - for key in ( - "platform", - "status", - "total_equity_usdt", - "trend_equity_usdt", - "external_cash_flow", - "external_cash_flow_interval", - "degraded_mode_level", - ) + "platform": "binance", + "status": _public_cycle_status(execution_result.get("status")), + # Legacy consumers treat an absent flow as zero. Explicit null + # preserves unknown performance evidence without exposing values. + "external_cash_flow": None, } if safe_result.get("status") != "ok": safe_result["error_code"] = "cycle_failed" @@ -651,10 +671,10 @@ def run_live_cycle( printer=output_printer, ) report = execute_cycle(runtime) - output_printer("\n".join(report.get("log_lines", []))) + # Free-form portfolio lines belong only in the existing private report. + output_printer("Strategy report details retained in the execution report.") report_path = report_writer(report) persisted_local_path = report_path - persisted_cloud_uri = None try: persisted = persist_runtime_report( report, @@ -663,16 +683,12 @@ def run_live_cycle( project_id=os.getenv("CLOUD_PROJECT_ID") or os.getenv("GCP_PROJECT_ID") or os.getenv("GOOGLE_CLOUD_PROJECT"), ) persisted_local_path = persisted.local_path or report_path - if hasattr(persisted, "cloud_uri"): - persisted_cloud_uri = persisted.cloud_uri - else: - persisted_cloud_uri = getattr(persisted, "gcs_uri", None) except Exception: report["status"] = "error" append_report_error(report, "report_persistence_failed", stage="report_persistence") output_printer("failed to persist archived execution report: report_persistence_failed") persisted_local_path = report_writer(report) - report_status = str(report.get("status", "unknown")) + report_status = _public_cycle_status(report.get("status")) status_event = { "ok": "strategy_cycle_completed", "aborted": "strategy_cycle_aborted", @@ -684,12 +700,10 @@ def run_live_cycle( severity="INFO" if report_status in {"ok", "aborted"} else "ERROR", printer=output_printer, status=report_status, - report_path=persisted_local_path, - report_cloud_uri=persisted_cloud_uri, - total_equity_usdt=report.get("total_equity_usdt"), - trend_equity_usdt=report.get("trend_equity_usdt"), - degraded_mode_level=report.get("degraded_mode_level"), - circuit_breaker_triggered=report.get("circuit_breaker_triggered"), + circuit_breaker_triggered=( + report.get("circuit_breaker_triggered") + if isinstance(report.get("circuit_breaker_triggered"), bool) else None + ), error_count=len(report.get("error_summary", {}).get("errors", [])), ) diff --git a/docs/operator_runbook.md b/docs/operator_runbook.md index d7e93374..af23a3cd 100644 --- a/docs/operator_runbook.md +++ b/docs/operator_runbook.md @@ -854,3 +854,41 @@ returns `blocked` and exit 2. All outcomes keep full account reconciliation and execution authority false, including when out-of-scope Spot assets are present. Stop on the first real failure; do not initialize a cursor or activate trading to make this diagnostic pass. A successful preview is not durable accounting. + + +## Public diagnostic output privacy + +The Runtime workflow must not upload the complete `execution_report.json` to +GitHub Actions or copy it into the public `logs` branch. Its former full-report +upload and isolated repository log-publisher job are removed. Existing local and +configured private runtime-report persistence remain unchanged; this change +creates no storage destination, identity or permission. + +Public lifecycle export and console completion records contain bounded health +metadata only. They omit account valuation, cash-flow amounts, account-scope digests, +free-form portfolio lines and nested financial objects. The private report and +PerformanceStore recorder keep their existing content. Missing financial values +in public diagnostics mean unavailable, never zero or a substitute account NAV. +The public lifecycle payload explicitly keeps `external_cash_flow: null`, since +legacy consumers otherwise interpret an absent cash-flow field as zero. + +The standalone protected account-facts workflow remains unchanged. Its exact +source-artifact requirement now makes new Runtime sources unavailable: the +preflight or required download must fail before native-account reading and +publication. Do not bypass that check, substitute an old report, or enable a +strategy to manufacture a source. Restoring this optional reader needs a +separately reviewed private handoff; automatic native account facts remain open. + +This source change is not a deployment or permission to restore trading. +Runtime stays manually disabled. Both the workflow and application revisions +must be reviewed and adopted through the existing protected process before any +future operator recovery; deploying the workflow alone can still upload an old +application's unsanitized lifecycle file. Do not change pins or enable Runtime +as part of this patch. + +Deleting existing Actions artifacts or job logs is separate from this source +fix and requires an exact operator decision because that evidence cannot be +restored normally. Public Git copies and history, clones and prior downloads +are not removed by artifact/log deletion. Keep only non-financial audit metadata +(run/artifact/job identifiers, timestamps, source revisions and field-presence +booleans) in the incident summary; do not copy amounts or account-scope hashes. diff --git a/tests/test_cycle_service.py b/tests/test_cycle_service.py index 0379df84..747a2471 100644 --- a/tests/test_cycle_service.py +++ b/tests/test_cycle_service.py @@ -13,6 +13,7 @@ _write_lifecycle_export, execute_strategy_cycle, run_live_cycle, + try_record_platform_execution, write_execution_report, ) from application.execution_service import execute_trend_buys @@ -309,6 +310,56 @@ def test_full_cycle_publishes_heartbeat_only_once(self): self.assertIn("state_write", events) monitor.beat.assert_called_once_with(status="ok", error="") + def test_public_safe_recorder_preserves_private_call_and_return_contract(self): + payload = {"platform": "binance", "total_equity_usdt": 918273.625} + decision = object() + with patch("application.cycle_service.PerformanceMonitor") as monitor: + result = try_record_platform_execution( + "crypto_live_pool_rotation", payload, domain="crypto", + decision=decision, stream_id="binance", + ) + self.assertIsNone(result) + monitor.assert_called_once_with() + monitor.return_value.record_execution.assert_called_once_with( + "crypto_live_pool_rotation", payload, domain="crypto", + decision=decision, stream_id="binance", + ) + self.assertIs(monitor.return_value.record_execution.call_args.args[1], payload) + with patch("application.cycle_service.PerformanceMonitor") as monitor: + self.assertIsNone(try_record_platform_execution("crypto_live_pool_rotation", None)) + monitor.assert_not_called() + + def test_public_safe_recorder_replaces_constructor_and_write_exception_details(self): + sentinel = "SYNTHETIC_PRIVATE_STORE_EXCEPTION" + for failure_stage in ("constructor", "write"): + with self.subTest(failure_stage=failure_stage): + with ( + patch("application.cycle_service.PerformanceMonitor") as monitor, + patch("application.cycle_service._performance_monitor_logger.warning") as warning, + ): + if failure_stage == "constructor": + monitor.side_effect = RuntimeError(sentinel) + else: + monitor.return_value.record_execution.side_effect = RuntimeError(sentinel) + self.assertIsNone(try_record_platform_execution( + "crypto_live_pool_rotation", {"platform": "binance"}, + )) + warning.assert_called_once_with("platform_execution_record_failed") + self.assertNotIn(sentinel, str(warning.call_args)) + + def test_public_safe_recorder_failure_does_not_change_real_cycle_result(self): + def rebase(*args): + args[2]["status"] = "ok" + + with ( + patch("application.cycle_service.PerformanceMonitor", side_effect=RuntimeError("SYNTHETIC_PRIVATE_STORE_EXCEPTION")), + patch("application.cycle_service._performance_monitor_logger.warning") as warning, + ): + report, events = self._run_funds_cycle(True, rebase_fn=rebase) + self.assertEqual(report["status"], "ok") + self.assertIn("state_write", events) + warning.assert_called_once_with("platform_execution_record_failed") + def test_platform_performance_record_marks_external_cash_flow_incomparable(self): with patch("application.cycle_service.try_record_platform_execution") as record: self._run_funds_cycle(True) @@ -359,12 +410,88 @@ def test_lifecycle_export_is_optional_and_redacts_provider_details(self): self.assertEqual(payload["domain"], "crypto") self.assertEqual(payload["record_kind"], "execution") self.assertEqual(payload["lifecycle_stream_id"], "binance") - self.assertIsNone(payload["execution_result"]["external_cash_flow_interval"]) + self.assertNotIn("external_cash_flow_interval", payload["execution_result"]) self.assertEqual(payload["execution_result"]["error_code"], "cycle_failed") self.assertNotIn("provider-secret-must-not-export", json.dumps(payload)) self.assertNotIn("orders", json.dumps(payload)) self.assertEqual(os.stat(export_path).st_mode & 0o777, 0o600) + def test_public_lifecycle_export_omits_financial_values_and_nested_details(self): + sentinel = "SYNTHETIC_PRIVATE_VALUE_DO_NOT_PUBLISH" + execution_result = { + "platform": "binance", + "status": "ok", + "total_equity_usdt": 918273.625, + "trend_equity_usdt": 817263.125, + "external_cash_flow": sentinel, + "external_cash_flow_interval": { + "end_equity_usdt": sentinel, + "net_external_cash_flow": sentinel, + "account_scope_sha256": sentinel, + "nested": {"unknown": sentinel}, + }, + "degraded_mode_level": {"details": sentinel}, + "error": sentinel, + "unknown": sentinel, + } + original = copy.deepcopy(execution_result) + with tempfile.TemporaryDirectory() as directory: + export_path = os.path.join(directory, "lifecycle-run.json") + with patch.dict(os.environ, {"BINANCE_LIFECYCLE_EXPORT_PATH": export_path}): + _write_lifecycle_export("crypto_live_pool_rotation", execution_result) + with open(export_path, encoding="utf-8") as handle: + payload = json.load(handle) + self.assertEqual(payload["execution_result"], { + "platform": "binance", "status": "ok", "external_cash_flow": None, + }) + self.assertNotIn(sentinel, json.dumps(payload)) + self.assertNotIn("918273.625", json.dumps(payload)) + self.assertNotIn("817263.125", json.dumps(payload)) + self.assertEqual(execution_result, original) + + def test_public_lifecycle_export_does_not_copy_unexpected_status_payloads(self): + sentinel = "SYNTHETIC_PRIVATE_STATUS_DO_NOT_PUBLISH" + for status in (sentinel, {"detail": sentinel}, [sentinel], None): + with self.subTest(status_type=type(status).__name__): + with tempfile.TemporaryDirectory() as directory: + export_path = os.path.join(directory, "lifecycle-run.json") + with patch.dict(os.environ, {"BINANCE_LIFECYCLE_EXPORT_PATH": export_path}): + _write_lifecycle_export( + "crypto_live_pool_rotation", + {"platform": sentinel, "status": status}, + ) + with open(export_path, encoding="utf-8") as handle: + payload = json.load(handle) + self.assertEqual(payload["execution_result"], { + "platform": "binance", "status": "unknown", "error_code": "cycle_failed", + "external_cash_flow": None, + }) + self.assertNotIn(sentinel, json.dumps(payload)) + + def test_public_lifecycle_is_unavailable_to_pinned_return_consumer(self): + from quant_platform_kit.strategy_lifecycle.live_equity import ( + extract_equity_value, + extract_external_cash_flow, + live_run_records_to_return_series_result, + ) + + with tempfile.TemporaryDirectory() as directory: + export_path = os.path.join(directory, "lifecycle-run.json") + with patch.dict(os.environ, {"BINANCE_LIFECYCLE_EXPORT_PATH": export_path}): + _write_lifecycle_export("crypto_live_pool_rotation", { + "platform": "binance", "status": "ok", + "total_equity_usdt": 918273.625, + "external_cash_flow_interval": {"end_equity_usdt": "918273.625"}, + }) + with open(export_path, encoding="utf-8") as handle: + payload = json.load(handle) + self.assertIsNone(extract_equity_value(payload)) + self.assertIsNone(extract_external_cash_flow(payload)) + records = [dict(payload, recorded_at=f"2026-10-0{day}T12:00:00Z") for day in (5, 6)] + result = live_run_records_to_return_series_result(records, domain="crypto") + self.assertTrue(result.series.empty) + self.assertEqual(result.status, "insufficient_observations") + def test_lifecycle_export_without_authorized_path_does_not_write(self): with tempfile.TemporaryDirectory() as directory: export_path = os.path.join(directory, "lifecycle-run.json") @@ -749,7 +876,7 @@ def fake_execute_cycle(runtime): self.assertEqual(observed["built"], 1) self.assertEqual(len(observed["printed"]), 3) - self.assertEqual(observed["printed"][1], "line-1\nline-2") + self.assertEqual(observed["printed"][1], "Strategy report details retained in the execution report.") self.assertEqual(report["status"], "ok") self.assertEqual(payload["log_lines"], ["line-1", "line-2"]) @@ -803,6 +930,90 @@ def test_run_live_cycle_emits_structured_runtime_events(self): self.assertEqual(end_log["event"], "strategy_cycle_completed") self.assertEqual(end_log["status"], "ok") + def test_public_cycle_stdout_omits_financial_data_but_preserves_private_report(self): + sentinel = "SYNTHETIC_PRIVATE_REPORT_DO_NOT_PUBLISH" + report = { + "status": "ok", + "total_equity_usdt": 918273.625, + "trend_equity_usdt": 817263.125, + "external_cash_flow_interval": {"end_equity_usdt": sentinel}, + "log_lines": [sentinel], + "degraded_mode_level": {"detail": sentinel}, + "circuit_breaker_triggered": {"detail": sentinel}, + "error_summary": {"errors": []}, + } + original = copy.deepcopy(report) + printed = [] + archived = [] + with tempfile.TemporaryDirectory() as directory: + with patch( + "application.cycle_service.persist_runtime_report", + side_effect=lambda value, **kwargs: archived.append(copy.deepcopy(value)) + or SimpleNamespace(local_path=kwargs["output_path"], cloud_uri=sentinel), + ): + returned, output_path = run_live_cycle( + runtime_builder=lambda: SimpleNamespace(run_id="public-test", dry_run=False), + execute_cycle=lambda _runtime: report, + output_printer=printed.append, + report_writer=lambda value: write_execution_report( + value, reports_dir=directory, filename=f"{sentinel}.json", + ), + ) + with open(output_path, encoding="utf-8") as handle: + persisted = json.load(handle) + public_output = "\n".join(printed) + for private_value in (sentinel, "918273.625", "817263.125"): + self.assertNotIn(private_value, public_output) + completion = json.loads(printed[-1]) + self.assertEqual(completion["event"], "strategy_cycle_completed") + self.assertEqual(completion["status"], "ok") + for field in ("total_equity_usdt", "trend_equity_usdt", "degraded_mode_level", + "report_path", "report_cloud_uri", "circuit_breaker_triggered"): + self.assertNotIn(field, completion) + self.assertEqual(returned, original) + self.assertEqual(persisted, original) + self.assertEqual(archived, [original]) + + def test_public_stdout_with_real_cycle_keeps_nested_financial_evidence_private(self): + from contextlib import redirect_stderr, redirect_stdout + from io import StringIO + + sentinel = "SYNTHETIC_CYCLE_PRIVATE_DO_NOT_PUBLISH" + private_interval = {"end_equity_usdt": sentinel, "account_scope_sha256": sentinel} + observed = {} + + def rebase(*args): + args[2]["status"] = "ok" + args[2]["external_cash_flow_interval"] = private_interval + args[6].append(sentinel) + + def run_real_cycle(_runtime): + report, events = self._run_funds_cycle(True, rebase_fn=rebase) + observed["events"] = events + return report + + output = StringIO() + with tempfile.TemporaryDirectory() as directory: + with ( + patch("application.cycle_service.try_record_platform_execution") as recorder, + patch("application.cycle_service.persist_runtime_report", + return_value=SimpleNamespace(local_path=None, cloud_uri=None)), + redirect_stdout(output), redirect_stderr(output), + ): + report, output_path = run_live_cycle( + runtime_builder=lambda: SimpleNamespace(dry_run=False), + execute_cycle=run_real_cycle, + report_writer=lambda value: write_execution_report(value, reports_dir=directory), + ) + with open(output_path, encoding="utf-8") as handle: + persisted = json.load(handle) + self.assertNotIn(sentinel, output.getvalue()) + self.assertIn("state_write", observed["events"]) + self.assertEqual(report["status"], "ok") + self.assertEqual(persisted["external_cash_flow_interval"], private_interval) + self.assertIn(sentinel, persisted["log_lines"]) + self.assertEqual(recorder.call_args.args[1]["external_cash_flow_interval"], private_interval) + def test_run_live_cycle_uses_shared_runtime_report_archive(self): observed = {} diff --git a/tests/test_runtime_workflow_security.py b/tests/test_runtime_workflow_security.py index 77e32bf8..d7174300 100644 --- a/tests/test_runtime_workflow_security.py +++ b/tests/test_runtime_workflow_security.py @@ -23,12 +23,35 @@ def test_runtime_does_not_automatically_run_optional_account_facts() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") jobs = workflow.split("\njobs:\n", 1)[1] assert re.findall(r"^ ([a-z][a-z0-9-]*):$", jobs, re.MULTILINE) == [ - "deploy", "publish-execution-log", + "deploy", ] assert "account-facts-readonly:" not in jobs assert "/.github/workflows/binance-account-facts.yml@" not in jobs +def test_runtime_never_uploads_full_execution_report_or_publishes_logs_branch() -> None: + workflow = WORKFLOW.read_text(encoding="utf-8") + assert "binance-execution-report-" not in workflow + assert "publish-execution-log:" not in workflow + assert 'LOGS_BRANCH="logs"' not in workflow + assert "contents: write" not in workflow + assert "EXECUTION_REPORT_GCS_URI: ${{ vars.EXECUTION_REPORT_GCS_URI }}" in workflow + + +def test_manual_native_reader_remains_fail_closed_without_report_artifact() -> None: + workflow = WORKFLOW.with_name("binance-account-facts.yml").read_text(encoding="utf-8") + download = workflow.split( + " - name: Download only the exact successful Runtime report artifact", 1 + )[1].split(" - name: Read the bound Spot and Flexible Earn account quantities", 1)[0] + assert "actions/download-artifact@" in download + assert "binance-execution-report-${{ steps.preflight.outputs.source_run_id }}" in download + assert "continue-on-error" not in download + assert "if:" not in download + assert workflow.index("Verify trusted checkout and exact Runtime source") < ( + workflow.index("Download only the exact successful Runtime report artifact") + ) < workflow.index("Read the bound Spot and Flexible Earn account quantities") + + def test_runtime_keeps_workflow_lock_after_optional_reader_detachment() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") before_jobs = workflow.split("\njobs:\n", 1)[0] @@ -90,7 +113,7 @@ def test_runtime_remote_actions_are_pinned_to_full_commit_shas() -> None: def test_runtime_release_pin_is_required_before_execution_checkout() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") - broker_job = _job_block(workflow, "deploy", "publish-execution-log") + broker_job = _job_block(workflow, "deploy") assert ( "BINANCE_RUNTIME_RELEASE_SHA: ${{ vars.BINANCE_RUNTIME_RELEASE_SHA }}" @@ -113,7 +136,7 @@ def test_runtime_release_pin_is_required_before_execution_checkout() -> None: def test_runtime_workflow_revision_pin_is_required_before_release_selection() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") - broker_job = _job_block(workflow, "deploy", "publish-execution-log") + broker_job = _job_block(workflow, "deploy") assert "Verify approved workflow revision" in broker_job assert ( @@ -134,7 +157,7 @@ def test_runtime_workflow_revision_pin_is_required_before_release_selection() -> def test_runtime_workflow_keeps_trigger_sha_distinct_from_app_checkout() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") - broker_job = _job_block(workflow, "deploy", "publish-execution-log") + broker_job = _job_block(workflow, "deploy") assert "ref: ${{ github.sha }}" in broker_job assert "ref: ${{ steps.runtime-release.outputs.sha }}" in broker_job @@ -152,7 +175,7 @@ def test_runtime_workflow_keeps_trigger_sha_distinct_from_app_checkout() -> None def test_runtime_dependency_bootstrap_does_not_upgrade_pip_or_uv() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") - broker_job = _job_block(workflow, "deploy", "publish-execution-log") + broker_job = _job_block(workflow, "deploy") deps = broker_job.split( " - name: 3. Prepare or update dependency environment", 1 )[1].split(" - name: Download approved accounting migration preview", 1)[0] @@ -207,23 +230,17 @@ def test_candidate_release_sha_guard_rejects_write_modes() -> None: def test_broker_job_cannot_write_repository_contents() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") - broker_job = _job_block(workflow, "deploy", "publish-execution-log") - log_job = _job_block(workflow, "publish-execution-log") + broker_job = _job_block(workflow, "deploy") assert "BINANCE_API_KEY: ${{ secrets.BINANCE_API_KEY }}" in broker_job - assert "contents: write" not in broker_job + assert "contents: write" not in workflow assert "contents: read" in broker_job - assert "BINANCE_API_KEY" not in log_job - assert "BINANCE_API_SECRET" not in log_job - assert "contents: write" in log_job - assert "actions/download-artifact@" in log_job + assert "publish-execution-log:" not in workflow def test_reconciliation_run_does_not_try_to_publish_a_regular_execution_report() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") - log_job = _job_block(workflow, "publish-execution-log") - - assert "github.event.inputs.reconcile_only != 'true'" in log_job + assert "binance-execution-report-" not in workflow def test_reconciliation_artifact_retention_matches_repository_policy() -> None: @@ -244,7 +261,7 @@ def test_reconciliation_failure_still_uploads_the_redacted_candidate() -> None: def test_reconciliation_only_can_collect_evidence_while_normal_runtime_is_disabled() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") - broker_job = _job_block(workflow, "deploy", "publish-execution-log") + broker_job = _job_block(workflow, "deploy") reconciliation_gate = "env.RUNTIME_TARGET_ENABLED == 'true' || github.event.inputs.reconcile_only == 'true'" assert broker_job.count(reconciliation_gate) >= 4 @@ -254,7 +271,7 @@ def test_reconciliation_only_can_collect_evidence_while_normal_runtime_is_disabl def test_strategy_step_passes_and_revalidates_runtime_controls_before_main() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") - broker_job = _job_block(workflow, "deploy", "publish-execution-log") + broker_job = _job_block(workflow, "deploy") strategy = broker_job.split(" - name: 4. Run trading strategy", 1)[1].split( " - name: Remove protected LIVE risk authority source", 1 )[0] @@ -415,7 +432,7 @@ def test_lifecycle_workflow_is_read_only_and_uses_pinned_actions() -> None: def test_reconciliation_defaults_to_zero_persistence_and_no_notification() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") - broker_job = _job_block(workflow, "deploy", "publish-execution-log") + broker_job = _job_block(workflow, "deploy") assert "reconcile_persist_candidate:" in workflow assert 'default: false' in workflow[workflow.index("reconcile_persist_candidate:") : workflow.index("permissions:")] @@ -426,7 +443,7 @@ def test_reconciliation_defaults_to_zero_persistence_and_no_notification() -> No def test_live_authority_secret_is_materialized_and_removed_in_broker_job() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") - broker_job = _job_block(workflow, "deploy", "publish-execution-log") + broker_job = _job_block(workflow, "deploy") materialize = broker_job.split(" - name: Materialize protected LIVE risk authority source", 1)[1].split( " - name:", 1 )[0] @@ -442,7 +459,7 @@ def test_live_authority_secret_is_materialized_and_removed_in_broker_job() -> No assert "if: ${{ always() }}" in cleanup assert ': > "$BINANCE_RISK_AUTHORITY_TEMP_FILE"' in cleanup assert 'rm -- "$BINANCE_RISK_AUTHORITY_TEMP_FILE"' in cleanup - assert "BINANCE_RISK_AUTHORITY_JSON" not in _job_block(workflow, "publish-execution-log") + assert "publish-execution-log:" not in workflow def test_live_authority_materialization_roundtrip_uses_synthetic_secret(tmp_path) -> None: @@ -451,7 +468,7 @@ def test_live_authority_materialization_roundtrip_uses_synthetic_secret(tmp_path import textwrap workflow = WORKFLOW.read_text(encoding="utf-8") - broker_job = _job_block(workflow, "deploy", "publish-execution-log") + broker_job = _job_block(workflow, "deploy") materialize = textwrap.dedent( broker_job.split(" - name: Materialize protected LIVE risk authority source", 1)[1] .split(" - name:", 1)[0] @@ -483,7 +500,7 @@ def test_live_authority_materialization_roundtrip_uses_synthetic_secret(tmp_path def test_live_lifecycle_recorder_export_is_scoped_to_normal_strategy_runs() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") - broker_job = _job_block(workflow, "deploy", "publish-execution-log") + broker_job = _job_block(workflow, "deploy") assert ( "BINANCE_LIFECYCLE_EXPORT_PATH: ${{ runner.temp }}/binance-lifecycle-${{ github.run_id }}-" @@ -499,12 +516,12 @@ def test_live_lifecycle_recorder_export_is_scoped_to_normal_strategy_runs() -> N assert "if-no-files-found: warn" in export_step assert "retention-days: 7" in export_step assert "continue-on-error: true" in export_step - assert "BINANCE_API_KEY" not in _job_block(workflow, "publish-execution-log") + assert "publish-execution-log:" not in workflow def test_disabled_host_observation_uses_actual_control_read_and_existing_source() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") - broker_job = _job_block(workflow, "deploy", "publish-execution-log") + broker_job = _job_block(workflow, "deploy") control = broker_job.split("id: runtime-target-control", 1)[1].split(" - name:", 1)[0] assert 'echo "observed_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"' in control assert 'if [ "${RUNTIME_TARGET_ENABLED,,}" = "false" ]; then' in control @@ -526,7 +543,7 @@ def test_disabled_host_observation_uses_actual_control_read_and_existing_source( def test_disabled_host_does_not_require_cloud_identity_or_runtime_dependencies() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") - broker_job = _job_block(workflow, "deploy", "publish-execution-log") + broker_job = _job_block(workflow, "deploy") identity = broker_job.split(" - name: 0. Validate deployment identity configuration", 1)[1].split(" - name:", 1)[0] assert "if: ${{ env.RUNTIME_TARGET_ENABLED == 'true' || github.event.inputs.reconcile_only == 'true' || github.event.inputs.validate_only == 'true' }}" in identity assert broker_job.index("Publish disabled host observation") < broker_job.index("Authenticate to Google Cloud") @@ -727,7 +744,7 @@ def test_accounting_migration_guard_is_disabled_main_only( def test_accounting_migration_uses_fixed_artifact_and_never_falls_through_to_strategy(): workflow = WORKFLOW.read_text() - broker_job = _job_block(workflow, "deploy", "publish-execution-log") + broker_job = _job_block(workflow, "deploy") download = broker_job.split( " - name: Download approved accounting migration preview", 1 )[1].split(" - name:", 1)[0] diff --git a/tests/test_runtime_workflow_shared_config.sh b/tests/test_runtime_workflow_shared_config.sh index 9663c174..f5413443 100644 --- a/tests/test_runtime_workflow_shared_config.sh +++ b/tests/test_runtime_workflow_shared_config.sh @@ -18,7 +18,10 @@ grep -Fq 'BINANCE_RUNTIME_WORKFLOW_SHA: ${{ vars.BINANCE_RUNTIME_WORKFLOW_SHA }} grep -Fq 'Verify approved workflow revision' "$workflow_file" grep -Fq 'candidate_release_sha:' "$workflow_file" grep -Fq 'Runtime target is disabled; broker strategy was intentionally not invoked.' "$workflow_file" -grep -Fq 'needs.deploy.outputs.runtime_target_enabled == '\''true'\''' "$workflow_file" +if grep -Fq 'binance-execution-report-' "$workflow_file" || grep -Fq 'publish-execution-log:' "$workflow_file"; then + echo "workflow must not publish complete financial reports to public artifacts or Git" >&2 + exit 1 +fi grep -Fq 'id-token: write' "$workflow_file" grep -Fq 'workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }}' "$workflow_file" grep -Fq 'service_account: ${{ env.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }}' "$workflow_file" From b42a32ecc64472acf60326f957b3408155373da1 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 7 Oct 2026 05:39:53 +0800 Subject: [PATCH 5/8] fix: keep static secret-scope probe bound to adjacent strategy step Replace the removed artifact-upload marker with the retained cleanup boundary. Require unique adjacent markers and reject cross-step or duplicated secret references. --- scripts/runtime_isolation_host_probe.py | 12 ++++--- tests/test_runtime_isolation_host_probe.py | 39 ++++++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/scripts/runtime_isolation_host_probe.py b/scripts/runtime_isolation_host_probe.py index 070e3d6f..a6fd9b38 100644 --- a/scripts/runtime_isolation_host_probe.py +++ b/scripts/runtime_isolation_host_probe.py @@ -55,11 +55,14 @@ def inspect_secret_source(workflow_path: Path) -> dict[str, Any]: workflow = workflow_path.read_text(encoding="utf-8") key_ref = "BINANCE_API_KEY: ${{ secrets.BINANCE_API_KEY }}" secret_ref = "BINANCE_API_SECRET: ${{ secrets.BINANCE_API_SECRET }}" - strategy_marker = "- name: 4. Run trading strategy" - next_marker = "- name: 5. Stage execution report" + strategy_marker = " - name: 4. Run trading strategy" + next_marker = " - name: Remove protected LIVE risk authority source" strategy_block = "" - if strategy_marker in workflow and next_marker in workflow: - strategy_block = workflow[workflow.index(strategy_marker) : workflow.index(next_marker)] + if workflow.count(strategy_marker) == 1 and workflow.count(next_marker) == 1: + candidate = workflow[workflow.index(strategy_marker) : workflow.index(next_marker)] + # The end marker must be the very next step, never a broader job slice. + if not any(line.startswith(" - ") for line in candidate.splitlines()[1:]): + strategy_block = candidate references_present = key_ref in workflow and secret_ref in workflow return { "source": "github_actions_environment_secrets" if references_present else "UNVERIFIED", @@ -68,6 +71,7 @@ def inspect_secret_source(workflow_path: Path) -> dict[str, Any]: "broker_secret_scope": ( "strategy_step_only" if key_ref in strategy_block and secret_ref in strategy_block + and workflow.count(key_ref) == 1 and workflow.count(secret_ref) == 1 else "UNVERIFIED" ), "secret_values_read": False, diff --git a/tests/test_runtime_isolation_host_probe.py b/tests/test_runtime_isolation_host_probe.py index ab1a4e6f..5ece7591 100644 --- a/tests/test_runtime_isolation_host_probe.py +++ b/tests/test_runtime_isolation_host_probe.py @@ -37,6 +37,45 @@ def test_current_secret_source_is_github_environment_and_step_scoped(self) -> No self.assertEqual(result["broker_secret_scope"], "strategy_step_only") self.assertFalse(result["secret_values_read"]) + def _inspect_synthetic_workflow(self, text: str): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "workflow.yml" + path.write_text(text, encoding="utf-8") + return self.probe.inspect_secret_source(path) + + def test_strategy_secret_boundary_requires_unique_adjacent_step_markers(self) -> None: + workflow = MAIN_WORKFLOW.read_text(encoding="utf-8") + start = " - name: 4. Run trading strategy" + end = " - name: Remove protected LIVE risk authority source" + variants = { + "missing_start": workflow.replace(start, " - name: Unrecognized strategy"), + "missing_end": workflow.replace(end, " - name: Unrecognized cleanup"), + "duplicate_start": workflow + "\n" + start + "\n", + "duplicate_end": workflow + "\n" + end + "\n", + "intervening_named_step": workflow.replace(end, " - name: Unrecognized step\n" + end), + "intervening_unnamed_step": workflow.replace(end, " - uses: example/action@v1\n" + end), + } + for case, text in variants.items(): + with self.subTest(case=case): + result = self._inspect_synthetic_workflow(text) + self.assertEqual(result["broker_secret_scope"], "UNVERIFIED") + self.assertFalse(result["secret_values_read"]) + + def test_cross_step_secret_references_are_not_claimed_strategy_only(self) -> None: + workflow = MAIN_WORKFLOW.read_text(encoding="utf-8") + key = " BINANCE_API_KEY: ${{ secrets.BINANCE_API_KEY }}" + secret = " BINANCE_API_SECRET: ${{ secrets.BINANCE_API_SECRET }}" + later_step = "\n - name: Other synthetic step\n env:\n" + key + "\n" + secret + "\n" + for case, text in { + "moved": workflow.replace(key + "\n", "").replace(secret + "\n", "") + later_step, + "duplicated": workflow + later_step, + }.items(): + with self.subTest(case=case): + result = self._inspect_synthetic_workflow(text) + self.assertTrue(result["broker_secret_references_present"]) + self.assertEqual(result["broker_secret_scope"], "UNVERIFIED") + self.assertFalse(result["secret_values_read"]) + def test_provider_inference_does_not_assume_gce(self) -> None: self.assertEqual( self.probe.infer_host_provider({"sys_vendor": "OracleCloud.com"}), From e5f3caaf895f46368a1c310e6387a8f2b8733032 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:49:00 +0800 Subject: [PATCH 6/8] Adopt scoped authority App authentication on protected maintenance branch (#351) Co-authored-by: Codex --- .../workflows/authority-app-auth-check.yml | 50 +++++++++++ .../workflows/authority-version-reissue.yml | 24 +++++- docs/authority_reissue.md | 59 +++++++++++-- docs/authority_runner_revision_update.md | 29 ++++++- tests/test_authority_reissue_workflow.py | 84 ++++++++++++++++++- 5 files changed, 228 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/authority-app-auth-check.yml diff --git a/.github/workflows/authority-app-auth-check.yml b/.github/workflows/authority-app-auth-check.yml new file mode 100644 index 00000000..e737590f --- /dev/null +++ b/.github/workflows/authority-app-auth-check.yml @@ -0,0 +1,50 @@ +name: Authority App authentication check + +# Configuration access only: no authority materialization, writes or trading. +on: + workflow_dispatch: + +permissions: {} + +jobs: + authenticate: + strategy: + max-parallel: 1 + matrix: + environment-permission: [read, write] + runs-on: ubuntu-24.04 + timeout-minutes: 5 + environment: binance-runtime + steps: + - name: Mint scoped maintenance installation token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.BINANCE_AUTHORITY_APP_CLIENT_ID }} + private-key: ${{ secrets.BINANCE_AUTHORITY_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-actions: read + permission-environments: ${{ matrix.environment-permission }} + permission-metadata: read + env: + # v3.2.0 parses this undeclared input into actions_variables. + INPUT_PERMISSION-ACTIONS-VARIABLES: read + + - name: Verify installation scope and configuration reads + shell: bash + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + actual=$(gh api installation/repositories --jq '.repositories | map(.full_name) | sort | join(",")') + if [ "$actual" != "$REPOSITORY" ]; then + echo "::error::Installation token repository scope does not match." + exit 1 + fi + gh api --silent "repos/${REPOSITORY}/actions/runs?per_page=1" + gh api --silent "repos/${REPOSITORY}/actions/variables/RUNTIME_TARGET_ENABLED" + gh api --silent "repos/${REPOSITORY}/environments/binance-runtime/variables/BINANCE_AUTHORITY_APP_CLIENT_ID" + gh api --silent "repos/${REPOSITORY}/environments/binance-runtime/secrets/public-key" + echo "Scoped GitHub App authentication verified; no configuration writes." diff --git a/.github/workflows/authority-version-reissue.yml b/.github/workflows/authority-version-reissue.yml index 03a33540..4f80edc0 100644 --- a/.github/workflows/authority-version-reissue.yml +++ b/.github/workflows/authority-version-reissue.yml @@ -3,8 +3,8 @@ run-name: Authority version reissue ยท ${{ inputs.mode }} # One-shot maintenance only. Does not enable trading, does not alter RECONCILE_ONLY # or risk mandate, and does not dispatch Runtime / no-submit full_cycle. -# The scoped environment token supplies GitHub configuration reads in both -# modes. Only apply passes it to the write gateway. This workflow is not +# A scoped GitHub App installation token supplies configuration access in +# both modes. Only apply passes it to the write gateway. This workflow is not # production-ready until that identity and branch/environment protections are # configured by operators. @@ -61,6 +61,22 @@ jobs: ref: ${{ github.sha }} persist-credentials: false + - name: Mint scoped authority maintenance token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.BINANCE_AUTHORITY_APP_CLIENT_ID }} + private-key: ${{ secrets.BINANCE_AUTHORITY_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-actions: read + permission-environments: ${{ inputs.mode == 'apply' && 'write' || 'read' }} + permission-metadata: read + env: + # v3.2.0's INPUT_PERMISSION_* parser maps this to actions_variables; + # its declared inputs omit the variables permission. + INPUT_PERMISSION-ACTIONS-VARIABLES: read + - name: Materialize authority secret to a 0600 temp file id: materialize shell: bash @@ -82,7 +98,7 @@ jobs: id: reissue shell: bash env: - GH_TOKEN: ${{ secrets.BINANCE_AUTHORITY_UPDATE_TOKEN }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} UPDATE_MODE: ${{ inputs.mode }} TARGET_STRATEGY_REVISION: ${{ inputs.target_strategy_revision }} TARGET_RUNNER_REVISION: ${{ inputs.target_runner_revision }} @@ -92,7 +108,7 @@ jobs: AUTHORITY_FILE: ${{ steps.materialize.outputs.authority_file }} MAINTENANCE_WORKFLOW_SHA: ${{ github.sha }} MAINTENANCE_RUN_ID: ${{ github.run_id }} - BINANCE_AUTHORITY_UPDATE_TOKEN: ${{ inputs.mode == 'apply' && secrets.BINANCE_AUTHORITY_UPDATE_TOKEN || '' }} + BINANCE_AUTHORITY_UPDATE_TOKEN: ${{ inputs.mode == 'apply' && steps.app-token.outputs.token || '' }} run: | set -euo pipefail python3 scripts/update_binance_authority_reissue.py \ diff --git a/docs/authority_reissue.md b/docs/authority_reissue.md index df94e28c..3112dc1c 100644 --- a/docs/authority_reissue.md +++ b/docs/authority_reissue.md @@ -23,14 +23,61 @@ operator-supplied target SHA. ## Modes - `plan` (default): verify live preconditions + byte digest + surgical patch; - **zero** secret/variable writes. It uses the scoped environment token only - to read the current GitHub configuration; the script clears its write-token - input before executing the plan. + **zero** secret/variable writes. It uses a scoped GitHub App installation + token to read the current GitHub configuration; the script clears its + write-token input before executing the plan. - `apply`: same checks, then submit secret bytes, the new SHA256 variable, and the new source revision variable (skipping any write that is already at - target). Requires environment secret `BINANCE_AUTHORITY_UPDATE_TOKEN` - (fine-grained **Environments: write**). Ordinary `GITHUB_TOKEN` cannot write - environment secrets; do not invent `permissions: secrets: write`. + target). The same installation token is passed to the write gateway only in + apply mode. + +## GitHub App maintenance identity + +Create a dedicated private organization GitHub App and install it only on +`QuantStrategyLab/BinancePlatform`. Grant the installation **Actions: read**, +**Variables: read**, **Environments: write**, and **Metadata: read**. The +workflow downscopes each minted token to this repository; plan requests +**Environments: read**, while apply requests **Environments: write**. GitHub +App Environments permission applies repository-wide and cannot be restricted +to the named `binance-runtime` environment. Keeping the App private key as a +secret in the protected `binance-runtime` environment limits which workflow +jobs can access that key. No webhook or OAuth configuration is needed. + +Set `BINANCE_AUTHORITY_APP_CLIENT_ID` as an environment variable and +`BINANCE_AUTHORITY_APP_PRIVATE_KEY` as an environment secret in +`binance-runtime`. Each workflow run mints an installation token that expires +within one hour; the action attempts to revoke it in its post-job step. A +revocation failure is reported as a warning; expiry still limits its lifetime. +The private key +remains long-lived until manually revoked or rotated. Keep the existing +`BINANCE_AUTHORITY_UPDATE_TOKEN` PAT until the App identity has been validated +with the maintenance workflow, then retire the PAT. + +The current `binance-runtime` deployment branch policy allows only +`runtime-production`. A change merged to `main` alone is not adopted by that +maintenance entry point. Promote the authentication change through the approved +release path and verify the dispatched revision; do not widen the environment +branch policy to make validation pass. + +After that adoption, dispatch `authority-app-auth-check.yml` on +`runtime-production` to verify installation-token creation, repository scope, +and configuration reads. This separate manual check does not read the authority +JSON, write configuration, or dispatch trading; it requires no authority-update +inputs and can run without stopping a trading runtime. It serially mints the +read and write Environment permission variants used by plan and apply, while +performing only read requests with both tokens. + +Before adopting a maintenance change on `runtime-production`, compare its +current revision with `BINANCE_RUNTIME_WORKFLOW_SHA`. Updating the branch also +changes the runtime workflow revision. Do not advance the approved runtime pin +or include unrelated runtime changes merely to validate authentication; those +changes require their own approved release decision. + +An authentication-only adoption must leave `.github/workflows/main.yml`, +runtime source, and the approved runtime pins unchanged. If the production +branch already differs from the approved workflow pin, that mismatch remains +a separate runtime release issue; token verification does not resolve it or +authorize execution. ## Explicit inputs (no main-tip selection) diff --git a/docs/authority_runner_revision_update.md b/docs/authority_runner_revision_update.md index a8992614..139c6326 100644 --- a/docs/authority_runner_revision_update.md +++ b/docs/authority_runner_revision_update.md @@ -22,9 +22,29 @@ digest, mandate, and `BINANCE_RISK_AUTHORITY_SOURCE_REVISION` stay unchanged. - `plan` (default): verify live preconditions + byte digest + surgical patch; **zero** secret/variable writes. Do not inject the write token. - `apply`: same checks, then submit secret bytes and the new SHA256 variable. - Requires environment secret `BINANCE_AUTHORITY_UPDATE_TOKEN` (fine-grained - **Environments: write** on this repository). Ordinary `GITHUB_TOKEN` cannot - write environment secrets; do not invent `permissions: secrets: write`. + The workflow passes its per-run GitHub App installation token through the + existing `BINANCE_AUTHORITY_UPDATE_TOKEN` process environment only in apply + mode. No saved PAT is required by the script. + +## GitHub App maintenance identity + +Use a dedicated private organization GitHub App installed only on +`QuantStrategyLab/BinancePlatform`, with **Actions: read**, **Variables: read**, +**Environments: write**, and **Metadata: read**. The workflow scopes each +installation token to this repository and requests **Environments: read** for +plan or **Environments: write** for apply. GitHub App Environments permission +is repository-wide; the protected `binance-runtime` environment that stores +the App private key controls which workflow jobs can access it. No webhook or +OAuth configuration is needed. + +Store `BINANCE_AUTHORITY_APP_CLIENT_ID` as an environment variable and +`BINANCE_AUTHORITY_APP_PRIVATE_KEY` as an environment secret in +`binance-runtime`. The action mints a token for each run, valid for at most one +hour, and attempts to revoke it in the post-job step. A revocation failure is +reported as a warning; expiry still limits its lifetime. The private key remains long-lived +until manually revoked or rotated. Retain the existing +`BINANCE_AUTHORITY_UPDATE_TOKEN` PAT until the new App identity is validated, +then retire the PAT. ## Preconditions (fail closed) @@ -57,7 +77,8 @@ external `gh`/console writers; operators must coordinate before `apply`. ## Not production-ready until -- `BINANCE_AUTHORITY_UPDATE_TOKEN` is created out-of-band (never paste into chat). +- GitHub App client ID and private key are configured out-of-band in + `binance-runtime`. - Environment/branch protection and human approval gates are confirmed for `binance-runtime` / `main` as required by operators. - Version triangle is decided: maintenance workflow revision, approved trading diff --git a/tests/test_authority_reissue_workflow.py b/tests/test_authority_reissue_workflow.py index f37fee69..77898e0a 100644 --- a/tests/test_authority_reissue_workflow.py +++ b/tests/test_authority_reissue_workflow.py @@ -1,16 +1,92 @@ from pathlib import Path - WORKFLOW = Path(__file__).resolve().parents[1] / ".github" / "workflows" / "authority-version-reissue.yml" +def _step(workflow: str, name: str) -> str: + marker = f" - name: {name}\n" + start = workflow.index(marker) + end = workflow.find(" - name:", start + len(marker)) + return workflow[start:] if end == -1 else workflow[start:end] + + def test_authority_reissue_uses_an_isolated_hosted_runner() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") assert "runs-on: ubuntu-24.04" in workflow assert "runs-on: self-hosted" not in workflow - assert "GH_TOKEN: ${{ secrets.BINANCE_AUTHORITY_UPDATE_TOKEN }}" in workflow + assert "timeout-minutes: 15" in workflow + assert "persist-credentials: false" in workflow + + +def test_app_token_is_scoped_and_wired_before_authority_materialization() -> None: + workflow = WORKFLOW.read_text(encoding="utf-8") + mint = _step(workflow, "Mint scoped authority maintenance token") + reissue = _step(workflow, "Plan or apply authority version reissue") + + assert workflow.index(mint) < workflow.index( + _step(workflow, "Materialize authority secret to a 0600 temp file") + ) + assert "actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1" in mint + assert "client-id: ${{ vars.BINANCE_AUTHORITY_APP_CLIENT_ID }}" in mint + assert "private-key: ${{ secrets.BINANCE_AUTHORITY_APP_PRIVATE_KEY }}" in mint + assert "owner: ${{ github.repository_owner }}" in mint + assert "repositories: ${{ github.event.repository.name }}" in mint + assert "permission-actions: read" in mint + assert "INPUT_PERMISSION-ACTIONS-VARIABLES: read" in mint + assert "permission-variables: read" not in mint + assert "permission-environments: ${{ inputs.mode == 'apply' && 'write' || 'read' }}" in mint + assert "permission-metadata: read" in mint + assert "permission-contents:" not in mint + assert "permission-administration:" not in mint + assert "permission-secrets:" not in mint + assert "skip-token-revoke:" not in mint + + assert "GH_TOKEN: ${{ steps.app-token.outputs.token }}" in reissue assert ( "BINANCE_AUTHORITY_UPDATE_TOKEN: ${{ inputs.mode == 'apply' " - "&& secrets.BINANCE_AUTHORITY_UPDATE_TOKEN || '' }}" - ) in workflow + "&& steps.app-token.outputs.token || '' }}" + ) in reissue + assert "secrets.BINANCE_AUTHORITY_UPDATE_TOKEN" not in workflow + + +def test_manual_inputs_and_existing_stop_contract_remain() -> None: + workflow = WORKFLOW.read_text(encoding="utf-8") + docs = (WORKFLOW.parents[2] / "docs" / "authority_reissue.md").read_text(encoding="utf-8") + + assert "workflow_dispatch:" in workflow + assert "options: [plan, apply]" in workflow + assert "schedule:" not in workflow + assert "does not enable trading" in workflow.lower() + assert "does not dispatch runtime / no-submit full_cycle" in workflow.lower() + for name in ( + "target_strategy_revision", + "target_runner_revision", + "target_source_revision", + "expected_authority_sha256", + "expected_source_revision", + ): + assert f" {name}:" in workflow + assert f" {name}:\n description:" in workflow + assert "RUNTIME_TARGET_ENABLED` live value is exactly `false`" in docs + assert "first failure or uncertain outcome: stop" in docs + + +def test_authentication_check_covers_both_modes_without_authority_or_writes() -> None: + workflow = WORKFLOW.with_name("authority-app-auth-check.yml").read_text(encoding="utf-8") + + assert "environment-permission: [read, write]" in workflow + assert "max-parallel: 1" in workflow + assert "permission-environments: ${{ matrix.environment-permission }}" in workflow + assert "repositories: ${{ github.event.repository.name }}" in workflow + assert "GH_TOKEN: ${{ steps.app-token.outputs.token }}" in workflow + assert "skip-token-revoke:" not in workflow + assert "BINANCE_RISK_AUTHORITY_JSON" not in workflow + assert "BINANCE_AUTHORITY_UPDATE_TOKEN" not in workflow + assert "actions/checkout" not in workflow + for method in ("POST", "PUT", "PATCH", "DELETE"): + assert f"--method {method}" not in workflow + assert f"-X {method}" not in workflow + assert "scripts/update_binance_authority_reissue.py" not in workflow + assert "main.py" not in workflow + assert "schedule:" not in workflow From 39d69a3e692421b489d164c995733e82cd425276 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:22:07 +0800 Subject: [PATCH 7/8] fix: adopt isolated direct account-facts read entry Co-Authored-By: Codex --- .github/workflows/binance-account-facts.yml | 55 +++++++++++++++++---- 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/.github/workflows/binance-account-facts.yml b/.github/workflows/binance-account-facts.yml index f6ad64d5..e09572da 100644 --- a/.github/workflows/binance-account-facts.yml +++ b/.github/workflows/binance-account-facts.yml @@ -16,7 +16,7 @@ name: Binance Account Facts workflow_dispatch: inputs: enabled: - description: "Run the existing terminal-report read; repository gate must also be true." + description: "Run the manual read-only account-facts read; repository gate must also be true." required: false type: boolean default: false @@ -25,10 +25,10 @@ name: Binance Account Facts required: false type: string source_mode: - description: "Keep this workflow_dispatch on the single legacy terminal-report path." + description: "Read directly from the bound account, or use the legacy Runtime report." required: true type: choice - options: [legacy_terminal] + options: [direct_read, legacy_terminal] permissions: actions: read @@ -44,10 +44,13 @@ jobs: read-and-publish: if: >- vars.BINANCE_ACCOUNT_FACTS_ENABLED == 'true' && - github.ref == 'refs/heads/runtime-production' && - ((inputs.enabled == true && inputs.source_mode == 'same_parent') || - (github.workflow == 'Binance Account Facts' && github.event_name == 'workflow_dispatch' && - inputs.enabled == true && inputs.source_mode == 'legacy_terminal')) + ((github.event_name == 'workflow_dispatch' && github.workflow == 'Binance Account Facts' && + inputs.enabled == true && inputs.source_mode == 'direct_read' && + github.ref == 'refs/heads/runtime-production') || + (github.ref == 'refs/heads/runtime-production' && + ((inputs.enabled == true && inputs.source_mode == 'same_parent') || + (github.workflow == 'Binance Account Facts' && github.event_name == 'workflow_dispatch' && + inputs.enabled == true && inputs.source_mode == 'legacy_terminal')))) runs-on: self-hosted timeout-minutes: 5 continue-on-error: true @@ -62,7 +65,7 @@ jobs: - name: Set up uv uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9 - - name: Verify trusted checkout and exact Runtime source + - name: Verify trusted reader checkout and source mode id: preflight shell: bash env: @@ -71,13 +74,20 @@ jobs: BINANCE_RUNTIME_WORKFLOW_SHA: ${{ vars.BINANCE_RUNTIME_WORKFLOW_SHA || '' }} SOURCE_MODE: ${{ inputs.source_mode || 'legacy_terminal' }} SOURCE_RUN_ID: ${{ inputs.source_run_id || '' }} - GITHUB_TOKEN: ${{ github.token }} + READER_PUBLIC_REVISION: ${{ vars.BINANCE_ACCOUNT_FACTS_READER_REVISION || '' }} + GITHUB_TOKEN: ${{ inputs.source_mode != 'direct_read' && github.token || '' }} GITHUB_API_URL: ${{ github.api_url }} run: | set -euo pipefail + expected_ref=refs/heads/runtime-production + if [ "$SOURCE_MODE" != "direct_read" ] && \ + [ "$SOURCE_MODE" != "same_parent" ] && [ "$SOURCE_MODE" != "legacy_terminal" ]; then + echo "account_facts_source_mode_invalid" >&2 + exit 1 + fi if [[ ! "$BINANCE_ACCOUNT_FACTS_READER_REVISION" =~ ^[0-9a-f]{40}$ ]] || \ [ "$(git rev-parse HEAD)" != "$BINANCE_ACCOUNT_FACTS_READER_REVISION" ] || \ - [ "$GITHUB_REF" != "refs/heads/runtime-production" ]; then + [ "$GITHUB_REF" != "$expected_ref" ]; then echo "account_facts_trusted_checkout_invalid" >&2 exit 1 fi @@ -94,6 +104,7 @@ jobs: chmod 700 "$private_dir" - name: Download only the exact successful Runtime report artifact + if: ${{ inputs.source_mode != 'direct_read' }} uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: binance-execution-report-${{ steps.preflight.outputs.source_run_id }} @@ -104,6 +115,7 @@ jobs: - name: Read the bound Spot and Flexible Earn account quantities id: read-account-facts + if: ${{ inputs.source_mode != 'direct_read' }} shell: bash env: FACTS_DIR: ${{ steps.private-dir.outputs.facts_dir }} @@ -133,7 +145,30 @@ jobs: --report "$FACTS_DIR/source/execution_report.json" \ --output "$FACTS_DIR/account_facts.json" + - name: Read directly from the protected account binding + id: read-direct-account-facts + if: ${{ github.event_name == 'workflow_dispatch' && inputs.source_mode == 'direct_read' }} + shell: bash + env: + FACTS_DIR: ${{ steps.private-dir.outputs.facts_dir }} + SOURCE_MODE: direct_read + BINANCE_ACCOUNT_FACTS_ENABLED: ${{ vars.BINANCE_ACCOUNT_FACTS_ENABLED || 'false' }} + BINANCE_API_KEY: ${{ secrets.BINANCE_API_KEY }} + BINANCE_API_SECRET: ${{ secrets.BINANCE_API_SECRET }} + BINANCE_ACCOUNT_FACTS_BINDING_JSON: ${{ secrets.BINANCE_ACCOUNT_FACTS_BINDING_JSON }} + RUNTIME_TARGET_JSON: ${{ vars.RUNTIME_TARGET_JSON }} + BINANCE_ACCOUNT_FACTS_READER_REVISION: ${{ vars.BINANCE_ACCOUNT_FACTS_READER_REVISION || '' }} + READER_PUBLIC_REVISION: ${{ vars.BINANCE_ACCOUNT_FACTS_READER_REVISION || '' }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_REF: ${{ github.ref }} + run: | + set -euo pipefail + uv run --no-sync python scripts/read_binance_account_facts.py \ + --direct-read \ + --output "$FACTS_DIR/account_facts.json" + - name: Recheck Runtime source before publishing + if: ${{ inputs.source_mode != 'direct_read' }} shell: bash env: SOURCE_RUN_ID: ${{ steps.preflight.outputs.source_run_id }} From f2bb5878f2d4c016514e793c760bbe76164b61df Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:27:52 +0800 Subject: [PATCH 8/8] test: retain legacy protection alongside direct read mode Co-Authored-By: Codex --- tests/test_runtime_workflow_security.py | 39 ++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/tests/test_runtime_workflow_security.py b/tests/test_runtime_workflow_security.py index d7174300..bbec998b 100644 --- a/tests/test_runtime_workflow_security.py +++ b/tests/test_runtime_workflow_security.py @@ -46,8 +46,22 @@ def test_manual_native_reader_remains_fail_closed_without_report_artifact() -> N assert "actions/download-artifact@" in download assert "binance-execution-report-${{ steps.preflight.outputs.source_run_id }}" in download assert "continue-on-error" not in download - assert "if:" not in download - assert workflow.index("Verify trusted checkout and exact Runtime source") < ( + assert "if: ${{ inputs.source_mode != 'direct_read' }}" in download + + legacy_read = workflow.split( + " - name: Read the bound Spot and Flexible Earn account quantities", 1 + )[1].split(" - name: Read directly from the protected account binding", 1)[0] + assert "if: ${{ inputs.source_mode != 'direct_read' }}" in legacy_read + assert "--report \"$FACTS_DIR/source/execution_report.json\"" in legacy_read + + direct_read = workflow.split( + " - name: Read directly from the protected account binding", 1 + )[1].split(" - name: Recheck Runtime source before publishing", 1)[0] + assert "actions/download-artifact@" not in direct_read + assert "if: ${{ github.event_name == 'workflow_dispatch' && inputs.source_mode == 'direct_read' }}" in direct_read + assert "--direct-read" in direct_read + + assert workflow.index("Verify trusted reader checkout and source mode") < ( workflow.index("Download only the exact successful Runtime report artifact") ) < workflow.index("Read the bound Spot and Flexible Earn account quantities") @@ -68,15 +82,23 @@ def test_manual_native_facts_entry_remains_opt_in_and_source_bound() -> None: workflow = WORKFLOW.with_name("binance-account-facts.yml").read_text(encoding="utf-8") dispatch = workflow.split(" workflow_dispatch:\n", 1)[1].split("\npermissions:\n", 1)[0] enabled = dispatch.split(" enabled:\n", 1)[1].split(" source_run_id:\n", 1)[0] + source_mode = dispatch.split(" source_mode:\n", 1)[1] assert " type: boolean\n" in enabled assert " default: false\n" in enabled assert " source_run_id:\n" in dispatch - assert " options: [legacy_terminal]\n" in dispatch + assert " type: choice\n" in source_mode + assert " options: [direct_read, legacy_terminal]\n" in source_mode assert " schedule:" not in workflow assert "vars.BINANCE_ACCOUNT_FACTS_ENABLED == 'true'" in workflow assert "github.ref == 'refs/heads/runtime-production'" in workflow assert "github.workflow == 'Binance Account Facts' && github.event_name == 'workflow_dispatch'" in workflow assert "inputs.enabled == true && inputs.source_mode == 'legacy_terminal'" in workflow + job = workflow.split(" read-and-publish:\n", 1)[1].split(" runs-on:", 1)[0] + assert "vars.BINANCE_ACCOUNT_FACTS_ENABLED == 'true'" in job + assert "inputs.enabled == true && inputs.source_mode == 'direct_read'" in job + assert "inputs.enabled == true && inputs.source_mode == 'legacy_terminal'" in job + assert "inputs.enabled == true && inputs.source_mode == 'same_parent'" in job + assert "github.ref == 'refs/heads/runtime-production'" in job assert " environment: binance-runtime\n" in workflow assert " group: binance-account-facts-readonly\n cancel-in-progress: false\n" in workflow assert " ref: ${{ vars.BINANCE_ACCOUNT_FACTS_READER_REVISION }}\n" in workflow @@ -84,7 +106,16 @@ def test_manual_native_facts_entry_remains_opt_in_and_source_bound() -> None: assert "scripts/read_binance_account_facts.py --verify-current-source" in workflow assert "SOURCE_RUN_ID: ${{ steps.preflight.outputs.source_run_id }}" in workflow assert "BINANCE_ACCOUNT_FACTS_SYNC_TOKEN: ${{ secrets.BINANCE_ACCOUNT_FACTS_SYNC_TOKEN }}" in workflow - assert workflow.index("Verify trusted checkout and exact Runtime source") < ( + direct_read = workflow.split( + " - name: Read directly from the protected account binding", 1 + )[1].split(" - name: Recheck Runtime source before publishing", 1)[0] + for trade_or_runtime_gate in ( + "RUNTIME_TARGET_ENABLED", "BINANCE_DRY_RUN", "BINANCE_RISK_AUTHORITY_JSON", + "BINANCE_RUNTIME_RELEASE_SHA", "BINANCE_RECONCILIATION_EXPECTED_DIGESTS_JSON", + ): + assert trade_or_runtime_gate not in direct_read + assert "RUNTIME_TARGET_JSON: ${{ vars.RUNTIME_TARGET_JSON }}" in direct_read + assert workflow.index("Verify trusted reader checkout and source mode") < ( workflow.index("Read the bound Spot and Flexible Earn account quantities") ) < workflow.index("Recheck Runtime source before publishing") < ( workflow.index("Publish with the isolated account-facts token")