Skip to content

fix: fail closed on untrusted native releases - #115

Merged
abrichr merged 10 commits into
mainfrom
codex/desktop-production-release-gates
Aug 19, 2026
Merged

fix: fail closed on untrusted native releases#115
abrichr merged 10 commits into
mainfrom
codex/desktop-production-release-gates

Conversation

@abrichr

@abrichr abrichr commented Aug 18, 2026

Copy link
Copy Markdown
Member

Summary

  • require the native tag tree to equal the deterministic five-file version transform from the matching engine tag
  • replace direct writes to main with a protected version-sync pull request; the post-merge job only creates the immutable desktop-vX.Y.Z tag
  • reject a native version pull request unless it starts at current main, strictly advances the version, and contains only the exact five-file transform
  • reject the native tag at promotion time if it does not advance every published native release
  • bind each native build to the exact stable engine tag, commit, GitHub release ID, and release URL
  • define a closed engine-release receipt that requires the exact protected-main release workflow commit, released source commit, wheel hash, and sdist hash
  • bind native provenance to the exact GitHub-hosted workflow, native tag commit, run ID, and run attempt
  • define a closed, strictly monotonic stable channel descriptor that binds the selected index and SHA256SUMS by SHA-256
  • authenticate the stable descriptor, selected index, and SHA256SUMS against their exact GitHub workflow certificate identities before installer verification
  • verify the complete descriptor-to-index-to-checksum-to-asset chain and detect local file replacement during attestation verification
  • retain optional prior-channel and minimum-version checks for consumer rollback protection
  • require the configured Apple Developer ID identity and Team ID on the DMG and installed app
  • require valid timestamped Authenticode on each Windows installer, installed executable, and NSIS uninstaller

Approval-held target-state work

This pull request remains a draft. It does not yet move native build, signing,
publication, mirror, or channel-pointer mutation into a protected-main
workflow_dispatch transaction. The existing tag and release-event workflow
must not publish a release. The privileged workflow rewrite needs explicit
founder approval after its write-authority and publication risk is reviewed.

The Desktop Cloud Connector replacement and its shared qualification-v2
terminal admission are also approval-held in draft PR #116. No release can
occur until Desktop consumes the frozen shared verifier and accepts only an
exact signed VERIFIED result that binds the permit, run sequence, policy,
identity, and effect proof.

Runtime dependency state

This branch updates the frozen runtime from Flow 1.27.1 and Types 0.7.0 to the published Flow 1.31.0 and Types 0.10.0 set. Flow 1.31.0 includes the qualified-bundle sanitization fix from Flow commit faf9945537d4011baeb36ce5f063b6e1814903e6 and PR OpenAdaptAI/openadapt-flow#351.

This is an interim exact pin. It is not the final hosted-operation release pin. Do not release the native application until the later immutable Flow release supplies the complete authorization and typed push-result contract, Cloud deploys and accepts that exact runtime, and Desktop updates its pin and lock from that accepted evidence.

External activation required

The workflow is not the complete trust boundary. Before the next native tag or release:

  1. Add a no-bypass pull-request ruleset for main. Require exact-head checks and require branches to be up to date before merge.
  2. Require the Reject a stale native version pull request check for native-version/v* pull requests.
  3. Add an immutable tag ruleset for v*. Only the engine release identity can create a tag. Do not permit update or deletion.
  4. Add an immutable tag ruleset for desktop-v*. Only the native freshness identity can create a tag. Do not permit update or deletion.
  5. Configure all six Apple secrets in the reviewed native-release environment:
    • APPLE_CERTIFICATE
    • APPLE_CERTIFICATE_PASSWORD
    • APPLE_SIGNING_IDENTITY
    • APPLE_ID
    • APPLE_PASSWORD
    • APPLE_TEAM_ID
  6. Configure exactly one Windows method. The recommended Azure Trusted Signing method uses:
    • AZURE_TENANT_ID
    • AZURE_CLIENT_ID
    • AZURE_CLIENT_SECRET
    • TRUSTED_SIGNING_ENDPOINT
    • TRUSTED_SIGNING_ACCOUNT
    • TRUSTED_SIGNING_CERTIFICATE_PROFILE
  7. The alternative importable PFX method uses:
    • WINDOWS_CERTIFICATE
    • WINDOWS_CERTIFICATE_PASSWORD
    • WINDOWS_CERTIFICATE_THUMBPRINT

