Certified Agent Harness Engineer
Course sections

M5.7

Credentials and secrets

936 words · about 6 min

Concept

Never in context: a secret in the token stream is one exfiltrating call, one log line, and one telemetry payload from disclosure — and it now lives in a transcript that may be persisted, replayed, and summarized. Never in logs: logs are copied, shipped, and read by people and systems never scoped for secrets.

Both follow from one principle: pass references, not values. Code needing a secret should hold a name and resolve it at the moment of use, so the value's lifetime is one operation and it never lands in a durable structure.

Mechanism: the specific leak paths

Mechanism
  1. Error messages. The commonest leak: a failed HTTP request serializes its request object — headers included — into an exception that is caught, logged, and often shown to the model. Countermeasure: redact at error construction; never serialize whole request objects.
  2. Environment dumps. The agent runs env, or a build tool logs its environment on failure. If the harness's key is in the child's environment it is now in tool output and therefore in context. Countermeasure: scrub the child environment.
  3. Session persistence. Anything in context is in the session log, which gets reloaded, compacted, summarized, and sometimes shipped for debugging. A secret that entered context once is durable.
  4. Telemetry. Traces capture prompts, tool arguments, and errors and route them to a third party. Countermeasure: allow-list what telemetry captures rather than deny-listing what it strips.
  5. Spill and temp files. Large output written to a predictable, world-readable path is disclosure plus a symlink-race target.
  6. Subprocess argv. Arguments are visible in the process table to other users. Pass secrets by environment or stdin, never argv.

Worked example — the scrub

Worked example

packages/subprocess/subprocess/src/index.ts closes leak path 2:

export const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i

export function scrubbedParentEnv(): Record<string, string> {
  const env: Record<string, string> = {}
  for (const [key, value] of Object.entries(process.env)) {
    if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key) && !key.toUpperCase().startsWith(DSH_ENV_PREFIX)) env[key] = value
  }
  return env
}

It is an allow-by-default deny-list on names — the weaker posture, since MY_CREDS survives — applied to a namespace where the naming convention is near-universal, because the alternative (allow-listing environment names) breaks child CLIs needing arbitrary variables. The JSDoc names the failure prevented: "the harness's own DEEPSEEK_API_KEY/secrets must not leak into a spawned process implicitly," while "PATH, HOME, locale, and proxy variables survive, so child CLIs run normally." A scrub that breaks every child gets disabled, so usability is a security property here. Note it matches names, never values.

The case-insensitive flag is not cosmetic: Windows environment names are case-insensitive, so a parent dsh_* entry "would otherwise survive and read back as $env:DSH_* in the child." All DSH_* names drop too, so harness identity does not leak implicitly.

The ordering detail is the one to internalize: explicit caller entries merge after the scrub. A deliberately forwarded credential still works; the scrub removes only the implicit inheritance nobody chose. It is exported as a plain function precisely so spawners that cannot route through the service — node-pty backends, SDK transports — share one definition, since a second divergent copy of a security check is a hole.

docs/defensive-patterns.md covers leak path 5: "Temp/spill files use a private (0700) dir, random names, and exclusive owner-only opens ('wx', 0o600) — predictable world-readable paths invite symlink races and disclosure."

Worked example — references, not values

Worked example

docs/subsystems/credentials.md implements pass-by-reference cleanly. CredentialRef is a branded type whose value is a POSIX environment-variable name, not a secret. Resolution produces ResolvedCredential { value, source } per operation, and consumers "must not cache across operations" — making hot rotation work and bounding every value's lifetime to one call.

The introspection type is the elegant part: CredentialInfo { configured, source?, writable } lets a UI describe a credential's state "without ever exposing a value." Most leaks come from code that needed to know about a secret and, lacking a way to ask, reached for the value. Give that code a value-free question.

Two more details. "An empty stored value is absent everywhere... so a blank never masquerades as a configured secret" — a blank reading as configured produces confusing auth failures and can satisfy a naive presence check. And modifyRecord is the only write path, with cross-process exclusion, "because two processes rotating one refresh token concurrently would otherwise lose whichever wrote first" — a correctness bug presenting as an availability outage exactly when auth must work.

Tradeoffs

Deny-list scrubbing misses creatively-named secrets; allow-listing breaks children. Per-operation resolution costs a lookup per call and forbids caching. Aggressive redaction makes debugging harder, and the pressure to "just log it once to see what's happening" is exactly how secrets reach logs.

How `dsh` does it

How dsh does it

Layered: the scrub for implicit leaks, references for explicit handling, private-directory spill files with owner-only exclusive opens, AGENTS.md's "Never commit credentials," and — structurally — keeping approval/asked/approval/decided/approval/policy log-only, never in the model transcript. That principle generalizes past credentials: not everything the harness records needs to be something the model reads. Worth noting honestly: dsh's only scrub is this environment-name scrub for spawned children; there is no general transcript-redaction module.

How others differ

Most harnesses read provider credentials from environment or config and pass the parent environment through largely intact, so this scrub is a stricter-than-typical default. Hosted agents inject short-lived scoped tokens from a secrets manager — the stronger design, since a leaked token expires and is scoped, at the cost of requiring that infrastructure. If you can issue short-lived scoped tokens instead of forwarding a long-lived key, do that; the scrub is what you build when you cannot.


Check yourself

3 questions on M5.7

Start drill