diff --git a/.agents/skills/review-opensecret-security/SKILL.md b/.agents/skills/review-opensecret-security/SKILL.md index 2428d0b37..61a240fae 100644 --- a/.agents/skills/review-opensecret-security/SKILL.md +++ b/.agents/skills/review-opensecret-security/SKILL.md @@ -133,11 +133,15 @@ database, provider, client, build/artifact, and live evidence separately. Local artifact builds and read-only PCR comparison are validation when in scope. Root backend CI validates Rust, Nix checks/default binary, and SDK -compatibility. A separate read-only ARM64 workflow compares dev/prod EIF +compatibility. A separate ARM64 workflow compares dev/prod EIF measurements on explicit approved-PCR JSON edits in PRs, relevant master changes, and manual runs. Do not require ordinary backend PRs to update -approvals, and do not suppress meaningful master mismatches. CI never signs, -publishes EIFs, or deploys the TEE service. A passing comparison is not live +approvals, and do not suppress meaningful master mismatches. Only its trusted +master push/manual job receives OIDC for FlakeHub caching; PRs and non-master +manual refs use GitHub's branch-scoped cache without OIDC. Review both event +and ref guards, cache provenance, and default-branch versus PR cache scope. +Cache writes never authorize approval changes, signing, EIF releases, or +deployment. A passing comparison is not live deployment evidence or proof that both public PCR locations are synchronized. Use `docs/pcr-compatibility.md` for manual signed-PCR validation and legacy publication. Require explicit authorization for PCR diff --git a/.agents/skills/validate-opensecret/SKILL.md b/.agents/skills/validate-opensecret/SKILL.md index 6f6e1fb70..1d23d9e3e 100644 --- a/.agents/skills/validate-opensecret/SKILL.md +++ b/.agents/skills/validate-opensecret/SKILL.md @@ -178,12 +178,19 @@ and compares generated measurements on PRs that explicitly edit the four approved PCR JSON files, relevant backend/TEE or approval changes to master, and manual runs. Ordinary backend PRs do not require new PCR approvals; master mismatches intentionally signal that the revision does not match its current -approvals. CI does not sign, publish EIFs, or deploy the TEE service. +approvals. CI does not sign, create EIF releases, or deploy the TEE service. Ordinary pull-request completion does not update PCR references. Do not copy or sign values just to clear a validation failure; distinguish an EIF build failure from a PCR mismatch. Use `docs/pcr-compatibility.md` for the offline signed-history validation and manual legacy-publication procedure. +For EIF cache/workflow changes, follow +`docs/nitro-deploy.md#binary-caches-and-cold-run-validation`: preserve +master-only FlakeHub OIDC and the unprivileged GitHub cache path. Verify actual +custom-kernel substitution and timing on a fresh hosted ARM64 runner, then +unprivileged reuse of the warmed GitHub cache. Local store hits and skipped +PR jobs cannot establish hosted cache performance or cross-organization access. + Immediately before an authorized dev or prod publish/deployment, use the supported Linux/ARM64 release builder and the operator runbook in `docs/nitro-deploy.md` to build the exact target, review its measurements, and diff --git a/.github/workflows/opensecret-eif.yml b/.github/workflows/opensecret-eif.yml index e6e8da4bf..42d94b7ab 100644 --- a/.github/workflows/opensecret-eif.yml +++ b/.github/workflows/opensecret-eif.yml @@ -7,7 +7,8 @@ on: branches: [master] workflow_dispatch: -# This is measurement verification, never signing, publication, or deployment. +# Cache writes are not PCR approval, release publication, or deployment. +# OIDC is granted only to the separate trusted-master job below. permissions: contents: read @@ -28,12 +29,10 @@ jobs: ${{ always() && !cancelled() && ( - github.event_name == 'workflow_dispatch' || + (github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/master') || (github.event_name == 'pull_request' && needs.changes.result == 'success' && - needs.changes.outputs.pcr_approvals == 'true') || - (github.event_name == 'push' && github.ref == 'refs/heads/master' && - (needs.changes.result != 'success' || needs.changes.outputs.eif != 'false')) + needs.changes.outputs.pcr_approvals == 'true') ) }} runs-on: ubuntu-24.04-arm @@ -57,8 +56,72 @@ jobs: - name: Install pinned Nix uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22 with: + determinate: false github-token: "" + # Fork PRs cannot authenticate to FlakeHub. GitHub scopes cache writes + # to the PR merge ref; those entries cannot populate master's cache. + - name: Reuse branch-scoped Nix cache without FlakeHub credentials + uses: DeterminateSystems/magic-nix-cache-action@3c034b51a9deec0a09ef1df8b436ac5db50fae94 + with: + source-revision: 4cc363589df8090801c098cdcde1bdd42562318a + use-flakehub: disabled + use-gha-cache: enabled + + - name: Build EIF and compare approved measurements + shell: bash + run: bash scripts/ci/check_opensecret_eif.sh "$EIF_MODE" + + eif-trusted: + name: EIF/PCR approval match (master, ${{ matrix.mode }}) + needs: changes + # Never issue an OIDC token to PR code or a dispatch on another ref. + if: >- + ${{ + always() && !cancelled() && + github.ref == 'refs/heads/master' && + ( + github.event_name == 'workflow_dispatch' || + (github.event_name == 'push' && + (needs.changes.result != 'success' || needs.changes.outputs.eif != 'false')) + ) + }} + permissions: + contents: read + id-token: write + runs-on: ubuntu-24.04-arm + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + mode: [dev, prod] + env: + EIF_MODE: ${{ matrix.mode }} + OPENSECRET_DEV_POSTGRES: "0" + OPENSECRET_DEV_ENV: "0" + OPENSECRET_DEV_CONTAINERS: "0" + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + submodules: recursive + fetch-depth: 0 + + - name: Install pinned Determinate Nix for FlakeHub authentication + uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22 + with: + determinate: true + github-token: "" + + - name: Reuse FlakeHub and warm the master GitHub cache + uses: DeterminateSystems/flakehub-cache-action@1f9a51a2959d3e26c7838c6f3bf9f48acae525ea # v3 + with: + # Enable both, not the default fallback-only GitHub cache. + use-gha-cache: enabled + # Also cache substituted paths, such as the FlakeHub kernel hit, + # for later PRs that have no FlakeHub access. + diff-store: true + - name: Build EIF and compare approved measurements shell: bash run: bash scripts/ci/check_opensecret_eif.sh "$EIF_MODE" diff --git a/AGENTS.md b/AGENTS.md index 19d97713f..5071300d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,7 +104,11 @@ same checkout. Backend changes do not imply Research or Agent packaging. The separate `opensecret-eif.yml` compares dev/prod EIF measurements only on PRs editing approved PCR JSON, relevant master changes, and manual runs. Preserve ordinary backend PRs without fresh approvals and meaningful master mismatches; -these read-only checks never sign, publish, or authorize deployment. +these checks never change approvals, sign, release, or authorize deployment. +Only the master EIF job receives OIDC permission for FlakeHub caching. PRs and +non-master manual runs use GitHub's branch-scoped cache without OIDC. Preserve +that separation and verify cache changes on fresh hosted runners, not just a +warm local Nix store. See the [cache policy](services/opensecret/docs/nitro-deploy.md#binary-caches-and-cold-run-validation). For Pages, read [the deployment guide](docs/pages-deployments.md). Preserve unprivileged preview builds and separate development/production profiles. diff --git a/docs/opensecret-import.md b/docs/opensecret-import.md index 67f2d2005..794ff67f1 100644 --- a/docs/opensecret-import.md +++ b/docs/opensecret-import.md @@ -47,13 +47,18 @@ checkout against a disposable database; it no longer fetches an external backend revision. This advances that lane from the former pinned commit `d26eb6bd54d50cc8e6b2967f647a94c61da913da` to the imported source. -PR jobs have read-only credentials and use hosted runners. Backend CI has no -EIF publisher, signing credentials, OIDC permission, or deployment step. PCR +PR jobs have read-only repository permission and use hosted runners, without +OIDC or signing/deployment credentials. Ordinary backend CI has no EIF +publisher or deployment step. PCR file changes retain their signature-validation lane. A separate ARM64 EIF workflow compares dev/prod measurements on PRs explicitly editing approved PCR JSON, relevant backend/TEE or approval changes to master, and manual runs. Ordinary backend PRs do not fail merely because approvals have not been updated; master mismatches are an intentional deployment-approval signal. +Only the master EIF job receives OIDC for FlakeHub Cache and also warms +GitHub's branch-scoped Nix cache for unprivileged PR/manual runs. Legacy +cross-organization cache access does not transfer automatically; follow the +[cache validation policy](../services/opensecret/docs/nitro-deploy.md#binary-caches-and-cold-run-validation). Backend-only changes do not select Research or Agent application packaging. The companion OpenSecret Workspaces change supports Maple-only compositions @@ -91,6 +96,6 @@ policy. Operator builds run from `services/opensecret/`; verify the selected checkout and commit on each deployment host rather than assuming a merged source change migrated that host. -GitHub does not sign PCR entries, publish EIFs, or deploy OpenSecret here. The +GitHub does not sign PCR entries, create EIF releases, or deploy OpenSecret here. The copy helper does not commit or push. Sigstore and the legacy compatibility sunset remain separate decisions. There is no automatic expiry of the legacy files. diff --git a/scripts/ci/test_opensecret_workflows.py b/scripts/ci/test_opensecret_workflows.py index 459299a60..6563e4944 100644 --- a/scripts/ci/test_opensecret_workflows.py +++ b/scripts/ci/test_opensecret_workflows.py @@ -1,4 +1,4 @@ -"""Exercise backend diff selection and enforce unprivileged CI boundaries.""" +"""Exercise backend diff selection and enforce scoped CI cache boundaries.""" import functools import json @@ -43,7 +43,7 @@ def test_nix_jobs_fetch_complete_backend_and_submodule_history(self): # fetcher cannot calculate revCount for a shallow recursive input. for workflow_name, job_names in ( ("opensecret-ci.yml", ("rust", "nix", "pcr")), - ("opensecret-eif.yml", ("eif",)), + ("opensecret-eif.yml", ("eif", "eif-trusted")), ("sdk-integration.yml", ("sdk-integration",)), ): for job_name in job_names: @@ -55,7 +55,7 @@ def test_nix_jobs_fetch_complete_backend_and_submodule_history(self): self.assertEqual(checkouts[0]["submodules"], "recursive") self.assertEqual(checkouts[0].get("fetch-depth"), 0) - def test_fork_jobs_are_hosted_read_only_and_credential_free(self): + def test_jobs_are_hosted_with_read_only_contents_and_scoped_oidc(self): for name in ("opensecret-ci.yml", "opensecret-eif.yml", "opensecret-change-detection.yml", "sdk-integration.yml"): config = workflow(name) @@ -64,10 +64,15 @@ def test_fork_jobs_are_hosted_read_only_and_credential_free(self): self.assertNotIn("pull_request_target", config["on"]) self.assertNotIn("workflow_run", config["on"]) for value in strings(config): - self.assertNotRegex(value, r"\bsecrets\b|github\.token|\bGH_TOKEN\b|\bid-token\b") - for job in config["jobs"].values(): + self.assertNotRegex(value, r"\bsecrets\b|github\.token|\bGH_TOKEN\b") + for job_name, job in config["jobs"].items(): self.assertNotIn("environment", job) - self.assertIn(job.get("permissions"), (None, {"contents": "read"})) + if (name, job_name) == ("opensecret-eif.yml", "eif-trusted"): + self.assertEqual(job["permissions"], {"contents": "read", "id-token": "write"}) + else: + self.assertIn(job.get("permissions"), (None, {"contents": "read"})) + for value in strings(job): + self.assertNotRegex(value, r"\bid-token\b|flakehub-cache-action") if "uses" in job: self.assertEqual(job["uses"], "./.github/workflows/opensecret-change-detection.yml") self.assertNotIn("secrets", job) @@ -101,7 +106,7 @@ def test_ordinary_backend_ci_does_not_publish_or_build_eifs(self): self.assertEqual(cache["save-if"], "${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}") - def test_eif_workflow_is_read_only_and_keeps_both_environments_independent(self): + def test_eif_workflow_preserves_approvals_and_keeps_both_environments_independent(self): config = workflow("opensecret-eif.yml") self.assertEqual(set(config["on"]), {"push", "pull_request", "workflow_dispatch"}) for event in ("push", "pull_request"): @@ -109,51 +114,95 @@ def test_eif_workflow_is_read_only_and_keeps_both_environments_independent(self) self.assertNotIn("paths", config["on"][event]) self.assertEqual(config["concurrency"]["group"], "opensecret-eif-${{ github.event_name }}-${{ github.ref }}") - job = config["jobs"]["eif"] - self.assertEqual(job["strategy"]["matrix"], {"mode": ["dev", "prod"]}) - self.assertIs(job["strategy"]["fail-fast"], False) - self.assertEqual(job["env"]["EIF_MODE"], "${{ matrix.mode }}") - self.assertEqual(job["timeout-minutes"], 90) - for key in ("OPENSECRET_DEV_POSTGRES", "OPENSECRET_DEV_ENV", "OPENSECRET_DEV_CONTAINERS"): - self.assertEqual(job["env"][key], "0") - commands = [step["run"] for step in job["steps"] if "run" in step] - self.assertEqual(commands, ['bash scripts/ci/check_opensecret_eif.sh "$EIF_MODE"']) + self.assertEqual(set(config["jobs"]), {"changes", "eif", "eif-trusted"}) + for job_name in ("eif", "eif-trusted"): + job = config["jobs"][job_name] + self.assertEqual(job["needs"], "changes") + self.assertEqual(job["strategy"]["matrix"], {"mode": ["dev", "prod"]}) + self.assertIs(job["strategy"]["fail-fast"], False) + self.assertEqual(job["env"]["EIF_MODE"], "${{ matrix.mode }}") + self.assertEqual(job["timeout-minutes"], 90) + for key in ("OPENSECRET_DEV_POSTGRES", "OPENSECRET_DEV_ENV", "OPENSECRET_DEV_CONTAINERS"): + self.assertEqual(job["env"][key], "0") + commands = [step["run"] for step in job["steps"] if "run" in step] + self.assertEqual(commands, ['bash scripts/ci/check_opensecret_eif.sh "$EIF_MODE"']) for value in strings(config["jobs"]): self.assertNotRegex(value, r"deploy-|stage-|scp-|update-pcr|append-pcr|generate-keys") - self.assertNotRegex(value, r"upload-artifact|download-artifact|flakehub-cache|gh release") + self.assertNotRegex(value, r"upload-artifact|download-artifact|gh release") + + def test_eif_cache_setup_precedes_build_and_warms_fork_compatible_cache(self): + jobs = workflow("opensecret-eif.yml")["jobs"] + installer_action = "DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25" + for job_name, cache_action, cache_inputs, determinate in ( + ("eif", "DeterminateSystems/magic-nix-cache-action@3c034b51a9deec0a09ef1df8b436ac5db50fae94", { + "source-revision": "4cc363589df8090801c098cdcde1bdd42562318a", + "use-flakehub": "disabled", + "use-gha-cache": "enabled", + }, False), + ("eif-trusted", "DeterminateSystems/flakehub-cache-action@1f9a51a2959d3e26c7838c6f3bf9f48acae525ea", { + "use-gha-cache": "enabled", + "diff-store": True, + }, True), + ): + with self.subTest(job=job_name): + steps = jobs[job_name]["steps"] + self.assertEqual(len(steps), 4) + self.assertTrue(steps[0]["uses"].startswith("actions/checkout@")) + self.assertEqual(steps[1]["uses"], installer_action) + self.assertEqual(steps[1]["with"], {"determinate": determinate, "github-token": ""}) + self.assertEqual(steps[2]["uses"], cache_action) + self.assertEqual(steps[2]["with"], cache_inputs) + self.assertNotIn("if", steps[2]) + self.assertNotIn("continue-on-error", steps[2]) + self.assertEqual(steps[3]["run"], 'bash scripts/ci/check_opensecret_eif.sh "$EIF_MODE"') def test_eif_event_gate_matches_the_approval_policy(self): # Exercise the actual GitHub boolean expression with a restricted, # equivalent local representation, not a separately implemented policy. - expression = workflow("opensecret-eif.yml")["jobs"]["eif"]["if"] - expression = expression.strip().removeprefix("${{").removesuffix("}}").strip() - expression = expression.replace("&&", " and ").replace("||", " or ") - expression = expression.replace("!cancelled()", "True").replace("always()", "True") - expression = " ".join(expression.split()) + expressions = {} + for job_name in ("eif", "eif-trusted"): + expression = workflow("opensecret-eif.yml")["jobs"][job_name]["if"] + expression = expression.strip().removeprefix("${{").removesuffix("}}").strip() + expression = expression.replace("&&", " and ").replace("||", " or ") + expression = expression.replace("!cancelled()", "NOT_CANCELLED").replace("always()", "True") + expressions[job_name] = " ".join(expression.split()) cases = ( ("pull_request", "refs/pull/1/merge", "success", "true", "false", False), ("pull_request", "refs/pull/1/merge", "success", "true", "true", True), ("pull_request", "refs/pull/1/merge", "failure", "", "", False), ("pull_request", "refs/pull/1/merge", "success", "", "", False), + ("pull_request", "refs/heads/master", "success", "true", "true", True), ("push", "refs/heads/master", "success", "true", "false", True), ("push", "refs/heads/master", "success", "false", "false", False), ("push", "refs/heads/master", "failure", "", "", True), ("push", "refs/heads/master", "success", "", "", True), ("push", "refs/heads/feature", "success", "true", "true", False), ("workflow_dispatch", "refs/heads/master", "success", "true", "false", True), + ("workflow_dispatch", "refs/heads/master", "failure", "", "", True), + ("workflow_dispatch", "refs/heads/feature", "success", "true", "false", True), + ("workflow_dispatch", "refs/tags/review", "success", "true", "false", True), + ("workflow_dispatch", "refs/tags/master", "failure", "", "", True), ("schedule", "refs/heads/master", "success", "true", "true", False), ) for event, ref, result, eif, approvals, expected in cases: with self.subTest(event=event, ref=ref, result=result, eif=eif, approvals=approvals): - condition = expression - for key, value in { - "github.event_name": event, "github.ref": ref, - "needs.changes.result": result, - "needs.changes.outputs.eif": eif, - "needs.changes.outputs.pcr_approvals": approvals, - }.items(): - condition = condition.replace(key, repr(value)) - self.assertEqual(eval(condition, {"__builtins__": {}}, {}), expected) + trusted = event in ("push", "workflow_dispatch") and ref == "refs/heads/master" + for cancelled in (False, True): + selected = [] + for job_name, expression in expressions.items(): + condition = expression + for key, value in { + "github.event_name": event, "github.ref": ref, + "needs.changes.result": result, + "needs.changes.outputs.eif": eif, + "needs.changes.outputs.pcr_approvals": approvals, + "NOT_CANCELLED": not cancelled, + }.items(): + condition = condition.replace(key, repr(value)) + if eval(condition, {"__builtins__": {}}, {}): + selected.append(job_name) + expected_jobs = ["eif-trusted" if trusted else "eif"] if expected and not cancelled else [] + self.assertEqual(selected, expected_jobs) def test_backend_retains_exact_rust_gates_and_disabled_stateful_shell_hooks(self): config = workflow("opensecret-ci.yml") diff --git a/services/opensecret/AGENTS.md b/services/opensecret/AGENTS.md index fa38fe77a..4c4956dcc 100644 --- a/services/opensecret/AGENTS.md +++ b/services/opensecret/AGENTS.md @@ -149,7 +149,11 @@ measurements only when a PR explicitly edits one of the four approved PCR JSON files, on relevant backend/TEE or approval changes to master, or on a manual run. An ordinary backend PR does not require updated PCR approvals. A master mismatch deliberately reports that the revision does not match current -approvals. GitHub Actions never signs or publishes EIFs or deploys the service. +approvals. GitHub Actions never signs approvals, creates EIF releases, or deploys +the service. Nix cache writes are not approval or deployment: only the master +EIF job has FlakeHub OIDC permission, while PRs and other manual refs use the +branch-scoped GitHub cache without OIDC. Follow the +[cache validation policy](docs/nitro-deploy.md#binary-caches-and-cold-run-validation). Do not update PCR references as part of ordinary pull-request work. Treat an EIF build failure separately from PCR mismatch. diff --git a/services/opensecret/docs/nitro-deploy.md b/services/opensecret/docs/nitro-deploy.md index 7dcfbf672..554fb26db 100644 --- a/services/opensecret/docs/nitro-deploy.md +++ b/services/opensecret/docs/nitro-deploy.md @@ -2,9 +2,9 @@ This operator runbook remains manual. Run repository-local build and `just` commands from `services/opensecret/` in the Maple monorepo using its pinned Nix -flake. The root EIF approval workflow performs read-only dev/prod builds and -measurement comparisons under the policy below; it never signs, publishes, -or deploys this service. For authorized signed-PCR updates and legacy client +flake. The root EIF approval workflow builds dev/prod and compares measurements +without updating approvals; binary caching is separate from signing, release +publication, and deployment. For authorized signed-PCR updates and legacy client compatibility, follow [the PCR publication procedure](pcr-compatibility.md). ## CI approval checks @@ -28,6 +28,35 @@ running enclave, and neither authorizes deployment. Builds use normal Nix cache semantics; this is measurement parity, not a forced independent rebuild. CI never updates references or handles signing keys. +### Binary caches and cold-run validation + +The master push/manual job installs Determinate Nix and uses FlakeHub Cache +with job-scoped `id-token: write`. It also explicitly enables the GitHub cache +and `diff-store: true`, so paths fetched from FlakeHub, not just locally built +paths, warm the default-branch cache. The cache action's post step can run after +an expected PCR mismatch; the comparison still fails and approvals stay unchanged. + +PRs, including forks, and manual runs on other refs have no OIDC permission. +They use the pinned Magic Nix Cache action with FlakeHub disabled and GitHub +caching enabled. GitHub permits default/base-branch cache reads; PR cache +writes are confined to the PR merge ref and cannot populate master's cache. +Do not use `pull_request_target`, pass cache secrets to PRs, or change the +checkout to trusted master while claiming to check a PR's source. + +FlakeHub access is repository/organization-scoped, and fork PRs cannot +authenticate to it. Restoring the action does not grant Maple access to +`OpenSecretCloud/opensecret` cache entries. A new identity or an empty/evicted +GitHub cache may need operator-approved access or a trusted cache-warming run. +Never assume the old cache's visibility transferred with the source import. + +After a cache change, inspect a fresh hosted ARM64 run for successful cache +setup, actual substitution of the expected custom kernel store path, build +duration, and the eventual measurement comparison. Then verify that an +unprivileged run can reuse the warmed GitHub cache. A warm local store, a +skipped PR EIF job, or passing workflow unit tests does not prove this. +Diagnose missing cache access separately from PCR mismatch; do not conceal +it by increasing the timeout or changing measured kernel/build inputs. + ## Log into AWS CLI This should be after creating an IAM profile with admin access.