ADR-0002: Credentials never touch argv, env history, or the clone URL¶
Status: accepted Date: 2026-09-11
Context¶
A single /build request can carry up to three distinct credentials —
a git HTTPS token or SSH private key, and a registry push
username/password — and the service shells out to real git, docker,
and devcontainers CLI subprocesses to actually use them. The obvious,
easy ways to hand a credential to a subprocess are also the ones most
likely to leak it: as a command-line argument (visible in ps output
and process-launch audit logs to any other process on the same host),
embedded directly in a clone URL (https://user:token@host/..., which
git itself then writes into its own on-disk remote config and any log
line that prints the URL), or as a long-lived environment variable
(inherited by every child process, not just the one that needs it).
Decision¶
Every credential a request supplies is written to a scratch file,
scoped to that one request, and removed in a finally block regardless
of success or failure — never passed as a CLI argument, never embedded
in a URL:
- Git HTTPS (
withNetrcEnv): a scratch$HOME/.netrc,chmod 0600, in a fresh scratch$HOME(not just a scratch file — see Credential handling for why the whole$HOMEneeds to be per-request). - Git SSH (
withSshKeyEnv): a scratch private key file + a scratchknown_hosts, referenced viaGIT_SSH_COMMAND's own-i/-o UserKnownHostsFileflags rather than the default~/.ssh/. - Registry push (
withRegistryAuthEnv): a scratchDOCKER_CONFIGoverlay, seeded from the ambient one (so builder-state reuse isn't broken — see 0001) with oneauths[<registry>]entry merged in.
The one genuine environment variable used at all
(GIT_SSH_COMMAND/DOCKER_CONFIG/HOME) points at the scratch file,
it never contains the secret itself.
Consequences¶
- Easier: a credential leak from this service's own process
behavior (not an upstream tool's own bug) is structurally hard to
produce — there's no code path where a secret value is ever a
spawn()argument or a URL string. - Harder: every credential-bearing operation needs its own
mkdtemp/write/chmod/finally-cleanup boilerplate rather than a one-line env var assignment — real, deliberate verbosity in exchange for the leak-resistance above. - A real, accepted constraint:
stdio: "inherit"subprocess output (bothrun()andrunCapture()inbuild.ts) means the actual underlying error text — a registry's real 401 body, git's real "Permission denied" — lands on the parent process's own stdout/stderr, never captured into theErrorobject a caller's HTTP response is built from beyond a generic exit-code message. Choosing not to capture subprocess output more granularly was a deliberate trade against accidentally capturing (and returning to an HTTP caller) some other sensitive line the subprocess happened to print.