Runtime Ambient Credential Hygiene
Gitmoot can curate the environment of runtime-agent subprocesses. The feature is off by default and applies to Codex, Claude Code, Kimi Code, omp, and shell runtime adapters in foreground and daemon-worker delivery paths.
[credentials]
env_curation = true
env_passthrough = ["GOCACHE", "NPM_*"]
github = "deny"
model_gateway = false
model_gateway_allow_hosts = ["api.anthropic.com"]
# keychain_path = "/absolute/operator/path/keychain.env"
env_curation = false preserves the historical behavior: runtime commands
inherit the daemon or foreground shell environment, except for the Claude auth
overlay described below. Gitmoot reads this section when it constructs an
adapter for a job; it is not cached and needs no SIGHUP wiring.
Claude runtime auth
~/.gitmoot/runtime-auth.env is the authoritative Claude auth source and is
always mode 0600. Create or rotate it with gitmoot auth set claude; the
token is read from stdin and never appears in argv. Rotation is visible to the
next foreground or daemon delivery without a restart. gitmoot auth status
reports sources, masked fingerprints, mtime, and permissions without making a
runtime call. gitmoot auth probe claude performs a paid fresh-session live
check. gitmoot auth unset claude atomically writes an explicit empty file.
Managed names are CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY, and
ANTHROPIC_AUTH_TOKEN. When the file contains any one of them, Gitmoot injects
all three and blanks absent names so an ambient API key cannot override a
file-selected OAuth token. An explicit-empty file injects nothing and leaves
Claude's ambient/credential-store fallback intact. If the authoritative file is
missing, Gitmoot imports legacy daemon-runtime.env once, or otherwise seeds
from ambient managed variables once. Existing files are never overwritten.
For systemd deployments, keep daemon.env for operational values such as
PATH; do not duplicate Claude secrets there after bootstrap.
Key registry and grants
The shared key source is an operator-owned env file. Its default path
is <base-home>/.config/gitmoot/keychain.env; gitmoot key path prints the
resolved path and validation status. [credentials] keychain_path can select a
different absolute path. The file must be regular, owned by the current uid,
mode exactly 0600, and outside the Gitmoot home and managed checkouts. Gitmoot
revalidates and rereads it at every registration, preflight, and delivery, so an
atomic rewrite rotates values without a daemon restart.
The key commands manage names, modes, and grants only. They never accept or
print values:
gitmoot key path
gitmoot key add SOURCE_API_TOKEN --mode injected
gitmoot key grant SOURCE_API_TOKEN --pipeline nightly-sync
gitmoot key add PARTNER_API_TOKEN --mode proxied
gitmoot key configure PARTNER_API_TOKEN --upstream https://api.partner.example/v1 --auth bearer
gitmoot key grant PARTNER_API_TOKEN --agent researcher
gitmoot key list --json
gitmoot key show SOURCE_API_TOKEN
gitmoot key revoke SOURCE_API_TOKEN --pipeline nightly-sync
gitmoot key rm SOURCE_API_TOKEN --force
rm removes registry metadata and grants, not the operator's file entry.
proxied keys must be configured before grant with an absolute HTTPS upstream
and either bearer or approved custom-header placement. Pipeline shell stages
may request pipeline-granted injected or configured proxied names with
env_keys. Agent stages require both a configured proxied key granted to their
registered seat and an explicit stage selector; injected agent grants are
refused. Gates remain ineligible. Shell resolution includes the pipeline's own
file/defaults and pipeline grants, while agent resolution never does.
A proxied shell or agent stage receives a per-job placeholder in <KEY> and a loopback URL in
GITMOOT_PROXY_<KEY>_URL. Gitmoot pins the destination origin/base path,
rereads the keychain and rechecks the grant for every request, and revokes the
lease when delivery ends. The real value never enters an agent process. Proxied mode hides key bytes; it does not prevent
an authorized child from exercising the credential on the pinned upstream.
Curated upstreams and base paths are part of the model; configure only trusted
services.
For an authenticated RegisterProxy lease pinned to an operator-selected
HTTPS upstream, response filtering prevents a non-malicious upstream's
accidental exact-byte reflection of the credential in response body bytes or
HTTP field names or values from reaching the sandbox. Malicious or compromised
upstreams and transformed application payloads are out of scope. That includes
application-layer compression such as Content-Type: application/gzip without
Content-Encoding; this boundary is not a general data-loss-prevention layer.
Ordinary agent jobs receive no keys, and delegation children do not inherit a pipeline stage's access.
Claude model gateway
model_gateway = true opts Claude deliveries into a daemon-owned loopback
model gateway. The gateway listens only on an OS-assigned 127.0.0.1 port. For
each job, Gitmoot snapshots the selected real credential into daemon memory,
mints a random gitmoot-kc-... placeholder, and gives the Claude child only
that placeholder plus ANTHROPIC_BASE_URL pointing at the loopback listener.
The gateway authenticates the placeholder, replaces it with the real upstream
credential, streams the response, and revokes the placeholder when delivery
ends. Unknown and revoked placeholders receive 401.
Because Claude Code prefers a cached ~/.claude/.credentials.json over the
CLAUDE_CODE_OAUTH_TOKEN env var, the child is also pointed at a per-home
CLAUDE_CONFIG_DIR under the Gitmoot home that mirrors the operator's real
Claude config (settings, skills, commands, CLAUDE.md) but omits the cached
credential. Without this the child would authenticate from the cached
credential and ignore the placeholder — the gateway would then 401 the
delivery (#936). The mirror never contains a credential; the operator's real
config is only read, never modified.
Pipeline injected and generic proxied key delivery is independent of the
Claude gateway and does not alter its model-runtime placeholder flow.
The feature is off by default. It covers local Claude and can issue broker
material to an opted-in remote shell. A populated
runtime-auth.env is required while it is enabled; Gitmoot fails the delivery
instead of falling back to ambient auth, Claude's credential store, or direct
upstream egress. model_gateway_allow_hosts is an exact hostname allowlist and
defaults to api.anthropic.com. The upstream is fixed by Gitmoot, not selected
by the child. Local Claude credentials are snapshotted when the adapter is
built. Remote-shell broker credentials are resolved per request, but only after
mTLS, capability, sandbox/runtime, expiry, and allowlist checks pass.
Curated environment
When curation is enabled, Gitmoot forwards only these exact base names:
PATH,HOME,USER,LOGNAME,SHELLTMPDIR,TMP,TEMP,TZLANG,LANGUAGE,TERM,COLORTERM,NO_COLOR, plus everyLC_*nameXDG_CONFIG_HOME,XDG_CACHE_HOME,XDG_DATA_HOME,XDG_STATE_HOMEGOTOOLCHAINGIT_AUTHOR_NAME,GIT_AUTHOR_EMAIL,GIT_COMMITTER_NAME,GIT_COMMITTER_EMAILGITMOOT_HOME
Runtime-specific additions are:
- Codex:
CODEX_HOME - Claude Code:
CLAUDE_CONFIG_DIR; the managed auth overlay is appended fromruntime-auth.envafter the curated base. - omp: routing plumbing only —
OMP_PROFILE,PI_PROFILE,PI_CODING_AGENT_DIR,PI_SMOL_MODEL,PI_SLOW_MODEL,PI_PLAN_MODEL,OMP_AUTH_BROKER_URL,OMP_AUTH_BROKER_TOKEN— and no raw provider key, so under curation a provider key reaches omp only through omp's own profile auth storage or an explicitenv_passthroughentry. Two caveats: theOMP_AUTH_BROKER_URL/OMP_AUTH_BROKER_TOKENpair is indivisible (omp throws when the URL is set and no token is available, so dropping only the token turns an inherited URL into a hard failure — drop both if you want omp strictly fail-closed), and--profileselects omp's auth/state store without isolating process environment, so a passed-through key is visible to every omp profile this daemon runs. - Kimi Code and shell: no additions. Shell stage variables, pipeline metadata, and the upstream-context file variable are job-owned injections and are appended after the curated base.
SSH_AUTH_SOCK, GH_*, GITHUB_*, proxy variables, and toolchain cache
variables such as GOCACHE are not in the base list. Add non-secret operational
variables with env_passthrough. Each entry is an exact name or a single
trailing-* prefix glob, such as GOCACHE or NPM_*. Names containing = or
NUL and non-trailing * forms are rejected. A bare * entry is legal and
passes every ambient variable through (except denied GitHub credentials) --
that keeps GitHub denial while giving up the rest of the curation boundary,
so treat it as a deliberate escape hatch, not a default.
GitHub denial and opt-out
With curation on, github = "deny" is the default. Ambient GH_* and
GITHUB_* values, including GH_TOKEN, GITHUB_TOKEN,
GH_ENTERPRISE_TOKEN, and GITHUB_ENTERPRISE_TOKEN, are omitted even if an
env_passthrough glob would otherwise match them. Gitmoot injects
GH_PROMPT_DISABLED=1 and points GH_CONFIG_DIR at a fresh, empty, delivery
scratch directory with mode 0700. The directory is removed when the runtime
subprocess completes on success, failure, timeout, or cancellation.
Set github = "inherit" as an explicit opt-out. Gitmoot then forwards ambient
GH_* and GITHUB_* variables and does not inject a GitHub config directory or
disable prompts.
Limits
The model gateway is credential custody, policy, and attribution, not a hard egress boundary. These two limits are deliberate:
- env-var routing is cooperative, not a hard egress boundary — a malicious
agent can unset
ANTHROPIC_BASE_URL, or pointCLAUDE_CONFIG_DIRback at the operator's real~/.claudeto read the cached credential directly. This buys credential custody/policy/attribution against a prompt-injected or misbehaving agent, not enforcement against one that actively evades. - The strong "agents never hold real credentials" claim also requires
Landlock read-rules for
runtime-auth.envand~/.claude/.credentials.json(same-UID read is currently possible) — that is P3.
Codex/Kimi custody, MITM CA support, corporate proxy/NO_PROXY interoperability,
Landlock read restrictions, and hard egress enforcement remain P3. SSH keys,
SSH agents, Git credential helpers, and direct network access are untouched.
Choosing the model-gateway provider
[credentials].model_gateway defaults to Anthropic, reading the credential from
runtime-auth.env. That file is deliberately Claude-only, so a box whose working
model credential belongs to another provider cannot use the gateway at all — and
a Claude Code subscription token is a real instance of this: it authenticates
against api.anthropic.com and still cannot address any /v1/messages model.
model_gateway_key names a proxied keychain key that supplies the upstream
and the credential instead:
[credentials]
model_gateway = true
model_gateway_allow_hosts = ["127.0.0.1"]
model_gateway_key = "OMP_AUTH_GATEWAY"
model_gateway_allow_loopback_upstream = true
gitmoot key add OMP_AUTH_GATEWAY --mode proxied
gitmoot key configure OMP_AUTH_GATEWAY --upstream https://api.example.com --auth bearer
Config names the key; the KEY owns its upstream, auth kind and header. There is
no second copy of those settings to drift, and gitmoot key list shows them.
The key is resolved twice on purpose: at preflight, so a wrong mode or an unconfigured key refuses before a billable sandbox exists, and again on every request, so a revoked or reconfigured key stops working immediately rather than at the next daemon restart.
Scope, stated plainly: the gateway credential is resolved by NAME, not
through the keychain's per-consumer grants. It is daemon-wide configuration, so
any remote job on this daemon can reach the upstream through the gateway. That
matches the pre-existing runtime-auth.env path, which was also daemon-global,
but it means a grant to one agent is not a restriction here. The value itself
never enters a sandbox: the sandbox receives only an mTLS client identity and a
placeholder bearer.
model_gateway_allow_loopback_upstream permits a plaintext http:// upstream
when, and only when, its host is genuinely loopback — gitmoot still refuses any
other host. It exists for a local credential broker such as omp auth-gateway,
where the chain is sandbox → gitmoot over public mTLS → broker over 127.0.0.1.
The plaintext hop never leaves the host. Pair it with
gitmoot key configure --allow-loopback, which applies the same rule when the
key is registered. Both default off.