Skip to content

Build a push under an automatic deploy rule on an enforcing Cluster (D3) - #4036

Merged
nick-potts merged 10 commits into
mainfrom
t3/sec04-d3-push-build
Nov 10, 2026
Merged

nick-potts merged 10 commits into
mainfrom
t3/sec04-d3-push-build

Conversation

@nick-potts

@nick-potts nick-potts commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Why

On an enforcing Cluster, a push to a rule's approved branch must build without any trust in Ployz Cloud. Cloud must not pick the commit, the inputs, the builder, the runner or the holder, read build secrets, or feed the build its own values. This is slice D3 of docs/agents/plans/security-0.4/d-plan.md: the Engine half and the builder. The image lands on the grant Machine and is not deployed; deploying is D4.

Summary

sequenceDiagram
    participant R as Runner (pinned builder)
    participant G as GitHub
    participant C as Cloud (untrusted)
    participant M as Grant Machine
    R->>R: check out github.sha or the commit input, make holder K and recipient R keys
    R->>C: check-in (OIDC, aud = Cloud origin)
    C-->>R: signed rule, Cluster key, attest key A, Service, deployment
    R->>R: freeze deployment once (one snapshot per Service); its build input == rule's policy, or stop
    R->>G: token, aud = BuildClaim::Grant{holder K, recipient R}
    R->>C: grant ask {proof, holder, recipient, commit, authority Head}
    C->>M: MintBuildGrant{rule, proof, Sealed record, reader token}
    M->>M: signature, Cluster, Machine, ended, Service, holder, claims, aud, head now, SealedDigest, jti once
    M-->>R: (over iroh, held by K) secrets HPKE-auth-sealed from A to R, info = audience
    R->>R: open only with A, else stop; build and push
    R->>G: token, aud = BuildClaim::Built{image, attest A}
    R->>C: final report + image token
Loading

Admission order, before anything opens (core/crates/ployzd/src/machine/local_machine/rule_grant.rs):

admit_rule_grant
  verify_signature(rule)            Device keys this Machine trusts
  rule.cluster == held Sealing Key; this Machine in rule.machines
  mark_rule                         generation mark raised durably, any signed generation; lower refused
  not ended; Service in rule
  IssuerKeys.check(proof)           GitHub-signed, claims vs rule (sha checked before ref)
  aud == BuildClaim::Grant{holder, recipient}.audience()
  Head: commit == sha == branch head read now
  SealedDigest(record) == rule's secrets
  consume_rule_proof                jti once, generation high-water mark
 core/crates/
   ployz-core/  rpc: MintBuildGrantRequest.rule: Option<RuleGrant>, BUILD_GRANT_RULE_CAPABILITY
+               sealed_record: X25519Secret, seal_for_build / open_build (HPKE auth mode)
+               authority/rule: BUILDER_SHA, Builder::pinned(); claim table checks job_workflow_sha before job_workflow_ref
   ployzd/      sealing_key: attest key, HKDF-SHA256 info "ployz build-secrets attest v1"; DescribeContract.attest
+               rule_grant.rs: admission above; every refusal logged with its field or claim
               management/build_grant: a rule grant serves its record sealed only to its recipient
   ployz/       handlers/build --cloud: the rule mode (rule.rs); policy checked on the prepared input it then captures
+               sdk/preparation: prepare() / Prepared::capture(); a Service in two snapshots is refused
+               Actions cache scope = SHA-256("ployz build cache v1", PolicyDigest, attest key the secrets opened with)
               connect/build_grant: sealed_for_build refuses any other sender
   ployz-build/ Request.cache_scope

Builder: getployz/build#8 (draft) adds .github/workflows/build.yml. BUILDER_SHA pins that PR's head, 5f35eaa8. If the operator merges it with a merge commit or rebase, that commit stays the pin. A squash merge makes a new commit, and the pin moves to it. Before it merges, the builder's PLOYZ_URL and PLOYZ_SHA256 move from the lab build to the first release that carries ployz build --cloud.

Evidence

Unit tests: cargo nextest run -p ployzd -E 'test(rule) or test(build_grant) or test(oidc)' (65 passed), -p ployz -E 'test(rule) or test(build_grant)', -p ployz-core rule|sealed, -p ployz-build, and the verify-build-handoff model check (18 passed, walk unchanged).

  • Before: an enforcing Machine refused every Operator MintBuildGrant (D1).
    After: a moved head is refused before the Sealed record opens: a_rule_mint_refuses_a_moved_head_before_the_sealed_record_opens. A foreign record with the right head reaches the open and fails there as Conflict, so the order is observable.
  • Before (review, P1): Cloud appended a second web snapshot with Railpack and its own command. The runner checked the first, built the last.
    After: a_second_snapshot_of_the_service_is_refused_before_any_token (fails with the guard removed) and a_deployment_that_swaps_the_recipe_is_refused_before_any_token.
  • Before (review, P2): signed generation 4 with ended: true, then generation 3, admitted 3.
    After: an_ended_generation_refuses_every_generation_below_it refuses 3 naming the generation (fails with mark_rule removed).
  • Before (lane 11 b): a run fed Cloud's own attest key read and wrote the honest cache scope; BuildKit leaves secret values out of cache keys.
    After: a_build_fed_another_attest_key_never_writes_the_clusters_cache_scope. Only the Machine holds the attest key, and it seals (even an empty set) only after admitting the grant, so every build in the Cluster's scope is an admitted one built with the Cluster's own values.

