Secrets and Credentials Handling
Context
autograph handles several secrets across its lifecycle:
- LLM provider credential -- a token / API key consumed by the agent (OpenCode) at runtime, inside the agent and metalab containers.
- Private npm registry token -- consumed by npm at image build time.
- Future: additional provider keys, CA material, etc.
A secret can leak through several distinct surfaces, each with its own mitigation:
| Surface | Example |
|---|---|
Docker image layer / docker history |
a build ARG / --build-arg value |
| Task runner's resolved environment | task <x> --summary / --verbose dumps dotenv: vars |
| Generated config file | credentials written into a rendered config |
| Container environment | docker inspect on a running container |
| Agent-readable workspace | an .npmrc / token file the sandboxed LLM can read |
Two concrete findings motivate a single, documented policy:
- Good -- the npm registry token is already handled correctly: passed as a
BuildKit secret, never a build arg (see
enterprise-network-proxy.md, Decision 2/4). It never lands in a layer, indocker history, in an env var, or in the final image. The non-sensitive registry URL stays a build arg. - Gap -- the LLM provider token is loaded into the task runner's environment
via
dotenv: .env.project, and is therefore printed in clear bytask <x> --summary/--verbose. Nothing in the task runner actually consumes it -- it is pure pass-through sodocker composecan interpolate it into the container -- yet it is exposed on the most casual surface (shell output, screen-share, CI logs).
Without a coherent policy each new secret risks picking an exposure-prone mechanism, and leaks like the one above recur.
Options considered
Mechanisms grouped by the surface they address.
Option A - Build arg (`ARG` / `--build-arg`)
- Advantage: trivial.
- Disadvantage: leaks via
docker history(created_by) and image layers; same-layer cleanup only closes the filesystem vector, not history. Rejected for secrets.
Option B - BuildKit secret mount (`RUN --mount=type=secret`)
- Advantage: the value is a tmpfs mount for one
RUN; never a build arg, layer, history entry, env var, or part of the final image. - Disadvantage: requires BuildKit (the default for
docker compose build). - Already adopted for the npm registry token.
Option C - Task `dotenv` + compose `environment: ${VAR}` (runtime)
- Advantage: simple; one aggregated env file.
- Disadvantage: the task runner loads the secret, so
--summary/--verboseprint it in clear. Current state for the LLM token. Rejected as a target for secrets.
Option D - Compose `env_file:` (compose-read, not task-loaded)
- Advantage: compose injects the secret into the container without the task
runner ever loading it -> not shown by
--summary. - Disadvantage: still visible via
docker inspect(container env). Partial.
Option E - Native application auth (out of env entirely)
- Advantage: the application authenticates natively (e.g. OpenCode OAuth, or
an API key persisted in
auth.jsonwith restrictive permissions). The secret never enters the task runner, the compose env, or the container's environment variables. Best exposure profile. - Disadvantage: depends on application support; needs a one-time (headless / device) auth flow.
Decision
A per-class policy. Every secret in autograph MUST follow the rule matching its class.
No secret in a Docker image. Build-time secrets use BuildKit secret mounts (
RUN --mount=type=secret), never build args. Builds go throughdocker compose build(BuildKit). Non-sensitive build inputs (e.g. a registry URL) may remain build args. (Already satisfied by the npm registry token.)No secret in the task runner's resolved environment. A secret MUST NOT be loaded into the task runner's
dotenv/vars/envsuch that--summary/--verbosecan print it. If the task runner does not itself consume a secret (pure pass-through to a child process such as compose), it MUST NOT load it.Prefer native application auth for runtime credentials. Where the application supports it, authenticate natively and persist the credential in an app-owned store (e.g.
auth.json, restrictive permissions) rather than injecting it as a container env var.Env-var runtime secrets, if unavoidable, via compose
env_file:-- compose-read (not task-loaded), isolated per role (separate dev and metalab credentials), and documented as a residualdocker inspectexposure.Redact secrets in diagnostics. Any task that prints resolved configuration MUST filter secret-named variables.
Keep secrets out of agent-readable paths -- as far as the agent does not need them. Secrets the agent never consumes (npm registry token, corporate proxy credentials, CA material) MUST NOT land anywhere the sandboxed agent can read (no workspace
.npmrc, no token file under the agent's workspace); hold them build-time-only or in the proxy. The agent's own runtime credential is a distinct case -- see below.
The agent's own credential (threat model)
The LLM provider credential is special: the agent process (OpenCode) needs it
to call the model, and OpenCode runs its tools (bash, read) as the same user,
in the same container. File permissions therefore do not protect it from the
agent -- a tool call can read the rendered config, the native auth.json, or the
process environment. "The agent cannot read its own credential" is not
achievable by file hygiene alone; it requires externalization or privilege
separation. Defense is layered, cheapest to strongest:
- Minimize the surface (rule 3): native auth store (
auth.json, mode 600), not rendered into/workspace/agent/.opencode.local.jsonnor exported as an env var. A casualcat <config>/envno longer reveals it. - Make a read harmless (already in place):
internal: truenetworks + the egress proxy prevent exfiltration to an arbitrary host, so reading the token is not the same as leaking it. - Externalize (target): the credential lives in the egress proxy, which
injects the provider's auth header; the agent container holds no token at all
("cannot read what is not there"). Extends the proxy-held-credential pattern
already used for the private npm registry to the LLM API. Non-trivial
(requires auth injection into the provider's TLS, e.g.
ssl_bump), hence a target, not a current guarantee.
Privilege separation inside the container (OpenCode holding the credential while running tools under a lower-privilege user) is not supported by the current tool model and is not pursued.
Consequences
Positive
- The build-time path is already compliant (rule 1).
- Rule 3 is the durable fix for the LLM-token
--summaryleak: native auth removes the token from the task runner, the compose env, and the container env at once -- satisfying rules 2 and 4 as a side effect. - A clear contract for any future package that introduces a secret: classify it (build vs runtime) and apply the matching rule.
Negative / Risks
- Rule 4 (the fallback) still exposes the secret via
docker inspectto anyone with Docker access -- which is why rule 3 is preferred. - Native auth (rule 3) requires a one-time, possibly interactive, auth step.
Implementation notes
- Build-time variables (what is in place today). The private npm registry is
configured with two inputs of different sensitivity:
AUGR_PROXY_NPM_REGISTRY_URL-- not sensitive -> a normal buildARG.AUGR_PROXY_NPM_REGISTRY_TOKEN-- sensitive -> a BuildKit secret (composebuild.secrets->RUN --mount=type=secret,id=npm_token, read from/run/secrets/...for a singleRUN, written to a throwaway npmrc removed in the same layer). It never becomes a build arg, layer,docker historyentry, env var, or part of the final image. Seeenterprise-network-proxy.md, Decision 2/4. This is the reference pattern for rule 1.
- Runtime credential (the current gap). The LLM provider token is loaded via
dotenv: .env.projectand consumed only by compose interpolation -- a rule 2 violation surfaced bytask --summary. Bringing it into compliance (rule 3, with rule 4 as fallback) is the runtime-credential work; this ADR is the security contract that work must satisfy. - Diagnostics already partially redact the LLM token; rule 5 generalises this.