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- 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.
- 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. - 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.
- 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.
- Spill and temp files. Large output written to a predictable, world-readable path is disclosure plus a symlink-race target.
- 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 examplepackages/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 exampledocs/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 itLayered: 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