Live lanes ran on GitHub (Ployz-test/lab-service, the builder from a lab fork) against a Boat Cluster with Device enforcement on and a Device-signed rule. A harness stood in for Cloud and relayed MintBuildGrant as Cloud's SDK does, honestly or as an attacker.

Lane Result
1 legacy dispatch not run: needs a real Cloud dispatch
2 push pass. Image landed. Machine: admitted a rule build's grant rule=baf9c7db… generation=1 service=web commit=085ef9cb…. Rerun on the final daemon: pass
3 holder swap pass. Refused, "names an audience". Nothing served
4 policy swap pass. Refused on the audience
5 rerun after a newer push pass. Refused, "names a commit"
6 replay pass. Second relay refused, "this GitHub token already minted a Build Grant"
7 branch attacker pass. "the GitHub token's ref claim does not match the rule"
8 builder fork commit pass. "the GitHub token's job_workflow_sha claim does not match the rule"
9 Actions cache pass at 3146b97 (scope was the policy digest). other wrote it; main's build ran its RUN step (no hit), then a second main build hit main's own entry (CACHED). The scope now also binds the attest key (unit-tested, not rerun live)
10 build secret pass. The build read it. The marker count was 0 in the runner log, the Cloud stand-in's log and the Machine journal
11 endpoint pass at 3146b97 (separate agent). (a) Real attest key, attacker endpoint: runner refused, "the build secrets did not come from this Cluster's attest key" (run). (b) Attacker's own key: the build completed and the image token's audience names the attacker's key, which D4 refuses (run). (b) also showed the cache crossing fixed above
12 commit input pass. Refused, "names a commit". No admitted line, no secret file

Perf: grant admission took 0.13 to 1.8 s per mint, including GitHub's head read. A push took 20 to 25 s to reach the final report. The trunk side of the metric was not measured.

Deviations from d-build-proof

  • The rule rides in one field, rule: Option<RuleGrant> {rule, proof, service, commit, authority, recipient, reader}. The plan has two fields, rule and proof.
  • The attest key reaches Cloud through DescribeContract.attest. Cloud relays it at check-in.
  • The runner derives the build policy from Cloud's deployment, frozen once, and requires it to equal the rule's policy. The same frozen input is what it captures and builds. A Cloud that changes inputs is caught here or at the audience.
  • The Actions cache stays on for rule builds (decision 7), scoped by the policy and the attest key.
  • The runner sends one final report. It carries imageToken and replaces live step streaming.
  • No Cloud path for rule builds in this PR (SDK github_build.rs, Store github_authorize, the dashboard routes and the template). No slice before D4 stores a signed rule in Cloud, so check-in has nothing to serve. D4's github rule approve is the first producer. The runner's wire contract is pinned in handlers/build/rule.rs, and the lab harness implements it.
  • .github/workflows/ployz-build.yml is unchanged. The new file runs on push, and in this repo that starts a failing run on every push to main. The customer copy lives in the builder PR.
  • The lab harness called the Machine with Full scope (the standalone CLI's slot), not Operator. The rule checks run either way; the no-rule refusal for Operator is D1's and is covered by unit tests.

Bets

  • 6. Machine-local authority. The grant Machine checks every claim itself, reads the branch head from GitHub, and seals the secrets from a key only the Cluster holds.
  • 7. Cloud drives, never owns. Cloud relays the rule, token and record and can only drop or delay them.
  • 9. Lean on proven primitives. GitHub OIDC, HPKE auth mode and HKDF; no new crypto.
  • 1, stable promise. MintBuildGrantRequest and ContractDescription gain optional fields, so old peers ignore them.

Residual, under the Cloud security bar: a compromised Cloud can drop, delay or refuse to relay a rule build. It cannot change what gets built.

Merge Danger

Door: two-way

Blast Radius: contained. A rule is used only when a request carries one, and nothing in Cloud sends one yet. Unenforced and legacy builds take the same path as before.

@coderabbitai

coderabbitai Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 631132a9-2c7a-4585-a7a7-3dea507563db

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@nick-potts
nick-potts marked this pull request as ready for review November 9, 2026 15:08
@nick-potts
nick-potts added this pull request to the merge queue Nov 10, 2026
Merged via the queue into main with commit 00859a3 Nov 10, 2026
21 checks passed
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