feat(credential): credentials as targets - #455
Merged
Merged
Conversation
A credential becomes a declared target — an identity a build needs, an ordered chain of ways to obtain it in whatever environment the build happens to be running in, and the shape it is presented in. The consumer says one word. It never knows which identity it got, and the cache key never knows there was one. Before this heph had no concept of a credential, so eight places each invented a partial one: `pass_env` values hashed into a def (a session token in a durable cache key, printed by `inspect def`), an OCI registry client silently degrading to anonymous, `docker_build` secrets wired to an `env=` source that resolves against a cleared environment, `http_fetch` with no auth at all, private Go modules with no credential path anywhere, and a devenv runner capturing its whole environment into a cached, remotely-shippable artifact. The contract, which everything else follows from: A credential grants ACCESS; it is not an INPUT. A target's outputs must be identical whichever identity satisfied its credential requirement. A target whose output depends on WHO ran it is not cacheable, and says so with `cache = False`. The half of that heph can enforce, it enforces structurally: a reference is an `Input` with `hashed: false, runtime: false`, and `hashin` is computed over `hashed` inputs only, so nothing about a credential has a path into a cache key in either direction. The half it cannot — that a presentation carries material and handles but never anything that selects content — is a stated convention, backed by `heph auth explain` and by `cache = False`, not by the parser. That distinction is written down in the parser, in the docs, and in a test, because it was overstated in the first draft (see the hermeticity note below). What ships: - The `credential` driver: `sources`, `present`, `ttl`. Declares, never executes, never cached — the `scratch` shape, pointed at identity. - Five inline source kinds (`env`, `file`, `exec`, `passthrough`, `oidc`) plus a bare address, which is a producer target or a delegation depending on its driver. No per-product source kind and no source preset: that list has no end, and a secret manager is `exec` with a field map or a target. - Three presentations — `env`, `files`, and a `helper` speaking the AWS credential-process, GCP executable-sourced, Docker, git and Kubernetes ExecCredential protocols. Each is a third-party grammar heph must produce byte-exactly, so each has a pinned conformance test. - `heph.auth.*` presentation presets from a builtin `auth` provider, returning plain dicts an author could have written. - Per-process (single-flighted, expiry-aware) and cross-process (`<home>/auth`, 0700/0600) acquisition caches, with a cross-process lock so two invocations do not both open a browser. - Redaction at the **output tee**, before any byte reaches `log.txt` — which is packed into the cache and lifted into the failure event, so redacting at render is too late. - `heph auth status | explain | login | logout`, and a hidden `heph __auth-helper` the callback protocols invoke. Two orderings are load-bearing and are asserted rather than described: acquisition sits after the cache decision (a hit acquires nothing), and strictly downstream of runner preparation (the devenv `wrap` runner's environment capture is a cached, remotely-shippable artifact). Review board ------------ hermeticity returned NOT HERMETIC on two findings. One is fixed: a helper callback runs inside the target's sandbox and re-walked the chain there, so it could silently pick a different source — and therefore a different identity — than the host chose. The host now writes a pin (material, workspace, winning source index) beside the presented files; the common path is a file read, and a refresh may use only the pinned source. The other is OVERRULED, deliberately: a literal `present.env` value can carry something that selects content (`AWS_REGION`), and nothing folds it into a consumer's key. This is the residual risk the design settled as the user's call — making it structural would cost CI its remote sharing on every private fetch and rebuild a workspace on a role rename. It needs a cacheable target whose output depends on the identity, which the contract already forbids. The overstated "nowhere to put a region" claim is corrected in the docs and in the parser, and the accepted behaviour is pinned by a test so the boundary between enforced and conventional is visible where someone will find it. code-quality and feature-quality both returned BLOCKED; every blocker and major is fixed, including: the recursion guard compared resolution keys against addresses so a self-referencing credential deadlocked instead of failing; the process cache was keyed on the address alone, dropping the root identity the disk key is careful to include; the redactor held back `len(longest secret) - 1` bytes unconditionally, which blanks an interactive `--shell` prompt; the acquire subprocess got an empty environment, so the documented `aws` source could not resolve on any supported target; `heph auth login` bypassed the exec-runner seam that its own probe uses; and a producer's output files were written before anything knew whether the material had an expiry. compatibility: COMPATIBLE AFTER BUMP. `ABI_SEMVER` 0.8.0 → 0.9.0. Per-platform note for the record: the argument-size limiter now fails a target rather than evicting a credential, and `ARG_MAX` differs by OS, so a very large credential set could spawn on Linux and fail on macOS. Failing loudly beats running unauthenticated, and the ceiling is far out of reach (each presented value is capped at 32 KiB). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jr413rT5LPhzVYo6QFckDm
The `credential` driver hand-parsed its whole config — `Presentation::parse`,
`parse_helper`, and private `string`/`strings`/`str_map` helpers — and then
hand-wrote an 80-line `DriverSchema` to match. Every one of those already
existed: `String::from_spec_value`, `Vec<String>::from_spec_value`,
`#[derive(SpecStruct)]` for a nested object, `#[derive(Spec)]` for the driver's
own config, and the derive's own unknown-key refusal.
The justification in the code was circular. `string()`'s doc comment said the
credential driver hand-parses, so the shared spec decoder never runs for it —
which is true, and is a reason to stop hand-parsing rather than a reason to
reimplement the decoder privately. Two copies of `source_ty` had already
appeared, one in `functions.rs` and one inline in `schema()`.
What changes:
- `Helper` and `Presentation` derive `SpecStruct`. The key set, the per-field
decoding and the unknown-key refusal now come from the field list, so the
parser and the schema cannot drift, and the doc comments are the schema docs.
- `CredentialSpec` derives `Spec`, replacing the hand-written `DriverSchema`.
Only the two fields whose *shape* the derive cannot express carry a `parse`
function: `sources` is an ordered heterogeneous list discriminated by `kind`,
and `ttl` is a duration grammar.
- `Dialect` gets a `FromSpecValue` that delegates to the existing
`Dialect::parse`, so the name table and its message exist once — the same
string is parsed out of `heph __auth-helper <dialect>`'s argv, where there is
no `Value`.
- The three private helpers become one-line delegations to the shared decoders,
keeping only what they were actually for: carrying the field name into the
message as `anyhow` context.
- `functions::{strs, presentation_ty, source_ty}` become the single definition
of those shapes.
Two additions to the shared decoder, both of which any driver can now use: a
blanket `FromSpecValue for Option<T>`, so a nested config struct can have an
optional field without the leaf type knowing about it; and one for
`BTreeMap<String, String>`, strict where the `HashMap` form is lenient — a bare
string does not become `{"": s}`, which is meaningless where the map *is* the
document.
Net −51 lines in the driver, and the parse and the schema become one thing.
Behaviour is unchanged except for two error messages, which now name the
offending key the way every other driver does; the two tests that asserted the
old wording assert the key name instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jr413rT5LPhzVYo6QFckDm
raphaelvigee
pushed a commit
to hephbuild/hephbuild.github.io
that referenced
this pull request
Sep 19, 2026
hephbuild/heph#455 introduced the credential driver, hephbuild/heph#456 the underlying deferred-values mechanism it and #458's ${src://…} both build on. Only the deferred-values half was covered so far; add a Credentials concept page covering the driver, source chain, when vocabulary, presentation shapes and presets, the CLI, and redaction — sourced from heph's docs/CREDENTIALS.md and the plugincredential builtins on master. Cross-link it from the deferred-values page, which already noted a credential's present block as a consumer. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JVc3xVtpWa7kPVKKweR5Dc
3 tasks
raphaelvigee
added a commit
to hephbuild/hephbuild.github.io
that referenced
this pull request
Sep 19, 2026
* docs: document deferred values (${read://…} and ${src://…})
hephbuild/heph#458 added ${src://pkg:name} — the sandbox path of
another target's output — alongside the existing ${read://pkg:name}
contents form. Neither was documented on the site; add a Deferred
values concept page and cross-link it from the exec/bash driver docs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVc3xVtpWa7kPVKKweR5Dc
* docs(deferred-values): fix copy-version example output path
cp's destination argument didn't match the declared out path.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVc3xVtpWa7kPVKKweR5Dc
* docs: document credentials as targets
hephbuild/heph#455 introduced the credential driver, hephbuild/heph#456
the underlying deferred-values mechanism it and #458's ${src://…} both
build on. Only the deferred-values half was covered so far; add a
Credentials concept page covering the driver, source chain, when
vocabulary, presentation shapes and presets, the CLI, and redaction —
sourced from heph's docs/CREDENTIALS.md and the plugincredential
builtins on master. Cross-link it from the deferred-values page, which
already noted a credential's present block as a consumer.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVc3xVtpWa7kPVKKweR5Dc
---------
Co-authored-by: Claude <noreply@anthropic.com>
raphaelvigee
added a commit
that referenced
this pull request
Sep 21, 2026
…master Post-rebase compile fixes only, no behavior change: - plugin-js-cdylib's PluginComponents literal needed the new `runners` field (from feat(execrunner): run targets in a described environment, #425) — js exports none, mirroring plugin-gha-cdylib/plugin-go-cdylib's own empty case. - Every test-only RunRequest literal in plugin-js needed the new `scratch`/`credentials`/`deferred` fields (from feat(scratch) #403/#434, feat(credential) #455, feat(deferred) #456) — empty/default, mirroring the same pattern already used in plugin-oci and builtins' pluginfs/ plugingroup tests. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
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.
Implements the Credentials as Targets design, Phase 1.
A credential becomes a declared target — an identity a build needs, an ordered chain of ways to obtain it in whatever environment the build happens to be running in, and the shape it is presented in. The consumer says one word. It never knows which identity it got, and the cache key never knows there was one.
Before this heph had no concept of a credential, so eight places each invented a partial one:
pass_envvalues hashed into a def (a session token in a durable cache key, printed byinspect def), an OCI registry client silently degrading to anonymous,docker_buildsecrets wired to anenv=source that resolves against a cleared environment,http_fetchwith no auth at all, private Go modules with no credential path anywhere, and a devenv runner capturing its whole environment into a cached, remotely-shippable artifact.The contract
The half heph can enforce, it enforces structurally: a reference is an
Inputwithhashed: false, runtime: false, andhashinis computed overhashedinputs only — so nothing about a credential has a path into a cache key in either direction. The half it cannot — that a presentation carries material and handles but never anything that selects content — is a stated convention, backed byheph auth explainand bycache = False, not by the parser.A probe answers "is this source applicable here?", not "will it succeed?". The first applicable source is the source; an acquire failure is terminal, not a fallthrough — otherwise a misconfigured role in CI silently falls back to whatever ambient identity the runner happened to have.
What ships
credentialdriver:sources,present,ttl. Declares, never executes, never cached — thescratchshape, pointed at identity.env,file,exec,passthrough,oidc) plus a bare address, which is a producer target or a delegation depending on its driver. No per-product source kind and no source preset: that list has no end, and a secret manager isexecwith a field map, or a target.env,files, and ahelperspeaking the AWS credential-process, GCP executable-sourced, Docker, git and Kubernetes ExecCredential protocols. Each is a third-party grammar heph must produce byte-exactly, so each has a pinned conformance test.heph.auth.*presentation presets from a builtinauthprovider, returning plain dicts an author could have written.<home>/auth, 0700/0600) acquisition caches, with a cross-process lock so two invocations do not both open a browser.log.txt— which is packed into the cache and lifted into the failure event, so redacting at render is too late. Best-effort by construction, and stated rather than hidden: material under 8 bytes is not scrubbed, and a secret the target transformed passes through.heph auth status | explain | login | logout, and a hiddenheph __auth-helperthe callback protocols invoke.Two orderings are load-bearing and asserted rather than described: acquisition sits after the cache decision (a hit acquires nothing), and strictly downstream of runner preparation (the devenv
wraprunner's environment capture is a cached, remotely-shippable artifact).example/credential/is runnable with no setup and no real secrets — a two-source chain, a credential whose source is a target, and a build step that leaks its own token so the redaction is visible.docs/CREDENTIALS.mdhas the full design.Review board
hermeticity — NOT HERMETIC on two. One fixed: a helper callback runs inside the target's sandbox and re-walked the chain there, so it could silently pick a different source — and therefore a different identity — than the host chose. The host now writes a pin (material, workspace, winning source index) beside the presented files; the common path is a file read, and a refresh may use only the pinned source.
The other is overruled, deliberately: a literal
present.envvalue can carry something that selects content (AWS_REGION), and nothing folds it into a consumer's key. This is the residual risk the design settled as the user's call — making it structural would cost CI its remote sharing on every private fetch, and rebuild a workspace on a role rename. It needs a cacheable target whose output depends on the identity, which the contract already forbids. The overstated "nowhere to put a region" claim is corrected in the docs and in the parser, and the accepted behaviour is pinned by a test.code-quality and feature-quality — BLOCKED, all fixed, including: the recursion guard compared resolution keys against addresses, so a self-referencing credential deadlocked instead of failing; the process cache was keyed on the address alone, dropping the root identity the disk key is careful to include; the redactor held back
len(longest secret) - 1bytes unconditionally, which blanks an interactive--shellprompt; the acquire subprocess got an empty environment, so the documentedawssource could not resolve on any supported target;heph auth loginbypassed the exec-runner seam its own probe uses; and a producer's output files were written before anything knew whether the material had an expiry.compatibility — COMPATIBLE AFTER BUMP.
ABI_SEMVER0.8.0 → 0.9.0.Per-platform note. The argument-size limiter now fails a target rather than evicting a credential, and
ARG_MAXdiffers by OS, so a very large credential set could spawn on Linux and fail on macOS. Failing loudly beats running unauthenticated, and the ceiling is far out of reach (each presented value is capped at 32 KiB).Second commit: parse the declaration with the derive
The first draft of this driver hand-parsed its whole config —
Presentation::parse,parse_helper, and privatestring/strings/str_maphelpers — and hand-wrote an 80-lineDriverSchemato match. Every one of those already existed:String::from_spec_value,Vec<String>::from_spec_value,#[derive(SpecStruct)]for a nested object,#[derive(Spec)]for the driver's own config, and the derive's own unknown-key refusal.The justification in the code was circular —
string()'s doc comment said the credential driver hand-parses, so the shared spec decoder never runs for it, so it reimplemented the decoder privately. Two copies ofsource_tyhad already appeared.HelperandPresentationderiveSpecStruct;CredentialSpecderivesSpec. The key set, the per-field decoding, the unknown-key refusal and the LSP schema now come from the field list, so the parser and the schema cannot drift, and the doc comments are the schema docs.parsefunction:sources(an ordered heterogeneous list discriminated bykind) andttl(a duration grammar).Dialectgets aFromSpecValuedelegating to the existingDialect::parse, so the name table and its message exist once — the same string is also parsed out ofheph __auth-helper <dialect>'s argv, where there is noValue.anyhowcontext.Two additions any driver can now use: a blanket
FromSpecValue for Option<T>(so an optional field inheritsT's gate rather than needing its own), and one forBTreeMap<String, String>— strict where theHashMapform is lenient, since a bare string becoming{"": s}is meaningless where the map is the document.Net −51 lines in the driver. Behaviour is unchanged except two error messages, which now name the offending key the way every other driver does.
Tests
43 engine e2e tests (
crates/e2e/tests/credential.rs), 13 binary e2e tests for the helper protocols (crates/bin-e2e/tests/auth_helper.rs), plus unit tests across the driver, the store, the redactor and the templates. Fulltstpasses; the only failure is the pre-existingtest_real_docker_default_builder_without_exporters_is_diagnosable, which needs a running Docker daemon.🤖 Generated with Claude Code
https://claude.ai/code/session_01Jr413rT5LPhzVYo6QFckDm
Stack created with GitHub Stacks CLI • Give Feedback 💬