Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 8 min read

How I let coding agents use my API key without ever giving it to them

I am building Primal Code, a desktop code editor based on VS Code where you can run coding agent CLIs like Claude Code side by side in terminal panes, using your own API keys. The obvious way to give a CLI your key is t

I am building Primal Code, a desktop code editor based on VS Code where you can run coding agent CLIs like Claude Code side by side in terminal panes, using your own API keys.

The obvious way to give a CLI your key is to put it in an environment variable:

export ANTHROPIC_API_KEY=sk-ant-...
claude

It works, but the more I looked at it, the less I liked it for an editor that runs agents all day:

  • Every child process inherits it. The agent runs shell commands, test scripts and npm install with its postinstall hooks. All of them can read the key.
  • An agent can be talked into leaking it. A prompt injection hidden in a README or an issue only needs the agent to run echo $ANTHROPIC_API_KEY or env once.
  • The key can do far more than chat. A provider key can usually also upload and read files, run batches, and reach stored conversations. A coding agent needs none of that.

So I built a different setup: the CLI never sees the real key. It talks to a small relay on 127.0.0.1 that holds the key and only lets model calls through. This post walks through how that relay works, with code from the repository.

The idea in one paragraph

When a pane starts Claude Code, Primal starts a relay just for that launch. The CLI is told that its provider lives at http://127.0.0.1:<port> and that its "API key" is a random 256-bit value (I call it the nonce). Every request goes to the relay. The relay checks the nonce, checks that the request is a model call, removes every credential the CLI sent, adds the real key, and forwards the request to the real provider. If the nonce leaks, it is useless anywhere except that port on that machine, for model calls only, and only while that pane is running.

Three processes, and only one of them reads the key

Primal is an Electron app, so the work is split across processes, and that split is the first security boundary:

Window (the pane)          Main process (key broker)          Shared process (relay)
-----------------          -------------------------          ----------------------
"Start Claude Code   --->  Is (claude, anthropic) an
 with my Anthropic          allowed pair? Decrypt the
 key, pane 7"               saved key, hand it over    --->    Start a relay with its own
                            within 10 s                        port and nonce
                     <---  relay id, port, nonce       <---
  • The window names a pane, a CLI and a provider. It never names or receives a key.
  • The main process is the only place a key is decrypted, and only for a CLI and provider pair in a fixed table. A Claude subscription token is refused outright, since it cannot be relayed this way.
  • The shared process runs the relays. It outlives a window reload, so reloading the editor does not break a running agent.

The table itself is small and deliberate:

// primalPaneKeys.ts
const PANE_KEY_PROVIDERS = Object.freeze({
  claude: Object.freeze(['anthropic', 'deepseek', 'kimi', 'glm', 'minimax', 'custom']),
});

Handing the nonce to Claude Code

Claude Code reads ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN, so the relay writes them into a settings file for that pane only:

// primalPaneKeyRelayService.ts (simplified)
function settingsWithRelay(settings, handle) {
  const env = typeof settings.env === 'object' && settings.env !== null ? settings.env : {};
  return JSON.stringify({
    ...settings,
    env: { ...env, ANTHROPIC_BASE_URL: handle.baseUrl, ANTHROPIC_AUTH_TOKEN: handle.nonce },
  });
}

await fs.promises.writeFile(path, settingsWithRelay(settings, handle), { mode: 0o600, flag: 'wx' });

Two small details matter here. 0o600 means only the current user can read the file, and wx means the write fails if a file is already there, so nothing can plant a file ahead of time and have Primal write into it. The file is deleted when the relay closes.

What happens to every request

Each request goes through a fixed list of checks. If any check fails, the request never reaches the provider.

1. Is this coming from a web page?

A website you visit can send requests to 127.0.0.1. A browser always adds an Origin header to those, and a DNS rebinding attack shows up as the wrong Host. So both are refused before anything else:

if (this._hostGuard && req.headers.origin !== undefined) {
  return this._refused(res, 'foreign', 'Refused a request from a web page (it sent an Origin header).');
}
if (this._hostGuard && !primalKeyRelayIsOwnHost(req.headers.host, port)) {
  return this._refused(res, 'foreign', 'Refused a request for another host name.');
}

2. Does it carry the right nonce?

The comparison runs in constant time so that response timing reveals nothing about the nonce. Node's timingSafeEqual throws if the two inputs differ in length, so both sides are hashed with SHA-256 first. That also hides the length of whatever was sent:

function digest(value: string): Uint8Array {
  return createHash('sha256').update(value, 'utf8').digest();
}