Do not configure both Windows methods. Linux needs no founder-managed key. GitHub gives the pinned release workflow a short-lived OIDC identity.

Exact-head local evidence

Head: 997da51

  • Python: 892 passed, 6 skipped
  • focused release contract: 65 passed
  • Ruff: passed
  • Python format check: passed
  • Git diff check: passed
  • GitHub exact-head CI: all executed checks passed

The full Python suite needed local loopback socket access. Its first sandboxed attempt failed only because the sandbox denied that socket bind. The full rerun with loopback access passed.

Release operation

Keep this PR as a draft. Do not merge it or publish a native release until the approval-held workflow changes, repository rules, signing identities, final Flow pin, Cloud acceptance, and live release verification are complete.

Historical desktop-v0.15.0 assets keep their original ad-hoc or unsigned labels. They do not satisfy this trust contract.

abrichr and others added 8 commits August 18, 2026 13:59
This branch moves the frozen runtime to the newest published Flow so the tree
builds and tests against it. That pin is not releasable: the immutable 1.31.0
wheel predates the `openadapt.push-result/v1` contract the governed push path
requires, so an installer built on it can only fail closed on every hosted
handoff.

Record the hold where a release operator will see it, name the exact ordered
exit conditions, and require the section to be deleted in the same pull request
that lands the releasable pin. Also state plainly that the hold is a
distribution hold and not a production-acceptance claim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…y run

Every `gh attestation verify` invocation this branch adds passed both
`--signer-workflow` and `--cert-identity`. Those two flags, with
`--signer-repo` and `--cert-identity-regex`, form one mutually exclusive group:
`gh` refuses the command before it verifies anything.

    if any flags in the group [cert-identity cert-identity-regex signer-repo
    signer-workflow] are set none of the others can be

So the release workflow could never complete its own attest step, the mirror
job could never authenticate the verified index, and the public download
verifier always exited 2. The behaviour was fail-closed, not fail-open, but the
whole documented verification path was unreachable. The unit tests could not
see it because they monkeypatch `subprocess.run` to return success.

Keep only `--cert-identity`. It is the strictest of the four: it pins the
complete subject alternative name -- repository, workflow path, and ref --
rather than the repository and workflow path alone. Pin the OIDC issuer
explicitly beside it so `GH_HOST` cannot redirect the lookup.

Three further corrections in the same path:

- The verified index is attested by `mirror-installers-to-engine-release`,
  which runs on the `release: published` event for the native tag. GitHub
  therefore issues its certificate with `refs/tags/desktop-vX.Y.Z` as the
  workflow ref. Both the workflow's own check and the download verifier
  demanded `refs/heads/main`, which that certificate can never carry.
- `SHA256SUMS` was authenticated by digest and then read from disk twice more,
  once to compare against the index and once inside `verify()`. A local writer
  could swap it in that window and every installer would be checked against
  attacker-chosen digests. The authenticated bytes now flow through
  `validate_channel`, `validate_index`, `read_manifest`, and `verify`, so each
  document is parsed from exactly the bytes its check hashed. The prior-channel
  descriptor is read once for the same reason.
- `docs/CODE_SIGNING.md` and `docs/BETA_NATIVE_INSTALLERS.md` gave users the
  same unrunnable command to copy. Both now show the corrected one.

