Secret obfuscation
Secret obfuscation keeps API keys, tokens and passwords out of the text Lemon sends to model providers. When it is on, native coding sessions replace known secrets in provider-visible messages with placeholders and put the real values back into tool arguments before a tool runs. It is unrelated to the encrypted secret store described in secrets and keychain.
Enabling
It is off by default. Turn it on in ~/.lemon/config.toml or a project's .lemon/config.toml:
[runtime.secret_obfuscation]
enabled = trueLEMON_SECRET_OBFUSCATION_ENABLED=true|false overrides the file setting. The setting is read when a session starts.
What is protected
Secrets are collected once when a session starts:
- Environment variables whose name contains
KEY,SECRET,TOKEN,PASSWORD,PASS,AUTH,CREDENTIAL,PRIVATEorOAUTHfollowed by_or the end of the name (case-insensitive), with a value of at least 8 characters.GITHUB_TOKENandDB_PASSWORD_FILEmatch;AUTHORdoes not. secrets.ymlentries (see below).- A built-in pattern for GitHub (
ghp_,gho_,ghu_,ghs_,ghr_,github_pat_), GitLab (glpat-), OpenAI (sk-,sk-proj-) and Anthropic (sk-ant-) style tokens that appear in session content or tool output.
Before every model call, Lemon rewrites an ephemeral copy of the conversation: user text, assistant text and thinking, tool-call arguments, tool results, shell output, custom messages and summaries. The session file and the transcript you see keep the original text. Image data is not scanned. The requests Lemon sends to summarize history for compaction, branch summaries and project memory are rewritten the same way.
The system prompt is not scanned. Do not put secrets in AGENTS.md, CLAUDE.md or other context files.
Placeholders and restore
Obfuscated values become deterministic placeholders such as $$3P8W5JH1TK2Q$$, $$3P8W5JH1TK2Q:L$$ or $$GITHUBTOKEN_3P8W5JH1TK2Q:L$$. When the model passes a placeholder in tool arguments, Lemon restores the real value before the tool runs, including inside nested arguments. Placeholders the model writes in ordinary assistant text are shown as written.
The 12-character base is an HMAC of the exact secret under a private per-install key in <agent_dir>/secret-placeholder.key (by default ~/.lemon/agent/secret-placeholder.key), created with mode 0600 when first needed; existing regular key files are tightened to 0600 before loading. The key is never sent to a provider, and its own bytes are redacted if a tool reads the file. Because the key persists, a secret gets the same placeholder in every session, which keeps provider prompt caches stable. If the key cannot be written, Lemon logs a warning and uses a key that lasts only for the current runtime process.
The optional suffix describes the casing of the hidden value's ASCII letters:
| Hint | Meaning |
|---|---|
:U | all uppercase |
:L | all lowercase |
:C | first letter uppercase, the rest lowercase |
:M | mixed casing |
Values that differ only in casing get unrelated bases.
secrets.yml
| Level | Path |
|---|---|
| Global | <agent_dir>/secrets.yml (default ~/.lemon/agent/secrets.yml) |
| Project | <project>/.lemon/secrets.yml |
The file is a YAML list. A project entry replaces a global entry with the same content.
| Field | Type | Required | Description |
|---|---|---|---|
type | plain or regex | yes | Match a literal value or a pattern |
content | string | yes | The secret value, or the regex pattern |
mode | obfuscate or replace | no | Default obfuscate |
replacement | string | no | Replacement text for replace mode |
flags | string | no | Regex flags: i, m, s, u (g is implied) |
friendlyName | string | no | Label added to obfuscate placeholders |
# Reversible placeholder (default mode)
- type: plain
content: sk-proj-abc123def456ghi789
# One-way replacement with a fixed string
- type: plain
content: hunter2
mode: replace
replacement: "********"
# Labelled placeholder: $$GITHUBTOKEN_<base>:L$$
- type: plain
content: github_pat_abc123def456
friendlyName: GitHub Token
# Regex, with flags in a separate field or in /pattern/flags form
- type: regex
content: "AKIA[0-9A-Z]{16}"
- type: regex
content: "/bearer\\s+[a-z0-9._~+\\/=-]+/i"
# One-way replacement of every match; without `replacement`, each match is
# replaced by a same-length value the pattern does not match again
- type: regex
content: "postgres://[^\\s]+"
mode: replaceModes:
| Mode | Result | Restored in tool arguments |
|---|---|---|
obfuscate | $$BASE(:hint)$$ or $$LABEL_BASE(:hint)$$ | yes |
replace | replacement, or a deterministic same-length value | no |
obfuscate values and regex matches shorter than 8 characters are left alone, so short words are not redacted. replace mode works on values of any length.
friendlyName is reduced to uppercase letters and digits and capped at 32 characters. It is dropped from a placeholder when it would reveal the secret it labels, another configured secret, or a regex value matched anywhere in the current input or previously encountered in this session, including constituent values redacted inside overlapping spans. This also protects values whose patterns require surrounding context, such as a lookahead. Cached placeholders lose an unsafe label on reuse but retain their restore authority. On a regex entry the label applies to every match, so keep it general.
Patterns use PCRE syntax, which accepts common JavaScript-style patterns.
Invalid entries
- A missing file means no entries.
- A file that cannot be parsed, or is not a list, is ignored with a warning.
- Invalid entries are skipped one by one with a warning:
typemust beplainorregex;contentmust be a non-empty string;mode,replacementandflagsmust have the types above; the pattern must compile; and areplaceregex withoutreplacementis rejected when it matches every possible 1–2 character value, because no stable redaction exists for it. - An invalid
friendlyNameis dropped; the entry is kept.
Order of matching
Every source is matched against the original text, before any replacement. Regex context is evaluated across existing placeholder boundaries; protected tokens stay atomic, while any matching raw bytes around them are redacted. Overlapping matches, including self-overlapping literal occurrences, are redacted as one span, so a smaller literal cannot split a larger credential pattern and expose its remaining bytes. A covering one-way replacement takes precedence; an overlap involving a one-way match without a covering replacement redacts the whole raw span irreversibly. Otherwise the complete raw span gets a reversible placeholder. Minted placeholders are not re-redacted on subsequent passes, although an unsafe friendly label is removed when another secret value is discovered. Unknown token-shaped text with a secret-bearing friendly prefix is obfuscated as raw content, not accepted as an alias. Token-shaped raw values and their bare forms are reserved before minting, so a new placeholder cannot turn unrelated original bytes into a live restore alias.
Before emitting a provider-message batch, Lemon collects regex values and reserves original raw tokens across every eligible text leaf. A later message, content block or nested tool argument therefore protects earlier labels and mints. Patterns still match each original text leaf independently; separate fields are not concatenated into regex input. Collection and rewriting use the same traversal: system messages, images, metadata, argument keys and opaque argument structs remain excluded.
Implementation
CodingAgent.Security.SecretObfuscator builds the session obfuscator, runs as a stage of the pre-LLM transform chain after the untrusted tool-output boundary, and wraps session targets before disclosure freezes its catalog, with restoration outside extension hooks and approval. Direct, deferred tool_invoke and WASM host calls therefore reach the same wrapped implementation, including after reload; caller-supplied extra tools remain directly exposed. Private plan-mode file tools and plan proposals use the same restore/interception order and recheck the plan-directory boundary after hook argument replacement. Repeated plan refreshes rebuild current guards without capturing earlier wrapper generations.