AileronControlPlane

ADR-0026: CLI-Capability Units

StatusAccepted
Date2026-06-20
Tracking#1319, #1324

Context

Before this decision, the credential story for gh lived in two separate central files owned by two separate packages. Its acquisition (how gh auth login runs in a container to mint a token) was a trusted YAML default, gh.yaml, embedded and loaded by the auth domain at internal/auth/capture. Its sealing (which outbound host gets the credential, and how it is injected at the proxy) was a separate trusted default, github.yaml, embedded and loaded by internal/proxybinding. Adding a CLI meant editing two central locations in two packages, and a tool’s identity was declared more than once across those files.

ADR-0025 established the two descriptors this unit composes. A capture descriptor governs the acquisition side, the one-time login that mints a credential the vault did not yet hold. A proxybinding entry governs the sealing side, rendering a stored credential into an outbound request at the proxy boundary. ADR-0019 is the data plane those sealing entries inject at. ADR-0017 established that sandbox images are composed from devcontainer Features carrying customizations.aileron blocks. ADR-0024 draws the line this decision honors. A CLI stays a binary the agent runs, and only its credential is injected. A CLI is not re-represented as an MCP tool.

The gap this decision closes is the missing single home for one CLI’s complete credential story. The acquisition lived in one package and the sealing lived in another, neither tied to the Feature that installs the tool.

Decision

One CLI tool’s complete credential story ships as one CLI-capability unit under customizations.aileron.cli on that tool’s devcontainer Feature. The unit declares the tool’s name, its single vault key, how its credential is acquired, and how each outbound host seals it. The unit is an envelope, not a new credential format.

Four planes

A unit has four planes, three of which carry behavior this umbrella and one of which is forward-declared.

  • Presence is accepted and reserved. A unit may declare presence.builtin naming the built-in image tier the tool is present in (e.g. base). The field is validated for well-formedness, and no behavior branches on it this umbrella.
  • Acquisition is shipped for the device-flow mode. A device-flow unit drives an interactive container login and reads the token back out. The direct-token mode is reserved and rejected. A unit naming it fails validation with an explicit not-yet-implemented error rather than a silent best-effort decode.
  • Sealing is shipped. Each sealing entry seals one outbound host’s credential at the proxy boundary, using the same scheme set and sentinel rules internal/proxybinding already owns.
  • State is reserved. A unit’s state block is forward-declared for cross-sandbox cache persistence. An absent or empty state block is valid, and a non-empty state block fails validation with an explicit not-yet-implemented error.

The carrier

The unit lives under customizations.aileron.cli on a devcontainer Feature. The devcontainer CLI stamps a built image with a devcontainer.metadata OCI label whose value is a JSON array of per-Feature metadata objects. The host reads that label from the resolved sandbox image through internal/cli/unitloader, parses each present customizations.aileron.cli block through the canonical internal/cli parser, and projects the resulting units into the two existing consumers. An image whose label is absent or carries no customizations.aileron.cli is a clean no-op that ships the embedded defaults alone. An image whose label is present but malformed is a loud error, because a present-but-broken unit must not silently ship nothing.

The key single-source derivation

A unit declares its credential identity once, through a single key field in <kind>/<service> form (e.g. user/github). Every per-store vault reference derives from that one field. The acquisition store_at is the key. Every sealing entry’s credential_ref is the key. The credential kind is the first path segment of the key (e.g. user/github derives kind user). The unit must not re-declare store_at, credential_ref, or kind. Re-declaring any derived field, even to a value equal to the derived one, is a load error so the key stays the one source of truth.

Envelope over capture and proxybinding

The unit is a thin envelope over two existing formats, and it adds no new credential decoder. The acquisition block mirrors capture.CaptureDescriptor field for field, and converting a unit to a capture descriptor is a field copy. Each sealing entry mirrors proxybinding.Entry, minus the credential_ref the unit derives from key, and converting a sealing entry to a proxybinding entry is a field copy. Validation is delegated to the canonical capture.(*CaptureDescriptor).Validate and proxybinding.(*Entry).Validate. The scheme set, the sentinel rules, the host-pattern legality, and the arg-vector semantics live in the source packages, and this envelope re-implements none of them. The two consumer loaders remain independent, and a user descriptor can still override either by name. The projection is byte-identical to the former central defaults, pinned by internal/app’s drift guard.

