dev
← All ADRs

Secrets and Credentials Handling

DRAFTEngine2026-10-07

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, in docker 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 by task <x> --summary / --verbose. Nothing in the task runner actually consumes it -- it is pure pass-through so docker compose can 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 / --verbose print 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.json with 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.

  1. No secret in a Docker image. Build-time secrets use BuildKit secret mounts (RUN --mount=type=secret), never build args. Builds go through docker compose build (BuildKit). Non-sensitive build inputs (e.g. a registry URL) may remain build args. (Already satisfied by the npm registry token.)

  2. No secret in the task runner's resolved environment. A secret MUST NOT be loaded into the task runner's dotenv / vars / env such that --summary / --verbose can 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.

  3. 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.

  4. 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 residual docker inspect exposure.

  5. Redact secrets in diagnostics. Any task that prints resolved configuration MUST filter secret-named variables.

  6. 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:

  1. Minimize the surface (rule 3): native auth store (auth.json, mode 600), not rendered into /workspace/agent/.opencode.local.json nor exported as an env var. A casual cat <config> / env no longer reveals it.
  2. Make a read harmless (already in place): internal: true networks + the egress proxy prevent exfiltration to an arbitrary host, so reading the token is not the same as leaking it.
  3. 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 --summary leak: 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 inspect to 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 build ARG.
    • AUGR_PROXY_NPM_REGISTRY_TOKEN -- sensitive -> a BuildKit secret (compose build.secrets -> RUN --mount=type=secret,id=npm_token, read from /run/secrets/... for a single RUN, written to a throwaway npmrc removed in the same layer). It never becomes a build arg, layer, docker history entry, env var, or part of the final image. See enterprise-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.project and consumed only by compose interpolation -- a rule 2 violation surfaced by task --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.