Skip to main content
Status: live. No personal access token sits in the macOS keychain, and no shell function switches between token tiers.
An agent gets an installation token that dies in an hour. It never sees the GitHub App private key that minted it.
A design that keeps several token tiers in the macOS keychain, with a shell function selecting a tier and an interactive password gating the dangerous ones, assumes a human at the keyboard and leaves long-lived credentials at rest — the opposite of what an unattended agent needs. The replacement is a dedicated GitHub App installed on the accounts where automation runs. The martinbaillie/vault-plugin-secrets-github v2.3.2 plugin runs as an OpenBao service secrets engine at github/. OpenBao protects the App private key and performs GitHub’s installation-token exchange. Consumer agents and runners cannot retrieve the private key from OpenBao. A privileged provisioning controller briefly supplies it during bootstrap or rotation. This is a service secrets engine, not GitHub authentication for OpenBao. Agents still authenticate to OpenBao with their existing AppRole identity.

Token flow

  1. The agent authenticates to OpenBao as itself.
  2. It requests a token at the tier its OpenBao identity allows.
  3. OpenBao uses the App private key to ask GitHub for an installation token.
  4. GitHub returns a token limited by the App installation and the tier’s scope.
  5. OpenBao returns the token with a lease. The token expires after one hour and can be revoked early through its lease.

Three tiers

The engine exposes three access tiers. Each is a distinct OpenBao identity with its own minting path, so the tier a caller holds is the whole privilege boundary. Read and admin tiers mint from named permission sets, whose stored scope the request body cannot widen. The write tier is the one identity allowed to use the raw mint endpoint, and its policy pins the request: the repository parameter is required and drawn from a reviewed allowlist, while the parameters that would name a whole installation or an arbitrary permission map are denied. So a write token carries write access to a single named repository, not an account. Adding a repository to the write allowlist is a reviewed configuration change, not something a caller can do at request time. GitHub still enforces the outer ceiling underneath all three tiers: an installation token can never exceed what the App installation itself holds.

Where the write allowlist lives

The allowlist is a single variable in the runtime secret store, read by the OpenBao configuration role and rendered into the write policy when that role converges. Two consequences follow:
  • A grant is not live until the converge runs. Adding a repository to the variable changes nothing on its own — the policy is only rewritten at converge time. A write mint that fails right after an allowlist edit usually means the converge has not happened yet.
  • The list is deliberately not in any public repository. The installations cover private repositories, so an inline list would disclose private repository names. It is referenced here by variable name only.
A refused write mint is a deny, not a bug. There is no client-side workaround, and falling back to a standing personal access token to complete the write defeats the entire model.

What the read tier can see

The read permission set is deliberately wider than “clone and read code.” It also carries read on security events and on dependency vulnerability alerts, so code-scanning findings and dependency alerts are queryable under the ambient read token — no elevation, no claim. Security triage is a read-shaped activity, and making it require a write token would have pushed routine triage onto the privileged path.

Concurrency: agent runs take no lease

Agent runs take no exclusive lease on a repository before minting a write token, by design. A GitHub App installation issues as many simultaneously valid tokens for one repository as callers ask for, so two write tokens on one repository is a normal state and not a conflict. Two runs racing on one branch does not arise: every run clones fresh and pushes its own uniquely named branch, and the remote rejects a stale push anyway. A lease would guard nothing here, while its deadman expiry and check-and-set refusal would block legitimate concurrent runs. Write scope is still narrow. The narrowing is structural rather than temporal: a write token names exactly one allowlisted repository, and it is minted launcher-side, so an agent can never widen its own reach. The interactive workstation helper is the exception that stayed. gh-claim still takes a repository lease, because a human shell is long-lived and mutable in a way a run is not — the same working tree, over hours, with pushes that do not each start from a fresh clone.

Separation from AWS

The GitHub engine and the AWS STS engine are separate logical engines inside one OpenBao security boundary: They have separate mounts, configuration, policies, and leases. Normal API access to one mount does not authorize access to the other. They still share the OpenBao root and admin control plane, Raft storage boundary, hosts, plugin runtime, and outbound HTTPS. A compromise of that shared boundary can affect both engines.