The worked gh example

gh’s complete credential story ships as one unit on its devcontainer Feature at images/sandbox-features/gh/devcontainer-feature.json. The customizations.aileron.cli block is the unit:

name: gh
key: user/github
presence:
  builtin: base
acquisition:
  mode: device-flow
  container_name: aileron-auth-github
  login_cmd: [gh, auth, login, --hostname, github.com, --git-protocol, https, --web]
  token_cmd: [gh, auth, token, --hostname, github.com]
  browser_shim: echo
sealing:
  - host: github.com
    scheme: basic
    emit_mechanism: inject
    username: x-access-token
  - host: api.github.com
    scheme: bearer
    emit_mechanism: sentinel-swap
    sentinel:
      value: ghp_AILERONSENTINELAAAAAAAAAAAAAAAAAAAAA
      env: GH_TOKEN

The single key: user/github is the source of the acquisition store_at, the credential kind of user, and the credential_ref of both sealing entries. The github.com entry seals git-over-HTTPS with HTTP basic auth and injects unconditionally. The api.github.com entry seals gh with a bearer token, planting a non-secret sentinel the proxy swaps for the real credential at egress. The sentinel.value is a placeholder with no authority and is safe to commit.

The cutover

Both central files were removed under #1323. The auth domain’s gh.yaml and proxybinding’s github.yaml no longer exist on main. gh’s unit on its devcontainer Feature is now the single source of truth, and the host projects it into the same two layers the central files used to feed.

Consequences

Positive

  • One CLI’s complete credential story lives in one place, on the Feature that installs the tool. Acquisition and sealing are no longer split across two packages.
  • Adding or changing a CLI’s credential story is one edit on that CLI’s Feature, with no edit to a central file in core.
  • The credential identity is declared once. The single key derives every per-store reference, so a tool’s identity cannot drift between its acquisition and its sealing.
  • The projection is byte-identical to the former central defaults, pinned by a drift guard, so the cutover changed the source of the data and not the data itself.
  • The envelope re-implements no field semantics. Validation delegates to the canonical capture and proxybinding validators, so there is one scheme set and one name contract.

Negative

  • Only the device-flow acquisition mode, sealing, and base-tier presence are live. The direct-token acquisition mode is reserved and rejected.
  • The state plane is reserved and rejected when non-empty, so cross-sandbox cache persistence is not yet expressible in a unit.
  • Presence carries no behavior this umbrella. A non-base presence declaration is accepted as data but drives nothing.
  • The sentinel plant target is an environment-variable name only. A CLI that reads its token from a config file rather than an environment variable is not yet expressible.

Deferred follow-on

The following are explicitly forward-declared and not done in this umbrella.

  • aileron auth <cli> capture-image convergence for a Feature-installed non-base CLI. The standalone aileron auth github command already resolves gh’s acquisition from the same image unit layer the launcher reads, because gh installs in the base image that aileron auth inspects. A CLI installed by a non-base Feature is not yet reachable by aileron auth, so its acquisition cannot converge on its unit this way.
  • The acquisition direct-token mode, for tools that supply a token directly rather than acquiring one through an interactive login (the linear-pp-cli case).
  • The state and cross-sandbox cache plane, absorbing the cache-persistence ask tracked in #1190.
  • Third-party untrusted units and the trust tiers that gate them, so a unit from an untrusted Feature is held to a different bar than a trusted in-house one.
  • File-based sentinel plant, so a CLI that reads its token from a config file rather than an environment variable can be sealed.

References

  • Issue #1319. CLI-capability-unit umbrella
  • Issue #1324. This ADR’s tracking sub-issue
  • Issue #1190. Stateful CLI cache persistence (the deferred state plane)
  • images/sandbox-features/gh/devcontainer-feature.json. The gh unit’s carrier Feature
  • internal/cli. The unit type, parser, validator, and the two conversion adapters
  • internal/cli/unitloader. The host-side bridge from the image devcontainer.metadata label to the two consumers
  • internal/auth/capture. The acquisition consumer the unit projects into
  • internal/proxybinding. The sealing consumer the unit projects into
  • ADR-0017. Sandbox composition through devcontainer Features
  • ADR-0019. The HTTPS data plane the sealing entries inject at
  • ADR-0024. A CLI stays a binary; only its credential is injected
  • ADR-0025. The acquisition and binding descriptors this unit composes