diff --git a/.github/workflows/runtime-isolation-host-profile.yml b/.github/workflows/runtime-isolation-host-profile.yml new file mode 100644 index 00000000..773698f3 --- /dev/null +++ b/.github/workflows/runtime-isolation-host-profile.yml @@ -0,0 +1,33 @@ +name: Runtime Isolation Host Profile + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + profile-current-runner: + runs-on: self-hosted + timeout-minutes: 10 + steps: + - name: Checkout BinancePlatform + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Collect redacted host, egress, and secret-boundary profile + env: + RUNTIME_EGRESS_CHECK_URL: ${{ vars.BINANCE_RUNTIME_EGRESS_CHECK_URL }} + RUNTIME_EXPECTED_EGRESS_SHA256: ${{ vars.BINANCE_RUNTIME_EGRESS_SHA256 }} + run: | + set -euo pipefail + python3 scripts/runtime_isolation_host_probe.py \ + --workflow .github/workflows/main.yml \ + --output reports/runtime_isolation_host_profile.json + + - name: Upload redacted host profile + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: runtime-isolation-host-profile-${{ github.run_id }} + path: reports/runtime_isolation_host_profile.json + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/runtime-isolation-shadow.yml b/.github/workflows/runtime-isolation-shadow.yml index 918d3b68..d45aa30a 100644 --- a/.github/workflows/runtime-isolation-shadow.yml +++ b/.github/workflows/runtime-isolation-shadow.yml @@ -2,6 +2,12 @@ name: Runtime Isolation Shadow Fixture on: workflow_dispatch: + inputs: + include_current_runner: + description: "Also replay the no-order fixture on the current self-hosted runner" + required: false + default: false + type: boolean permissions: contents: read @@ -57,16 +63,110 @@ jobs: BINANCE_DRY_RUN: "true" run: | set -euo pipefail - uv run --no-sync python run_cycle_replay.py \ - --run-id "isolation-shadow-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \ - --output reports/runtime_isolation_shadow.json - uv run --no-sync python scripts/assert_no_order_shadow_report.py \ - reports/runtime_isolation_shadow.json + uv run --no-sync python scripts/run_isolation_shadow_fixture.py \ + --output reports/runtime_isolation_shadow.json \ + --digest-output reports/runtime_isolation_shadow.sha256 - name: Upload redacted shadow report uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: runtime-isolation-shadow-${{ github.run_id }} - path: reports/runtime_isolation_shadow.json + name: runtime-isolation-shadow-github-${{ github.run_id }} + path: | + reports/runtime_isolation_shadow.json + reports/runtime_isolation_shadow.sha256 if-no-files-found: error retention-days: 7 + + current-runner-shadow: + if: ${{ inputs.include_current_runner }} + runs-on: self-hosted + timeout-minutes: 20 + steps: + - name: Checkout BinancePlatform + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Resolve pinned dependency refs + id: refs + shell: bash + run: | + set -euo pipefail + qpk_ref="$(grep -Eo 'QuantPlatformKit\.git@[0-9a-f]+' pyproject.toml | head -n1 | sed 's/.*@//')" + strategies_ref="$(grep -Eo 'CryptoStrategies\.git@[0-9a-f]+' pyproject.toml | head -n1 | sed 's/.*@//')" + test -n "$qpk_ref" + test -n "$strategies_ref" + echo "qpk_ref=$qpk_ref" >> "$GITHUB_OUTPUT" + echo "strategies_ref=$strategies_ref" >> "$GITHUB_OUTPUT" + + - name: Checkout QuantPlatformKit + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + repository: QuantStrategyLab/QuantPlatformKit + ref: ${{ steps.refs.outputs.qpk_ref }} + path: external/QuantPlatformKit + + - name: Checkout CryptoStrategies + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + repository: QuantStrategyLab/CryptoStrategies + ref: ${{ steps.refs.outputs.strategies_ref }} + path: external/CryptoStrategies + + - name: Setup Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.11" + + - name: Install locked runtime in the job temp directory + env: + UV_PROJECT_ENVIRONMENT: ${{ runner.temp }}/binance-isolation-shadow-venv + run: | + set -euo pipefail + python -m pip install --disable-pip-version-check uv==0.11.6 + uv sync --frozen --no-dev + uv pip install --no-deps -e external/QuantPlatformKit -e external/CryptoStrategies + + - name: Run current-runner fixed-input no-order replay + env: + BINANCE_DRY_RUN: "true" + UV_PROJECT_ENVIRONMENT: ${{ runner.temp }}/binance-isolation-shadow-venv + run: | + set -euo pipefail + uv run --no-sync python scripts/run_isolation_shadow_fixture.py \ + --output reports/runtime_isolation_shadow.json \ + --digest-output reports/runtime_isolation_shadow.sha256 + + - name: Upload redacted current-runner shadow report + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: runtime-isolation-shadow-current-runner-${{ github.run_id }} + path: | + reports/runtime_isolation_shadow.json + reports/runtime_isolation_shadow.sha256 + if-no-files-found: error + retention-days: 7 + + compare-shadow-digests: + if: ${{ inputs.include_current_runner }} + needs: [fixed-input-shadow, current-runner-shadow] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Download GitHub-hosted shadow evidence + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: runtime-isolation-shadow-github-${{ github.run_id }} + path: reports/github + + - name: Download current-runner shadow evidence + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: runtime-isolation-shadow-current-runner-${{ github.run_id }} + path: reports/current-runner + + - name: Compare semantic report digests + run: | + set -euo pipefail + cmp \ + reports/github/runtime_isolation_shadow.sha256 \ + reports/current-runner/runtime_isolation_shadow.sha256 + echo "GitHub-hosted and current-runner no-order shadow digests match." diff --git a/docs/operator_runbook.md b/docs/operator_runbook.md index eb9e642d..593e9b80 100644 --- a/docs/operator_runbook.md +++ b/docs/operator_runbook.md @@ -86,6 +86,21 @@ and rollback fence are documented in informational until a separately reviewed live cutover is approved; the current runtime remains authoritative. +Before selecting a replacement host, manually run `Runtime Isolation Host +Profile`. It has repository read permission only, receives no GitHub environment, +OIDC token, or secret, and writes a redacted artifact. Provider or network fields +that cannot be proven remain `UNVERIFIED`. To verify the current Binance +allowlisted egress without publishing the address, configure both +`BINANCE_RUNTIME_EGRESS_CHECK_URL` and `BINANCE_RUNTIME_EGRESS_SHA256`; the +workflow records only whether they match. + +`Runtime Isolation Shadow Fixture` always runs the portable no-order fixture on +a clean GitHub-hosted runner. Set `include_current_runner=true` only outside the +live scheduling window to run the same fixture on the current self-hosted runner +and compare semantic report digests. Neither job references Binance credentials, +the `binance-runtime` environment, or Google OIDC. Passing proves fixture parity, +not live readiness or host ephemerality. + ## Degraded Mode Ladder Healthy mode: diff --git a/docs/runtime_isolation_migration.md b/docs/runtime_isolation_migration.md index cd99046b..4a708a80 100644 --- a/docs/runtime_isolation_migration.md +++ b/docs/runtime_isolation_migration.md @@ -8,16 +8,22 @@ cutover, delete the current runner, change broker permissions, or move broker credentials. The current `main.yml` workflow remains the production path until a separately -reviewed cutover change is approved. The first migration phase is limited to a -fixed-input, no-order shadow replay on a clean GitHub-hosted runner. +reviewed cutover change is approved. Phase 1 is a fixed-input, no-order shadow +replay on a clean GitHub-hosted runner. Phase 2 is deployment-neutral discovery +and replay parity: it must identify the existing boundary before choosing an +ephemeral runner host or Cloud Run. ## Current architecture The current runtime is dispatched through GitHub Actions and runs on the -persistent `binance-quant-runner` self-hosted runner. The broker job checks out -the repository, authenticates to Google Cloud through GitHub OIDC, builds or -reuses a local dependency environment, and injects the Binance credentials only -into the strategy step. +`binance-quant-runner` self-hosted runner. The GitHub repository runner API +confirms only that it is an online Linux/X64 runner; it does not expose whether +the host is GCE, OCI, or another VPS/VM. The repository rename checklist records +the deployment as Oracle/VPS, but that remains documentation evidence rather +than a fresh host attestation. The broker job checks out the repository, +authenticates to Google Cloud through GitHub OIDC, builds or reuses a local +dependency environment, and injects the Binance credentials only into the +strategy step. Recent hardening already provides useful boundaries: @@ -34,19 +40,44 @@ operator action can leave files or processes on the runner and affect a later job. GitHub explicitly recommends ephemeral self-hosted runners for autoscaling and does not recommend persistent runners for that purpose. -## Decision +## Phase 2 decision gate -Use an isolated **Cloud Run Job** as the preferred target. A just-in-time -ephemeral GitHub runner is the fallback only if the runtime proves incompatible -with Cloud Run networking or execution constraints. +Do not select Cloud Run merely because the runtime uses Google Cloud for state. +The current runner may be an ordinary VPS with an already allowlisted stable +egress address. Phase 2 first records the host provider, runner registration +mode, network-egress match, and secret source without reading any secret value. -Cloud Run Job is the lower-complexity fit for this personal deployment because -the runtime is a bounded command, not a long-lived HTTP service. Each execution -runs in a fresh managed task, exits when complete, and writes logs to Cloud -Logging. It also avoids operating Kubernetes solely for GitHub Actions Runner -Controller. +Current evidence is: -### Target control and data planes +| Question | Evidence | Status | +| --- | --- | --- | +| Runner registration | GitHub repository API reports `binance-quant-runner`, Linux/X64, online | Confirmed; API did not attest ephemeral mode | +| Host provider | Rename checklist says Oracle/VPS; runner API does not expose provider | `UNVERIFIED` until host profile runs | +| Network egress | Runbook expects an allowlisted runner address; no checked-in fingerprint exists | `UNVERIFIED` until an operator-configured fingerprint matches | +| Broker secret source | `main.yml` obtains Binance credentials from GitHub environment secrets | Confirmed | +| Broker secret scope | Credentials are injected only into the trading strategy step | Confirmed by contract tests | + +The manual `Runtime Isolation Host Profile` workflow is read-only: it has no +environment, OIDC permission, or secret references. It records DMI/provider +evidence and, when the operator configures both an HTTPS egress-check endpoint +and the expected allowlisted-address digest, records only `MATCHED` or +`MISMATCHED`. It never records the raw address or its digest. + +### Candidate decision matrix + +| Candidate | Isolation gained | Stable egress | Operational cost | Main residual risk | Selection condition | +| --- | --- | --- | --- | --- | --- | +| Ephemeral runner on the same persistent host | One GitHub job per registration, but not a fresh host | Preserves current egress | Low | Host processes, filesystem, Docker daemon, or root compromise can survive runner deregistration | Transitional only; host is rebuilt or runner executes inside a genuinely disposable VM boundary | +| Independent disposable VPS/VM runner | Fresh VM per execution; keeps GitHub runner compatibility | Reserved/fixed IP is straightforward | Medium | Image/bootstrap, runner-token delivery, log forwarding, and VM teardown must be automated | Prefer when the current runtime needs VM semantics or Binance IP allowlisting dominates | +| Cloud Run Job | Fresh managed task with separate invoke/runtime identities | Requires Direct VPC egress plus Cloud NAT/static IP | Medium | Container compatibility, NAT cost/configuration, and GCP IAM become new dependencies | Prefer only after no-order container parity and fixed-egress feasibility are proven | + +No final target is selected in Phase 2. A same-host ephemeral registration is +not equivalent to an ephemeral machine and is not the end state. The lowest-risk +choice is the candidate that passes the same no-order digest, has a verified +stable egress path, separates invoke/runtime identity, and can be destroyed +after one execution. + +### Cloud Run candidate control and data planes ```text external scheduler @@ -71,20 +102,20 @@ Build/deploy authority and runtime invoke authority must remain separate: - the GitHub workflow never receives `BINANCE_API_KEY` or `BINANCE_API_SECRET`. -## Current GCP gaps to close before deployment +## Cloud Run candidate gaps to close before deployment -The `binancequant` project currently has the GitHub Workload Identity provider -and a runtime service account with Firestore access, but no reviewed Cloud Run -Job, Artifact Registry repository, runtime Secret Manager entries, dedicated -invoker identity, or fixed-egress Cloud NAT path. Those are deployment -prerequisites, not defects to paper over in workflow YAML. +Repository configuration proves the existing GitHub Workload Identity contract +and Firestore use, but it does not prove a reviewed Cloud Run Job, Artifact +Registry image, dedicated invoker identity, or fixed-egress Cloud NAT path. +Treat every missing attestation as `UNVERIFIED`; do not infer a resource from a +project name or create it as part of discovery. Binance IP allowlisting is an important constraint. Cloud Run uses a dynamic outbound IP pool by default. A live job must route all outbound traffic through Direct VPC egress (or a connector) and Cloud NAT with a reserved static IP before that IP can be allowlisted at Binance. -## Risk boundaries +## Cloud Run candidate risk mapping | Boundary | Required rule | Failure behavior | | --- | --- | --- | @@ -104,9 +135,10 @@ broker orders, fills, and the stable execution ID. ## Staged migration -### Phase 1: fixed-input isolation shadow (this change) +### Phase 1: fixed-input isolation shadow (completed) -- Run `run_cycle_replay.py` on `ubuntu-latest` using committed fixtures. +- Run `scripts/run_isolation_shadow_fixture.py` on `ubuntu-latest` using + committed fixtures and a fixed replay clock. - Do not request OIDC and do not reference any GitHub environment or secret. - Assert `dry_run=true`, `executed_call_count=0`, and at least one suppressed side effect. @@ -116,21 +148,37 @@ Passing this phase proves that the strategy package can execute in a clean, short-lived environment. It does not validate Google Cloud, Binance networking, or live readiness. -### Phase 2: Cloud Run fixture shadow - -- Create a shadow-only container entrypoint that runs the same committed fixture. -- Deploy a separate `binance-runtime-shadow` job with no broker secrets. -- Set one task, parallelism one, zero retries, and a bounded timeout. -- Invoke manually through a dedicated GitHub OIDC invoker identity. -- Compare the Cloud Run report digest with the GitHub-hosted Phase 1 report. +### Phase 2: deployment-neutral discovery and fixture parity (this change) + +- Collect a redacted current-runner profile without OIDC or secret access. +- Mark host provider, registration mode, or egress as `UNVERIFIED` rather than + guessing from the GCP project or legacy documentation. +- Run the same portable fixed-input fixture on the GitHub-hosted runner and, + optionally, the current self-hosted runner. +- Require `dry_run=true`, zero executed calls, no state writes, and a matching + semantic report digest. Deployment identity fields are excluded from the + digest; strategy decisions, intents, gates, and suppressed effects remain. +- Compare same-host ephemeral, disposable VPS/VM, and Cloud Run using the matrix + above. Do not create a runner, VM, container registry, Cloud Run Job, IAM + binding, network, or secret. + +If Cloud Run is selected later, its shadow job must use a digest-pinned image, +dedicated runtime and invoker identities, one task, parallelism one, zero +platform retries, a bounded timeout, and no broker secret. If a disposable VM +runner is selected, the equivalent controls are one job per VM, no runner reuse, +a pinned machine/container image, verified teardown, and external logs. ### Phase 3: read-only forward shadow -- Add Firestore read/write permissions required for shadow state only. +- Give the selected candidate only the Firestore permissions required for + shadow state. - If live market/account observations are required, use a separate Binance API key without order or withdrawal capability. -- Add Secret Manager resource-level access only for that read-only key. -- Establish Direct VPC egress, Cloud NAT, and a reserved outbound IP. +- Limit access to the named read-only credential at the selected runtime + boundary; do not inject it into discovery or fixture workflows. +- Preserve and verify the current fixed egress for a disposable VM candidate. + For Cloud Run, establish Direct VPC egress, Cloud NAT, and a reserved outbound + IP first. - Run alongside the old runtime without sending orders and reconcile decisions. Creating or changing a Binance API key is an explicit operator action and is not @@ -140,11 +188,12 @@ part of the automated migration. This phase requires a separate PR and human approval. Before it starts: -- the Cloud Run job must use the existing live risk envelope or a smaller one; +- the selected candidate must use the existing live risk envelope or a smaller + one; - the old scheduler must be fenced so only one execution path can place orders; - duplicate-order, partial-fill, timeout, Firestore outage, and Binance outage recovery must be rehearsed; -- the execution image digest, job revision, service accounts, IAM bindings, +- the execution image digest, runner/job revision, identities, IAM bindings, secret versions, static IP, and rollback owner must be recorded. ### Phase 5: cutover and retirement @@ -155,23 +204,28 @@ rotate credentials only in a later, separately authorized cleanup. ## Deployment preflight -All items below are mandatory before Phase 2 or later: +All common items below are mandatory before provisioning any candidate: - [ ] Phase 1 fixture report is deterministic and records zero executed calls. -- [ ] Container entrypoint defaults to no-order; live requires an explicit, - reviewed runtime target. -- [ ] Image is referenced by digest, not a mutable tag. -- [ ] Shadow and live are different Cloud Run Jobs and identities. -- [ ] GitHub invoker service account has `roles/run.invoker` only on the intended - job and cannot update the job. -- [ ] Job runtime service account is not the default Compute service account. -- [ ] Runtime service account has no project-wide Owner, Editor, IAM, Cloud Run - admin, or Secret Manager admin role. -- [ ] Broker secrets are absent from GitHub and plaintext environment variables; - Secret Manager access is limited to named secrets. +- [ ] Current host provider and runner persistence are attested, not inferred. +- [ ] Current public egress matches the separately configured allowlist + fingerprint without publishing the address. +- [ ] Candidate produces the same semantic digest as the GitHub-hosted fixture. +- [ ] Candidate runs one execution at a time with platform retries disabled. +- [ ] Candidate starts from a pinned image and is destroyed after one execution. +- [ ] Shadow and live use different jobs/runners and identities. +- [ ] Invoke identity cannot deploy, update IAM, or change runtime configuration. +- [ ] Runtime identity is dedicated and is not a default compute identity. +- [ ] Runtime identity has no project-wide Owner, Editor, IAM, runtime-platform, + or Secret Manager administrator role. +- [ ] Every shadow candidate receives no broker secret. Any later live design + documents its secret source and limits access to the single execution + boundary and named secret resources. - [ ] Live secret versions are pinned or rotation behavior is explicitly tested. -- [ ] Task count and parallelism are one; task retries are zero; timeout is - shorter than the scheduling interval. +- [ ] Cloud Run, if selected, uses a digest-pinned image, task count one, + parallelism one, `maxRetries: 0`, and a bounded timeout. +- [ ] Disposable VM, if selected, uses a one-job JIT/ephemeral registration, + external runner logs, and verified VM destruction. - [ ] Firestore lease prevents overlapping old/new runtime cycles. - [ ] Static outbound IP is observed from the job and allowlisted at Binance. - [ ] Binance key has withdrawals disabled and the smallest required trade scope. @@ -189,13 +243,14 @@ the existing runtime is unaffected. During live canary or cutover: -1. Stop the new scheduler/invoker and wait for the current Cloud Run execution to - reach a terminal state. +1. Stop the new scheduler/invoker and wait for the current candidate execution + to reach a terminal state. 2. Reconcile broker open orders, fills, balances, Firestore lease, and the last durable execution report. Do not start the old path while ownership is ambiguous. -3. Mark the Cloud Run job parked and revoke its invoker binding. Do not delete - evidence or secret versions during incident response. +3. Mark the candidate parked and revoke its invoker binding or runner + registration. Do not delete evidence or secret versions during incident + response. 4. Re-enable the old dispatch path only after the execution lease is cleared and the broker state matches the expected portfolio. 5. Record the rollback reason and require a new canary decision before retrying. diff --git a/scripts/assert_no_order_shadow_report.py b/scripts/assert_no_order_shadow_report.py index 0d644d39..fe139ead 100644 --- a/scripts/assert_no_order_shadow_report.py +++ b/scripts/assert_no_order_shadow_report.py @@ -2,11 +2,28 @@ from __future__ import annotations import argparse +import copy +import hashlib import json +import re from pathlib import Path from typing import Any +SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +VOLATILE_TOP_LEVEL_FIELDS = { + "account_group", + "account_region", + "account_scope", + "deploy_target", + "finished_at", + "instance_name", + "project_id", + "run_id", + "run_source", +} + + def validate_no_order_report(report: Any) -> list[str]: errors: list[str] = [] if not isinstance(report, dict): @@ -30,9 +47,40 @@ def validate_no_order_report(report: Any) -> list[str]: return errors +def normalize_shadow_report(report: dict[str, Any]) -> dict[str, Any]: + """Remove deployment identity only; preserve every strategy decision and intent.""" + normalized = copy.deepcopy(report) + for field in VOLATILE_TOP_LEVEL_FIELDS: + normalized.pop(field, None) + for notification in normalized.get("notifications", []): + if isinstance(notification, dict): + notification.pop("run_id", None) + return normalized + + +def semantic_report_sha256(report: dict[str, Any]) -> str: + canonical = json.dumps( + normalize_shadow_report(report), + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256(canonical).hexdigest() + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Fail unless a replay report proves no-order shadow execution.") parser.add_argument("report", type=Path, help="Path to the structured replay report.") + parser.add_argument( + "--expected-sha256", + default="", + help="Optional expected semantic report digest. A mismatch fails closed.", + ) + parser.add_argument( + "--digest-output", + type=Path, + help="Optional file that receives the accepted semantic SHA-256 digest.", + ) return parser.parse_args() @@ -42,7 +90,19 @@ def main() -> None: errors = validate_no_order_report(report) if errors: raise SystemExit("No-order shadow report rejected: " + "; ".join(errors)) - print("No-order shadow report accepted: dry_run=true executed_call_count=0") + digest = semantic_report_sha256(report) + expected = args.expected_sha256.strip().lower() + if expected and not SHA256_RE.fullmatch(expected): + raise SystemExit("Expected semantic report digest must be 64 lowercase hexadecimal characters") + if expected and digest != expected: + raise SystemExit(f"Semantic shadow report digest mismatch: expected={expected} actual={digest}") + if args.digest_output: + args.digest_output.parent.mkdir(parents=True, exist_ok=True) + args.digest_output.write_text(digest + "\n", encoding="utf-8") + print( + "No-order shadow report accepted: " + f"dry_run=true executed_call_count=0 semantic_sha256={digest}" + ) if __name__ == "__main__": diff --git a/scripts/run_isolation_shadow_fixture.py b/scripts/run_isolation_shadow_fixture.py new file mode 100644 index 00000000..dbdf8917 --- /dev/null +++ b/scripts/run_isolation_shadow_fixture.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from assert_no_order_shadow_report import ( # noqa: E402 + SHA256_RE, + semantic_report_sha256, + validate_no_order_report, +) + +import run_cycle_replay # noqa: E402 + + +FIXED_RUN_ID = "runtime-isolation-fixture-shadow" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run the deployment-neutral fixed-input, no-order shadow fixture." + ) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--digest-output", type=Path, required=True) + parser.add_argument( + "--expected-sha256", + default=os.getenv("SHADOW_EXPECTED_REPORT_SHA256", ""), + help="Optional semantic digest produced by another isolated target.", + ) + return parser.parse_args() + + +def main() -> None: + if os.getenv("BINANCE_DRY_RUN", "").strip().lower() != "true": + raise SystemExit("Isolation shadow requires BINANCE_DRY_RUN=true") + + args = parse_args() + expected = args.expected_sha256.strip().lower() + if expected and not SHA256_RE.fullmatch(expected): + raise SystemExit("Expected semantic report digest must be 64 lowercase hexadecimal characters") + + result = run_cycle_replay.run_replay_cycle( + run_id=FIXED_RUN_ID, + dry_run=True, + now_utc=run_cycle_replay.DEFAULT_REPLAY_TIME, + ) + report = result["report"] + errors = validate_no_order_report(report) + if result["client"].side_effect_calls: + errors.append("fixture client recorded a real side-effect call") + if result["state_store"].write_calls: + errors.append("fixture state store recorded a real write call") + if errors: + raise SystemExit("Isolation shadow rejected: " + "; ".join(errors)) + + digest = semantic_report_sha256(report) + if expected and digest != expected: + raise SystemExit(f"Semantic shadow report digest mismatch: expected={expected} actual={digest}") + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + args.digest_output.parent.mkdir(parents=True, exist_ok=True) + args.digest_output.write_text(digest + "\n", encoding="utf-8") + print( + "Isolation shadow accepted: " + f"dry_run=true executed_call_count=0 semantic_sha256={digest}" + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/runtime_isolation_host_probe.py b/scripts/runtime_isolation_host_probe.py new file mode 100644 index 00000000..070e3d6f --- /dev/null +++ b/scripts/runtime_isolation_host_probe.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import hashlib +import ipaddress +import json +import os +import platform +import re +import urllib.request +from pathlib import Path +from typing import Any + + +SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +DMI_FIELDS = { + "sys_vendor": Path("/sys/class/dmi/id/sys_vendor"), + "product_name": Path("/sys/class/dmi/id/product_name"), + "product_version": Path("/sys/class/dmi/id/product_version"), +} + + +def _read_field(path: Path) -> str: + try: + return path.read_text(encoding="utf-8").strip()[:160] + except (OSError, UnicodeError): + return "" + + +def infer_host_provider(dmi: dict[str, str]) -> str: + evidence = " ".join(dmi.values()).lower() + if "google" in evidence or "compute engine" in evidence: + return "gce" + if "oracle" in evidence and "cloud" in evidence: + return "oci" + if "amazon" in evidence or "ec2" in evidence: + return "aws_ec2" + if "microsoft" in evidence or "azure" in evidence: + return "azure_vm" + if "tencent" in evidence: + return "tencent_cloud_vm" + if "digitalocean" in evidence: + return "digitalocean_vm" + if "hetzner" in evidence: + return "hetzner_vm" + if "linode" in evidence or "akamai" in evidence: + return "linode_vm" + if any(marker in evidence for marker in ("kvm", "qemu", "vmware", "virtualbox")): + return "virtual_machine_unknown_provider" + return "unknown" + + +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_block = "" + if strategy_marker in workflow and next_marker in workflow: + strategy_block = workflow[workflow.index(strategy_marker) : workflow.index(next_marker)] + references_present = key_ref in workflow and secret_ref in workflow + return { + "source": "github_actions_environment_secrets" if references_present else "UNVERIFIED", + "environment_name": "binance-runtime" if "environment: binance-runtime" in workflow else None, + "broker_secret_references_present": references_present, + "broker_secret_scope": ( + "strategy_step_only" + if key_ref in strategy_block and secret_ref in strategy_block + else "UNVERIFIED" + ), + "secret_values_read": False, + } + + +def check_egress(*, check_url: str, expected_sha256: str) -> dict[str, Any]: + if not expected_sha256: + return { + "status": "UNVERIFIED", + "reason": "expected egress fingerprint is not configured", + "raw_address_recorded": False, + } + if not SHA256_RE.fullmatch(expected_sha256): + raise ValueError("expected egress fingerprint must be 64 lowercase hexadecimal characters") + if not check_url.startswith("https://"): + raise ValueError("egress check URL must use https") + + request = urllib.request.Request(check_url, headers={"User-Agent": "binance-runtime-isolation-probe/1"}) + with urllib.request.urlopen(request, timeout=10) as response: + raw_address = response.read(256).decode("ascii").strip() + address = ipaddress.ip_address(raw_address) + observed = hashlib.sha256(address.compressed.encode("ascii")).hexdigest() + matched = observed == expected_sha256 + return { + "status": "MATCHED" if matched else "MISMATCHED", + "ip_version": address.version, + "raw_address_recorded": False, + } + + +def build_report(*, workflow_path: Path, egress_check_url: str, expected_egress_sha256: str) -> dict[str, Any]: + dmi = {name: value for name, path in DMI_FIELDS.items() if (value := _read_field(path))} + egress = check_egress( + check_url=egress_check_url, + expected_sha256=expected_egress_sha256, + ) + secret_boundary = inspect_secret_source(workflow_path) + host_provider = infer_host_provider(dmi) + status = ( + "READY" + if egress["status"] == "MATCHED" + and host_provider != "unknown" + and secret_boundary["broker_secret_scope"] == "strategy_step_only" + else "PARTIAL" + ) + return { + "schema_version": "runtime_isolation_host_profile.v1", + "status": status, + "no_order": True, + "runner": { + "name": os.getenv("RUNNER_NAME") or None, + "os": os.getenv("RUNNER_OS") or platform.system(), + "arch": os.getenv("RUNNER_ARCH") or platform.machine(), + "environment": os.getenv("RUNNER_ENVIRONMENT") or None, + "registration_mode": "UNVERIFIED", + }, + "host": { + "provider": host_provider, + "dmi": dmi, + "hostname_recorded": False, + }, + "network_egress": egress, + "secret_boundary": secret_boundary, + } + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Collect a redacted runtime isolation host profile.") + parser.add_argument("--workflow", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument( + "--egress-check-url", + default=os.getenv("RUNTIME_EGRESS_CHECK_URL", ""), + help="Operator-controlled HTTPS endpoint that returns only the caller IP.", + ) + parser.add_argument( + "--expected-egress-sha256", + default=os.getenv("RUNTIME_EXPECTED_EGRESS_SHA256", "").strip().lower(), + help="Expected SHA-256 of the allowlisted public IP; the raw IP is never emitted.", + ) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + if bool(args.egress_check_url) != bool(args.expected_egress_sha256): + raise SystemExit("egress check URL and expected fingerprint must be configured together") + report = build_report( + workflow_path=args.workflow, + egress_check_url=args.egress_check_url, + expected_egress_sha256=args.expected_egress_sha256, + ) + if report["network_egress"]["status"] == "MISMATCHED": + raise SystemExit("Observed egress does not match the configured allowlist fingerprint") + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print( + "Runtime isolation host profile written: " + f"status={report['status']} provider={report['host']['provider']} " + f"egress={report['network_egress']['status']}" + ) + + +if __name__ == "__main__": + main() diff --git a/tests/test_runtime_isolation_host_probe.py b/tests/test_runtime_isolation_host_probe.py new file mode 100644 index 00000000..ab1a4e6f --- /dev/null +++ b/tests/test_runtime_isolation_host_probe.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[1] +PROBE = ROOT / "scripts" / "runtime_isolation_host_probe.py" +MAIN_WORKFLOW = ROOT / ".github" / "workflows" / "main.yml" + + +def load_probe(): + spec = importlib.util.spec_from_file_location("runtime_isolation_host_probe", PROBE) + if spec is None or spec.loader is None: + raise RuntimeError("Unable to load runtime isolation host probe") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class RuntimeIsolationHostProbeTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.probe = load_probe() + + def test_current_secret_source_is_github_environment_and_step_scoped(self) -> None: + result = self.probe.inspect_secret_source(MAIN_WORKFLOW) + + self.assertEqual(result["source"], "github_actions_environment_secrets") + self.assertEqual(result["environment_name"], "binance-runtime") + self.assertTrue(result["broker_secret_references_present"]) + self.assertEqual(result["broker_secret_scope"], "strategy_step_only") + 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"}), + "oci", + ) + self.assertEqual( + self.probe.infer_host_provider({"product_name": "Google Compute Engine"}), + "gce", + ) + self.assertEqual( + self.probe.infer_host_provider({"product_name": "KVM"}), + "virtual_machine_unknown_provider", + ) + self.assertEqual( + self.probe.infer_host_provider({"sys_vendor": "Tencent Cloud"}), + "tencent_cloud_vm", + ) + self.assertEqual(self.probe.infer_host_provider({}), "unknown") + + def test_egress_comparison_never_returns_raw_address_or_digest(self) -> None: + address = "203.0.113.10" + expected = hashlib.sha256(address.encode("ascii")).hexdigest() + response = mock.MagicMock() + response.__enter__.return_value.read.return_value = address.encode("ascii") + response.__exit__.return_value = False + + with mock.patch.object(self.probe.urllib.request, "urlopen", return_value=response): + result = self.probe.check_egress( + check_url="https://egress-check.example.test/ip", + expected_sha256=expected, + ) + + self.assertEqual(result["status"], "MATCHED") + self.assertEqual(result["ip_version"], 4) + self.assertFalse(result["raw_address_recorded"]) + self.assertNotIn(address, json.dumps(result)) + self.assertNotIn(expected, json.dumps(result)) + + def test_unconfigured_egress_is_partial_not_ready(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workflow = Path(temp_dir) / "main.yml" + workflow.write_text(MAIN_WORKFLOW.read_text(encoding="utf-8"), encoding="utf-8") + with mock.patch.object(self.probe, "DMI_FIELDS", {}): + report = self.probe.build_report( + workflow_path=workflow, + egress_check_url="", + expected_egress_sha256="", + ) + + self.assertEqual(report["status"], "PARTIAL") + self.assertEqual(report["network_egress"]["status"], "UNVERIFIED") + self.assertTrue(report["no_order"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_runtime_isolation_shadow.py b/tests/test_runtime_isolation_shadow.py index c9ea0e11..082d77c5 100644 --- a/tests/test_runtime_isolation_shadow.py +++ b/tests/test_runtime_isolation_shadow.py @@ -9,7 +9,9 @@ ROOT = Path(__file__).resolve().parents[1] WORKFLOW = ROOT / ".github" / "workflows" / "runtime-isolation-shadow.yml" +HOST_PROFILE_WORKFLOW = ROOT / ".github" / "workflows" / "runtime-isolation-host-profile.yml" VALIDATOR = ROOT / "scripts" / "assert_no_order_shadow_report.py" +PORTABLE_RUNNER = ROOT / "scripts" / "run_isolation_shadow_fixture.py" FULL_SHA_ACTION = re.compile(r"(?:-\s+)?uses:\s+[^\s@]+@[0-9a-f]{40}(?:\s+#\s+v\d+)?$") @@ -22,6 +24,12 @@ def load_validator(): return module +def job_block(workflow: str, job: str, next_job: str | None = None) -> str: + start = workflow.index(f" {job}:\n") + end = workflow.index(f" {next_job}:\n", start) if next_job else len(workflow) + return workflow[start:end] + + class RuntimeIsolationShadowWorkflowTests(unittest.TestCase): @classmethod def setUpClass(cls) -> None: @@ -30,11 +38,16 @@ def setUpClass(cls) -> None: def test_shadow_workflow_is_manual_ephemeral_and_has_no_secret_capability(self) -> None: workflow = self.workflow + github_shadow = job_block(workflow, "fixed-input-shadow", "current-runner-shadow") + current_runner_shadow = job_block(workflow, "current-runner-shadow", "compare-shadow-digests") self.assertIn("workflow_dispatch:", workflow) self.assertNotIn("pull_request_target:", workflow) - self.assertIn("runs-on: ubuntu-latest", workflow) - self.assertNotIn("runs-on: self-hosted", workflow) + self.assertIn("runs-on: ubuntu-latest", github_shadow) + self.assertNotIn("runs-on: self-hosted", github_shadow) + self.assertIn("if: ${{ inputs.include_current_runner }}", current_runner_shadow) + self.assertIn("runs-on: self-hosted", current_runner_shadow) + self.assertNotIn("environment:", current_runner_shadow) self.assertIn("contents: read", workflow) self.assertNotIn("id-token: write", workflow) self.assertNotIn("environment:", workflow) @@ -48,10 +61,12 @@ def test_shadow_workflow_uses_fixture_replay_and_pins_actions(self) -> None: self.assertTrue(action_lines) self.assertTrue(all(FULL_SHA_ACTION.fullmatch(line) for line in action_lines)) - self.assertIn("run_cycle_replay.py", workflow) - self.assertIn("assert_no_order_shadow_report.py", workflow) + self.assertIn("run_isolation_shadow_fixture.py", workflow) + self.assertIn("runtime_isolation_shadow.sha256", workflow) + self.assertIn("Compare semantic report digests", workflow) self.assertIn('BINANCE_DRY_RUN: "true"', workflow) self.assertNotIn("python main.py", workflow) + self.assertTrue(PORTABLE_RUNNER.is_file()) def test_validator_accepts_only_dry_run_with_zero_executed_calls(self) -> None: accepted = { @@ -73,6 +88,46 @@ def test_validator_accepts_only_dry_run_with_zero_executed_calls(self) -> None: executed["side_effect_summary"]["executed_call_count"] = 1 self.assertTrue(self.validator.validate_no_order_report(executed)) + def test_semantic_digest_ignores_only_deployment_identity(self) -> None: + first = { + "status": "ok", + "dry_run": True, + "run_id": "github-run", + "run_source": "github_actions", + "deploy_target": "vps", + "notifications": [{"run_id": "github-run", "delivery_status": "suppressed"}], + "side_effect_summary": {"executed_call_count": 0, "suppressed_call_count": 3}, + "buy_sell_intents": [{"symbol": "BTCUSDT", "action": "buy"}], + } + second = json.loads(json.dumps(first)) + second.update({"run_id": "cloud-run", "run_source": "runtime", "deploy_target": "cloud_run"}) + second["notifications"][0]["run_id"] = "cloud-run" + + self.assertEqual( + self.validator.semantic_report_sha256(first), + self.validator.semantic_report_sha256(second), + ) + second["buy_sell_intents"][0]["action"] = "sell" + self.assertNotEqual( + self.validator.semantic_report_sha256(first), + self.validator.semantic_report_sha256(second), + ) + + def test_host_profile_workflow_has_no_secret_or_cloud_authority(self) -> None: + workflow = HOST_PROFILE_WORKFLOW.read_text(encoding="utf-8") + action_lines = [line.strip() for line in workflow.splitlines() if "uses:" in line] + + self.assertIn("workflow_dispatch:", workflow) + self.assertIn("runs-on: self-hosted", workflow) + self.assertIn("contents: read", workflow) + self.assertNotIn("id-token: write", workflow) + self.assertNotIn("environment:", workflow) + self.assertNotIn("secrets.", workflow) + self.assertNotIn("BINANCE_API_KEY", workflow) + self.assertNotIn("BINANCE_API_SECRET", workflow) + self.assertTrue(action_lines) + self.assertTrue(all(FULL_SHA_ACTION.fullmatch(line) for line in action_lines)) + if __name__ == "__main__": unittest.main()