if (!timingSafeEqual(digest(sentCredential(req.headers, target.nonceHeader)), digest(runtime.nonce))) {
  this._refused(res, 'nonce', 'Refused a request without the pane\'s nonce.');
  return undefined;
}

3. Is it a model call?

Each provider API gets an allowlist of exact methods and paths. For Anthropic, that is four calls: send a message, count tokens, and list or read models. Anything else, such as file uploads or batches, gets a 404 from the relay and never leaves the machine.

anthropic: protocol({
  id: 'anthropic',
  calls: [
    call('messages', 'POST', '/v1/messages'),
    call('count_tokens', 'POST', '/v1/messages/count_tokens'),
    call('models', 'GET', '/v1/models'),
    call('model', 'GET', `/v1/models/${SEGMENT}`),
  ],
  query: [query('beta', 'true'), query('limit'), query('after_id')],
  ...
}),

Paths are matched exactly and case-sensitively after dot segments resolve, and any % in the path is refused, so tricks like /v1/messages/../files or encoded slashes do not get through. The same core already has definitions for OpenAI Responses, OpenAI Chat Completions, Gemini and xAI, ready for Codex, Gemini CLI and Grok Build.

4. Does the body reach stored data?

A path allowlist is not enough on its own, because a normal-looking POST /v1/messages can still reference things stored on your account, such as a previously uploaded file (file_id), a code container or a remote MCP server. The relay parses the JSON body and refuses those fields wherever they appear, while skipping places that may legitimately contain anything, like a tool's schema or a tool call's arguments.

Which fields are allowed was not guessed. I checked what the pinned CLI versions actually send. For example, Claude Code never builds a request with container or mcp_servers, so the relay can safely refuse them.

A few more rules around the body:

  • Compressed bodies are refused, because a body the relay cannot read is a body it cannot check.
  • Bodies over 32 MiB (Anthropic's own request limit) are refused.
  • Bodies nested deeper than any real CLI would send are refused before parsing.
  • The check runs in a worker thread so a large prompt does not block other panes. If the check cannot run, the request is refused. It fails closed, never open.

5. Swap the credentials

Every credential header the CLI sent is dropped, together with headers that could steer a proxy or bill a different project, and then the real key goes into the single header the provider expects:

const DROPPED_REQUEST_HEADERS = new Set([
  'authorization', 'x-api-key', 'x-goog-api-key', 'cookie',
  'x-http-method-override', 'openai-organization', 'openai-project',
  'forwarded', 'via', 'origin', 'referer', 'x-original-url', 'x-host',
  // ...plus hop-by-hop headers and every x-forwarded-* and x-envoy-*
]);

6. Be careful with what comes back

  • Redirects are never followed. A redirect could carry the key to another host.
  • Error responses are read in full (up to 64 KiB), and any exact copy of the key is replaced with [key hidden by Primal] before the CLI sees it. Providers sometimes echo part of the request in their errors.
  • Successful responses stream through chunk by chunk, so the agent still sees tokens as they arrive.

A relay should not outlive its agent

A relay that keeps running after its CLI exits is an open door, so each one closes at the first of these:

  • the CLI's process ends (checked with process.kill(pid, 0) every 5 seconds),
  • no process ID arrives within 60 seconds of starting,
  • the pane is launched again,
  • the key is removed in settings, or now points to a different address,
  • the window closes it.

If you replace a key in settings, the new key is swapped into running relays in place, on the same port and nonce, so your agents keep working without a restart.

Logs that cannot leak

The relay logs fixed sentences only. It never logs the key, the nonce, a request body, a path, a header or the provider's own error text. A log line looks like Refused a request carrying file_id. That gives enough to debug and nothing worth stealing.

What this does not solve

It is worth being honest about the limits:

  • Processes inside the pane can still read the nonce from the settings file. The nonce is not a secret in the same way the key is. It is a ticket that only works on this machine, for model calls, while this pane runs.
  • An agent can still spend money on your key through normal model calls. The relay limits what the key can do, not how much.
  • Claude Code is the first CLI wired up. Codex, Gemini CLI and Grok Build each get their own allowlists only after I check what their pinned versions actually send.

How I built it

Most of this feature was written test first. In the commit history, each fix is usually preceded by a commit that adds the failing test "before the code". The end-to-end test runs a fake agent through the full pipeline rather than a real CLI, so it is fast and does not need a real key. For a component whose whole job is to say no, being able to prove each "no" with a test was the only way I felt confident shipping it.

If you are building anything that hands credentials to an AI agent, I would suggest starting from one question: what is the smallest thing this agent actually needs? Here it was not a key. It was the ability to make model calls, and that is all the relay gives it.

Primal Code is open source on GitHub. The relay lives in src/vs/platform/primalKeyRelay.

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.