core/src/secrets
Secrets stay OUT of the environment the model’s tools inherit (#89).
What happened: .env is read-gate-protected as a FILE (guard.ts, the secret-basename
rule), but bun auto-loads it into process.env, in-process pi inherits that, and every
bash child inherits it from there. A bash env printed TAVILY_API_KEY into a
transcript and a session file. Nothing was wrong with any gate; the secret was not
behind one. Redaction would be cosmetic — the model has already seen the value by the
time anything could scrub output — so the fix is at the source:
quarantineSecrets— at startup, before any session or child process exists, DELETE every secret-shaped variable and every name the secrets files define fromprocess.env. bun’s autoload into THIS process is thereby undone; the spawn spread inenso.tsand the in-process host both start from an environment that carries none.- The secrets file is
.enso/secrets.env, NOT the root.env(ensoSecretsPath). Scrubbing the parent is not enough: bun auto-loads.env*from the cwd into EVERY bun process, so abun -ethe model runs from the repo root re-reads a root.envitself — verified withenv -i, the receipt in PR #98..enso/is a path bun never loads from; a root.envthat still defines a secret is reported as MISPLACED. readSecret— the extensions that need a key (web_search’s Tavily and DeepSeek providers) read the gated file DIRECTLY, at call time. Extension code is trusted; the read gate is for the model’sreadtool. The file is the one source on both surfaces — the web host runs pi in-process, the TUI runs it as a child, and a private in-memory map could never serve the second.
⚠ Provider auth (ANTHROPIC_API_KEY and kin) is NOT served from the secrets file. It
lives in pi’s own auth.json (/login, pi-multi-account — #7); an env-only provider
key would be scrubbed here and pi would say so. The file is for the harness extensions’
keys.
⚠ The name shape is the boundary, on purpose: *_API_KEY, *_TOKEN, *_SECRET,
*_PASSWORD, and their plurals (as whole underscore-delimited words, so TOKENIZER_PATH
is untouched).
A tool that needs a token in a bash child must get it another way — gh has its
keyring — because the environment is exactly the channel this closes.
Secrets
Section titled “Secrets”QuarantineReport
Section titled “QuarantineReport”Defined in: core/src/secrets.ts:196
Properties
Section titled “Properties”misplaced
Section titled “misplaced”
readonlymisplaced: readonlystring[]
Defined in: core/src/secrets.ts:200
Secret-shaped names the ROOT .env defines: bun would autoload them into every bun child. Move them to .enso/secrets.env.
removed
Section titled “removed”
readonlyremoved: readonlystring[]
Defined in: core/src/secrets.ts:198
Names deleted from the environment, sorted. Never values — this is what a startup log line prints.
isSecretShapedName()
Section titled “isSecretShapedName()”isSecretShapedName(
name):boolean
Defined in: core/src/secrets.ts:51
Whether an environment variable NAME looks like it carries a secret. Case-sensitive: env names are.
Parameters
Section titled “Parameters”string
Returns
Section titled “Returns”boolean
knownSecretValues()
Section titled “knownSecretValues()”knownSecretValues(
repoRoot,environment?): readonlystring[]
Defined in: core/src/secrets.ts:129
The secret VALUES this harness knows, for the logger’s value pass (#139).
Redaction by field name cannot see a token quoted inside a message, a guard’s reason, or a
trace payload; this is the set those passes scan for.
Two sources, both ours: .enso/secrets.env — everything in it is a secret by definition —
and the environment’s own secret-shaped names, for a process configured before
quarantineSecrets has run. After quarantine the environment half is empty by design,
and the file half is what still matters: it is where the provider key a tool might echo
actually lives.
⚠ Known values only, never a shape heuristic. “Looks like a secret” over model output redacts a user’s own text on a guess; an exact value redacts exactly what leaked.
Parameters
Section titled “Parameters”repoRoot
Section titled “repoRoot”string
environment?
Section titled “environment?”Readonly<Record<string, string | undefined>> = process.env
Returns
Section titled “Returns”readonly string[]
knownSecretValuesTracking()
Section titled “knownSecretValuesTracking()”knownSecretValuesTracking(
repoRoot,environment?): () => readonlystring[]
Defined in: core/src/secrets.ts:171
The same set as knownSecretValues, as a THUNK that re-reads .enso/secrets.env when the
file changes (#207).
The reader is per call — readSecret re-reads the file every time, so a rotated key is live
for the next web-search call and the refusal message says so — while the logger’s value
alternation was compiled once, at configure. A key added or rotated while a launcher runs was
therefore readable and unredactable for the rest of that process’s life, which is the one
window the design invites. Reproduced before this existed: a warn quoting the rotated value
landed in the day file verbatim.
The identity of the returned array is the CHANGE SIGNAL, not just its contents: unchanged
file, same array, and redactingKnownValues keeps its compiled regex. Callers may rely on
that — it is why the redactor can ask per record without recompiling per record.
The stamp is the file’s mtime, size and inode, so a rotation is seen whether it was an
in-place write or a write-and-rename. mtimeMs is a double carrying the platform’s
nanoseconds (≈0.2 µs of resolution here), so two rotations inside the same millisecond are
still two stamps — and the size and inode catch the rest.
One stat per call is what this costs: measured 1.7 µs, against ~8 µs for the record write
it rides on (.local benchmark, #207). An event-driven watcher would be cheaper and is
deliberately not used — an inotify handle per process, lost across an atomic replace, is a
failure mode a redactor may not have.
Parameters
Section titled “Parameters”repoRoot
Section titled “repoRoot”string
environment?
Section titled “environment?”Readonly<Record<string, string | undefined>> = process.env
Returns
Section titled “Returns”() => readonly string[]
parseDotenv()
Section titled “parseDotenv()”parseDotenv(
text):Map<string,string>
Defined in: core/src/secrets.ts:65
The subset of dotenv this harness needs: NAME=value and export NAME=value lines,
# comments, blank lines, single- or double-quoted values (quotes stripped, \n in
double quotes expanded), unquoted values trimmed.
No interpolation, no multi-line values — a secrets file is one key per line, and anything fancier is a smell.
Parameters
Section titled “Parameters”string
Returns
Section titled “Returns”Map<string, string>
quarantineSecrets()
Section titled “quarantineSecrets()”quarantineSecrets(
environment,repoRoot):QuarantineReport
Defined in: core/src/secrets.ts:219
Remove from environment every secret-shaped name and every name .enso/secrets.env
defines (whatever its shape — everything in the secrets file is a secret by definition),
and report a root .env that still holds secret-shaped names.
⚠ The root .env is NOT ours, so it is judged by shape only (PR #98 review): a project
may keep PORT or PUBLIC_URL there, and deleting those from the process would break
the very config bun loaded them for. Its secret-shaped names are already scrubbed by the
shape rule; they are additionally reported as misplaced so the operator moves them.
⚠ Call it before the first session and before the first child process; both inherit the environment as it is at that moment, and nothing removes a variable from a child after the fact. Idempotent: a second call finds nothing to remove.
Parameters
Section titled “Parameters”environment
Section titled “environment”Record<string, string | undefined>
repoRoot
Section titled “repoRoot”string
Returns
Section titled “Returns”readDotenvFile()
Section titled “readDotenvFile()”readDotenvFile(
path):Map<string,string>
Defined in: core/src/secrets.ts:92
Every name/value in a dotenv-shaped file, or an empty map when there is no file. Read fresh each call.
Parameters
Section titled “Parameters”string
Returns
Section titled “Returns”Map<string, string>
readSecret()
Section titled “readSecret()”readSecret(
repoRoot,name):string|undefined
Defined in: core/src/secrets.ts:102
One secret by name from .enso/secrets.env — the extensions’ read path. Per call, so a rotation needs no restart.
Parameters
Section titled “Parameters”repoRoot
Section titled “repoRoot”string
string
Returns
Section titled “Returns”string | undefined