Repository navigation
Represent and verify the agent-held fleet credential, so the runner route can carry trust facts - #8332
Merged
Conversation
… declaring it `FleetSshCredentialBinding` names which credential an SSH leg authenticated with. On the identity-file route nothing established that name: the mint took `identity_file` and `public_material` as two independent parameters and hashed the second, so `spark_credential_workflow` fetched Secret Manager versions/1 into a file and then asserted the file held `fleet_automation_ssh_key.material` without ever reading it. A rotated secret, a wrong pinned version, a truncated fetch or a bad materialization all produce the same binding and the same receipt -- the check was satisfiable by editing the declaration while the realization lied. The corpus already knew the question: `gunbc.ci_spec` `gunbc_ci_fleet_key_agent_script` derives the public key with `ssh-keygen -y` and compares its fingerprint, in hand-authored GitHub Actions shell, on the OTHER route. Two answers to one question and one of them unfounded. `gunbc.fleet_ssh_credential_verify` becomes the single authority. Observation and judgment are separated: `classify_derived_public_key_line` is pure and total over the process observation, so every arm is reachable hermetically, and the effectful half only runs the command. The mint is admitted to the classifier alone and the unverified route is deleted, so a binding cannot exist without a derivation that produced the material being hashed. Fail-closed by consequence rather than by special case: a zero exit with empty or unexpected output is a mismatch, because the empty string neither equals the expected line nor carries it as a prefix. The trailing-space form admits ssh-keygen's optional comment while refusing a longer material that merely carries the expected one as a prefix. The hermetic fixture now goes THROUGH the classifier rather than around it -- there is no other mint, so it must present a synthetic derivation that matches. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…oute can carry trust facts The identity-file route was the half that asserted its credential; the agent route had the same defect in a second place and one more besides. `fleet_multi_principal_probe` `modeled_fleet_automation_credential_identity` hashed the DECLARED material and stamped it onto every `PrincipalReachObservation`, so the receipt's `credential_identity` named a key nothing had observed -- and the probe's legs run over `typed_argv_probe_over_ssh` with no anchor, no credential and no posture at all. The root cause of the second half is a representation gap: `FleetSshCredentialBinding` required an `identity_file`, an agent-held key has none, so the CI route could not build one and `SparkServingSliceRunnerAgent` fell back to a bare `SshShell`. WHERE the private half lives becomes a `FleetSshCredentialLocus` coproduct (identity-file | agent-held) carried INSIDE the still-sealed binding record. The seal does not move onto the coproduct deliberately: `sole_constructor` seals a record, so making the binding itself a coproduct would have made every arm publicly constructible and dropped the wall. A forged locus is inert; a forged binding would be a credential claim nobody observed. The agent arm is verified against the agent's own listing: `ssh-add -l` on an explicitly named socket, matched against `fleet_automation_ssh_key_fingerprint` -- the same authority the hand-authored CI shell already compares against, so the model and that fragment cannot disagree about which key is expected. The membership test is space-delimited rather than a bare substring, so a fingerprint carrying the declared one as a prefix does not match. Exit 1 (no identities) and exit 2 (cannot connect) both refuse, and an empty listing on a zero exit is a mismatch by the same construction as the identity-file arm. `openssh_exact_agent_credential_options` is the agent posture. `IdentityFile` is not redundant there and does not name a private key: with `IdentitiesOnly=yes` it is what selects one identity out of an agent that may hold several, which is what keeps a success evidence about the modeled key. Options are derived from the locus, so an agent binding cannot be paired with identity-file options or the reverse, and the receipt names where the credential was held rather than leaving a reader to infer it from which function was called. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 16, 2026
Merged
…-335-agent # Conflicts: # dag/gunbc/fleet_known_hosts_anchor.dag # dag/gunbc/fleet_ssh_credential_verify.dag # dag/test/claim/fleet_ssh_credential_verify_witness_test.dag
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #8329 (base is
session/bright-ferret-335-cred). #8329 closes the identity-file half; this closes the agent half and the representation gap underneath it. Merge #8329 first and this retargets tomain.The defect, in two parts
Part one is the same fail-open in a second place.
gunbc.fleet_multi_principal_probemodeled_fleet_automation_credential_identityhashed the declared material and stamped it onto everyPrincipalReachObservation, so the receipt'scredential_identity— the field whose entire job is to say which key authenticated — named a key nothing had observed. #8329 closed exactly this on the identity-file route; it was open here too.Part two is why the CI route had no trust facts at all.
FleetSshCredentialBindingrequired anidentity_file. An agent-held key has none. So the runner route could not construct a binding, could not construct aFleetSshExecutionContext, andSparkServingSliceRunnerAgentfell back to a bareSshShell { ssh_host: "user@host" }— no anchor, no credential, no posture. The missing representation is what forced the bypass.The change
FleetSshCredentialLocus— where the private half lives — becomes a coproduct (FleetSshIdentityFileHeld|FleetSshAgentHeld) carried inside the still-sealed binding record.The seal deliberately does not move onto the coproduct.
sole_constructorseals a record, so makingFleetSshCredentialBindingitself a coproduct would have made every arm publicly constructible and dropped the wall this module exists to hold. A forged locus is inert — it names a socket and a file. A forged binding would be a credential claim nobody observed.The agent arm is verified against the agent's own listing.
ssh-add -lagainst an explicitly named socket (not inheritedSSH_AUTH_SOCK— which agent answered is a modeled fact), matched againstfleet_automation_ssh_key_fingerprint. That is the same authority the hand-authored CI shell already compares against, so the model and that fragment cannot disagree about which key is expected.The membership test is space-delimited rather than a bare substring, so a fingerprint carrying the declared one as a prefix does not match. Exit 1 (the agent has no identities) and exit 2 (cannot connect) both refuse; an empty listing on a zero exit is a mismatch by the same construction as the identity-file arm — the empty string does not contain the fingerprint.
openssh_exact_agent_credential_optionsis the agent posture.IdentityFilethere is not redundant and does not name a private key: withIdentitiesOnly=yesit is what selects one identity out of an agent that may hold several. Without a selector, OpenSSH falls back to default identity files; withoutIdentitiesOnly, it offers every key the agent holds. In both cases a success stops being evidence about the modeled key, which is the property these options exist to preserve.Options are derived from the locus, so an agent binding cannot be paired with identity-file options or the reverse, and the receipt names where the credential was held rather than leaving a reader to infer it from which function was called.
Evidence
Mutation control. Neutering the fingerprint membership test to a vacuous containment, in an isolated detached worktree at this commit:
Exactly the three claims that depend on the membership test, and nothing else.
What this does NOT do
Stated because the tempting summary is "the agent route is now exact", and it is not yet.
fleet_multi_principal_probe's legs still run overtyped_argv_probe_over_ssh, which carries no anchor and no options. What this PR changes for that probe is that itscredential_identityis no longer fabricated — the run refuses outright unless the named agent is observed to hold the declared key — and that the binding needed to route those legs throughshape_fleet_ssh_execnow exists. Routing them is the next step, not this one.SparkServingSliceRunnerAgentis likewise still present and still produces a bareSshShell. It is now deletable rather than deleted: with an agent binding representable, both loci can becomeFleetSshExecutionContextvalues, and that arm becomes deletion population. That cutover also needs the runner job to materialize a known-hosts anchor, which isci_specwork, so it is its own change.Rung
Structurally guaranteed at the mint, on both arms:
sole_constructorplusadmit_callersleaves exactly one path to a binding per locus, and each runs its comparison first.Outside the modeled guarantee, unchanged from #8329: that the key verified is the key that authenticated. The agent is observed to hold the fingerprint at verification time; OpenSSH then asks the same socket for a signature. An agent mutated between the two is not something
ssh-add -lcan close.No dissolution obligations added.
— sent from bright-ferret-335