<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Muhammad Talha Javed</title>
    <description>The latest articles on DEV Community by Muhammad Talha Javed (@muhammadtalhajaved0303).</description>
    <link>https://dev.to/muhammadtalhajaved0303</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4237654%2Fafd8be5d-0f54-4808-a013-7ab2c7820b24.jpeg</url>
      <title>DEV Community: Muhammad Talha Javed</title>
      <link>https://dev.to/muhammadtalhajaved0303</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/muhammadtalhajaved0303"/>
    <language>en</language>
    <item>
      <title>How I let coding agents use my API key without ever giving it to them</title>
      <dc:creator>Muhammad Talha Javed</dc:creator>
      <pubDate>Tue, 10 Nov 2026 18:19:25 +0000</pubDate>
      <link>https://dev.to/muhammadtalhajaved0303/how-i-let-coding-agents-use-my-api-key-without-ever-giving-it-to-them-1nk0</link>
      <guid>https://dev.to/muhammadtalhajaved0303/how-i-let-coding-agents-use-my-api-key-without-ever-giving-it-to-them-1nk0</guid>
      <description>&lt;p&gt;I am building &lt;a href="https://primal-code-site.vercel.app" rel="noopener noreferrer"&gt;Primal Code&lt;/a&gt;, 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.&lt;/p&gt;

&lt;p&gt;The obvious way to give a CLI your key is to put it in an environment variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="kd"&gt;export&lt;/span&gt; &lt;span class="nv"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sk-ant-...
claude
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works, but the more I looked at it, the less I liked it for an editor that runs agents all day:&lt;/p&gt;

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

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

&lt;h2&gt;
  
  
  The idea in one paragraph
&lt;/h2&gt;

&lt;p&gt;When a pane starts Claude Code, Primal starts a relay just for that launch. The CLI is told that its provider lives at &lt;code&gt;http://127.0.0.1:&amp;lt;port&amp;gt;&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three processes, and only one of them reads the key
&lt;/h2&gt;

&lt;p&gt;Primal is an Electron app, so the work is split across processes, and that split is the first security boundary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Window (the pane)          Main process (key broker)          Shared process (relay)
-----------------          -------------------------          ----------------------
"Start Claude Code   ---&amp;gt;  Is (claude, anthropic) an
 with my Anthropic          allowed pair? Decrypt the
 key, pane 7"               saved key, hand it over    ---&amp;gt;    Start a relay with its own
                            within 10 s                        port and nonce
                     &amp;lt;---  relay id, port, nonce       &amp;lt;---
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The window&lt;/strong&gt; names a pane, a CLI and a provider. It never names or receives a key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The main process&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The shared process&lt;/strong&gt; runs the relays. It outlives a window reload, so reloading the editor does not break a running agent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The table itself is small and deliberate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// primalPaneKeys.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="no"&gt;PANE_KEY_PROVIDERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;freeze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;claude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;freeze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'anthropic'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'deepseek'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'kimi'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'glm'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'minimax'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'custom'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Handing the nonce to Claude Code
&lt;/h2&gt;

&lt;p&gt;Claude Code reads &lt;code&gt;ANTHROPIC_BASE_URL&lt;/code&gt; and &lt;code&gt;ANTHROPIC_AUTH_TOKEN&lt;/code&gt;, so the relay writes them into a settings file for that pane only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// primalPaneKeyRelayService.ts (simplified)&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;settingsWithRelay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'object'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;ANTHROPIC_BASE_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;ANTHROPIC_AUTH_TOKEN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nonce&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;promises&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;settingsWithRelay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mo"&gt;0o600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'wx'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two small details matter here. &lt;code&gt;0o600&lt;/code&gt; means only the current user can read the file, and &lt;code&gt;wx&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens to every request
&lt;/h2&gt;

&lt;p&gt;Each request goes through a fixed list of checks. If any check fails, the request never reaches the provider.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Is this coming from a web page?
&lt;/h3&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_hostGuard&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_refused&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'foreign'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Refused a request from a web page (it sent an Origin header).'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_hostGuard&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;primalKeyRelayIsOwnHost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_refused&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'foreign'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Refused a request for another host name.'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Does it carry the right nonce?
&lt;/h3&gt;

&lt;p&gt;The comparison runs in constant time so that response timing reveals nothing about the nonce. Node's &lt;code&gt;timingSafeEqual&lt;/code&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'utf8'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sentCredential&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nonceHeader&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kc"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_refused&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'nonce'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Refused a request without the pane\'s nonce.'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Is it a model call?
&lt;/h3&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="n"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;protocol&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'anthropic'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'messages'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'POST'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/v1/messages'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'count_tokens'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'POST'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/v1/messages/count_tokens'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'models'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'GET'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/v1/models'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'model'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'GET'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sb"&gt;`/v1/models/${SEGMENT}`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'beta'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'true'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'limit'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'after_id'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Paths are matched exactly and case-sensitively after dot segments resolve, and any &lt;code&gt;%&lt;/code&gt; in the path is refused, so tricks like &lt;code&gt;/v1/messages/../files&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Does the body reach stored data?
&lt;/h3&gt;

&lt;p&gt;A path allowlist is not enough on its own, because a normal-looking &lt;code&gt;POST /v1/messages&lt;/code&gt; can still reference things stored on your account, such as a previously uploaded file (&lt;code&gt;file_id&lt;/code&gt;), 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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;container&lt;/code&gt; or &lt;code&gt;mcp_servers&lt;/code&gt;, so the relay can safely refuse them.&lt;/p&gt;

&lt;p&gt;A few more rules around the body:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Compressed bodies are refused, because a body the relay cannot read is a body it cannot check.&lt;/li&gt;
&lt;li&gt;Bodies over 32 MiB (Anthropic's own request limit) are refused.&lt;/li&gt;
&lt;li&gt;Bodies nested deeper than any real CLI would send are refused before parsing.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5. Swap the credentials
&lt;/h3&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="no"&gt;DROPPED_REQUEST_HEADERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="s1"&gt;'authorization'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'x-api-key'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'x-goog-api-key'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'cookie'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s1"&gt;'x-http-method-override'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'openai-organization'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'openai-project'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s1"&gt;'forwarded'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'via'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'origin'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'referer'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'x-original-url'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'x-host'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// ...plus hop-by-hop headers and every x-forwarded-* and x-envoy-*&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  6. Be careful with what comes back
&lt;/h3&gt;

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

&lt;h2&gt;
  
  
  A relay should not outlive its agent
&lt;/h2&gt;

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

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

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Logs that cannot leak
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;Refused a request carrying file_id&lt;/code&gt;. That gives enough to debug and nothing worth stealing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not solve
&lt;/h2&gt;

&lt;p&gt;It is worth being honest about the limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;An agent can still spend money on your key through normal model calls. The relay limits what the key can do, not how much.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How I built it
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Primal Code is open source on &lt;a href="https://github.com/MuhammadTalhaJaved-0303/primal-code" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. The relay lives in &lt;code&gt;src/vs/platform/primalKeyRelay&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>electron</category>
      <category>typescript</category>
      <category>security</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
