Skip to content

feat(credential): credentials as targets - #455

Merged
raphaelvigee merged 2 commits into
masterfrom
feat/credentials
Sep 19, 2026
Merged

raphaelvigee merged 2 commits into
masterfrom
feat/credentials

Conversation

@raphaelvigee

@raphaelvigee raphaelvigee commented Sep 8, 2026 •

Copy link
Copy Markdown
Member

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.

aws = target(
    name = "aws",
    driver = "credential",
    sources = [
        heph.auth.oidc("github_actions", audience = WIF,
                       present = heph.auth.aws_web_identity(role = ROLE)),
        heph.auth.exec(["aws", "configure", "export-credentials", "--format", "process"]),
    ],
    present = heph.auth.aws_env(),
)

target(name = "deploy", driver = "bash", credentials = [aws], run = "terraform apply")

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

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 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.

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

  • 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. 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 hidden heph __auth-helper the 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 wrap runner'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.md has 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.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.

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) - 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 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. 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).

Second commit: parse the declaration with the derive

The first draft of this driver hand-parsed its whole config — Presentation::parse, parse_helper, and private string/strings/str_map helpers — and 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, so it reimplemented the decoder privately. Two copies of source_ty had already appeared.

  • Helper and Presentation derive SpecStruct; CredentialSpec derives Spec. 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.
  • Only the two fields whose shape the derive cannot express keep a parse function: sources (an ordered heterogeneous list discriminated by kind) and ttl (a duration grammar).
  • Dialect gets a FromSpecValue delegating to the existing Dialect::parse, so the name table and its message exist once — the same string is also parsed out of heph __auth-helper <dialect>'s argv, where there is no Value.
  • The three private helpers become one-line delegations, keeping only what they were for: carrying the field name into the message as anyhow context.

Two additions any driver can now use: a blanket FromSpecValue for Option<T> (so an optional field inherits T's gate rather than needing its own), and one for BTreeMap<String, String> — strict where the HashMap form 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. Full tst passes; the only failure is the pre-existing test_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 💬

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
@raphaelvigee raphaelvigee changed the title feat/credentials feat(credential): credentials as targets Sep 8, 2026
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
raphaelvigee merged commit 4ba6266 into master Sep 19, 2026
28 of 37 checks passed
@raphaelvigee
raphaelvigee deleted the feat/credentials branch September 19, 2026 15:41
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
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
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