What it replaces

The service engine does not add a generic admin-verb broker. Routine reads and per-repository writes are available to the agent within the reviewed allowlist. An installation-wide token, or a repository outside the allowlist, stays on a human-only, interactive break-glass path under Golden law #3. The password-gated human credential is never injected into automation.

Residual risk, honestly

OpenBao becomes the path to the GitHub App private key. A caller that can use a mint endpoint can mint tokens up to that tier’s scope until its OpenBao access is revoked. The controls are layered: the App installation ceiling, the tier’s scope (a read-only map, or a single allowlisted repository), path- and parameter-scoped OpenBao policy, one-hour GitHub expiry, explicit lease revocation, and OpenBao audit logs. Per-repository write keeps a leaked write token’s blast radius to one repository; the installation-wide admin tier stays human-gated precisely because it is the broad one, which makes the shared OpenBao boundary the target that matters. The GitHub plugin is a third-party Apache-2.0 component. Its signed checksum manifest, binary checksum, compatibility with the deployed OpenBao version, token use, and revocation behavior were verified before the rollout was marked live.

Why gh needed its own cutover

Routing git through a credential helper did not cover the gh CLI, and the gap is structural rather than an oversight. gh reads its token from the environment and does not consult git’s credential helpers at all. A credential helper can therefore be perfectly wired and gh will still never call it. Left alone, gh authenticates from whatever credential it has stored. That is the failure worth naming plainly: a stored token can quietly satisfy a gh pr create against a repository whose write claim the secrets engine had just refused. The deny was real, and the outcome was a write anyway — by a different door. Requiring an explicit mint before every gh invocation would close the gap on paper, but “forgot to run it” is the same failure again. So the shell routes gh through the same engine by default:
  • With no token in the environment, an invocation mints a read token for the account inferred from the origin remote, caches it in shell memory only, and refreshes it well inside its lifetime.
  • A mutating call under a read token fails closed at GitHub with a 403. That is the signal to take a lease with gh-claim — a read is never silently widened into a write.
  • If minting fails, the call fails. It does not fall back to a stored credential. There is no configuration that re-enables such a fallback, and gh auth login has no place in this model.

Getting a token in a dev shell

Most of the time you need no token at all — git and gh reads both resolve one for you. Reach for a helper when you need a write, or when some other tool reads GITHUB_TOKEN from the environment.
1

For git, do nothing

git needs no token of its own. A credential helper answers every HTTPS credential request by minting one scoped to that request, so clone, fetch, and pull just work. A push needs a claim first — see below.
2

Never export a token from .envrc

The directory-environment loader caches its environment dump on disk. A token exported at load time would be written to disk and would outlive the shell — the one thing this model exists to prevent. Mint at call time instead.
3

For gh, reads need nothing; writes need a claim

A plain gh read works with no ceremony — the shell mints a read token for you. Reach for a helper when you need a write, or when another tool reads GITHUB_TOKEN from the environment. All three infer the account and repository from the origin remote, so the bare form usually suffices:
Pass an explicit target when you are outside the repo, or when the remote is ambiguous: gh-read <owner>, gh-claim <owner>/<repo>.A claim takes an exclusive lease on that one repository and installs a trap that releases it when the shell exits. You do not have to remember gh-release; run it only to free the repository sooner.
4

Secret-zero is ambient

The address and identity material the helper needs to authenticate are injected by the runtime secret injector (doppler run). Nothing about bootstrapping is stored locally, and nothing is passed on the command line.
Whatever you mint lives in that shell only. No token is written to disk at any point in this flow.

See also

Secrets

Where GitHub installation tokens sit on the credential security ladder.

Roadmap

How the service-engine rollout fits the wider agent program.

Golden laws

The human-approval law that keeps destructive verbs out of the catalog.

Git transport

The SSH/HTTPS split, and why the credential lookup gates private writes.