New guards, since the old `--signer-workflow` assertion encoded the broken
shape: `test_release_workflow_attestation_checks_use_one_exact_identity_flag`
reads the workflow text and refuses any second identity flag, and
`test_public_download_verifier_uses_the_authenticated_checksum_bytes` swaps
`SHA256SUMS` after authentication and requires the result to be unaffected.
That test fails against the previous code.

Tests: full suite 1026 passed. Ruff passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@abrichr
abrichr marked this pull request as ready for review August 19, 2026 22:40
@abrichr
abrichr merged commit 6f43a97 into main Aug 19, 2026
17 checks passed
@abrichr
abrichr deleted the codex/desktop-production-release-gates branch August 19, 2026 22:40
abrichr added a commit that referenced this pull request Aug 19, 2026
#115 hardened `verify_checksums` to accept only a manifest named exactly
`SHA256SUMS`, but the CI build lane still wrote
`SHA256SUMS-${platform}-${architecture}`. All four native installer jobs on
`main` then failed:

    error: SHA256SUMS must be inside the release asset directory

The native installer matrix is skipped on pull requests and runs on `main`
pushes, so the exact-head checks on #115 could not see it.

Write and verify `dist/native/SHA256SUMS`. Each matrix job owns its own
`dist/native` directory and uploads under a distinct artifact name, so the
plain name is unambiguous and nothing consumed the per-platform name. The
hardened check is unchanged.

New `test_every_workflow_checksum_manifest_uses_the_exact_name` reads every
workflow and refuses a `--output` or `--manifest` value that mentions
SHA256SUMS without ending in it, so this cannot regress silently again. It
fails against the previous build.yml.

Tests: full suite 1028 passed. Ruff passed.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
abrichr added a commit that referenced this pull request Aug 20, 2026
Three gaps that PR #115 deliberately deferred, plus three adjacent ones.

Branch-name bypass of the version advance guard. The guard gated its only
job on `startsWith(github.head_ref, 'native-version/v')`. The head branch
name is author controlled and GitHub counts a skipped job as a satisfied
required status check, so a version bump from any other branch skipped the
guard entirely. The job now always runs for the five paths.

It cannot simply demand the reserved branch, because Dependabot npm/cargo
updates and ordinary feature work edit the same files for dependency
reasons. It decides on content instead: when the pull request leaves the
native version alone it passes; when the pull request moves the version,
only the reserved `native-version/v*` branch of this repository may do it,
from the current protected base, as the exact strict advance. The job also
checks out the base rather than the head, so a pull request cannot rewrite
the guard that judges it.

Fail-open monotonicity check. `write_verified_release_index` and
`write_verified_release_channel` skipped the strict-advance comparison when
`--existing` named an absent file, and the release workflow made that
reachable by ending its download with `|| true`. A passed but missing
`--existing` is now an error, and the workflow decides from the release's
authenticated asset list instead of from the absence of a downloaded file.

Release-order comparison set filtered by mutable metadata. The set admitted
a release only when `draft` was false, `prerelease` was true, and the body
carried the installer marker. All three are mutable, and this repository
rewrites release notes itself, so a release that lost the marker dropped out
and a lower tag became acceptable. The comparison set is now the immutable
`desktop-v*` Git tag namespace. That set cannot observe draft state, which
the old set refused as a side effect, so `publish-draft` now refuses
explicitly to return an already public release to draft.

Also:

- `verify()` proved each member regular and unlinked, then hashed
  `(directory / name).read_bytes()`, which resolved the name again and
  followed a link created in between. It now hashes the validated path
  objects, and every read in the standalone verifier opens with `O_NOFOLLOW`
  where the platform has it.
- `_git_bytes` and the `git diff` call interpolated a caller ref into an
  argv element, so a ref beginning with `-` reached Git as an option. Every
  ref now resolves through `git rev-parse --verify` to a 40-character object
  id first, and the diff argument list ends with `--`.
- `expected_asset_names` in the standalone verifier duplicates
  `expected_release_asset_names`. The verifier ships beside the installers
  and may not import the module, so a new contract test fails when the two
  definitions drift apart.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant