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
- The agent authenticates to OpenBao as itself.
- It requests a token at the tier its OpenBao identity allows.
- OpenBao uses the App private key to ask GitHub for an installation token.
- GitHub returns a token limited by the App installation and the tier’s scope.
- 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.
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
originremote, 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 loginhas 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 Pass an explicit target when you are outside the repo, or when the remote is
ambiguous:
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: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.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.